Author SHA1 Message Date
Alireza HassaniandCursor 018f3142ec Show Roll Call to every staff role, including editors.
Owners, admins, editors, and viewers can open it when the business module is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:33:57 +03:30
Alireza HassaniandCursor b25f3cbea0 Gate Roll Call behind the opt-in business module.
Super Admin can enable it per business; nav and pages stay hidden when off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:15:46 +03:30
Alireza HassaniandCursor c4e846564e Add roll-call Start/Stop UX to business and customer dashboards.
Owners/admins can run timed work days, manage tasks in a modal, and stop from the FAB or row control.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:21:07 +03:30
Alireza HassaniandCursor ac287e02cc Sanitize rich-text paste and allow empty business postal codes.
Strip background/font-family/colors on paste while keeping bold, lists, and font size; treat business address postal code as optional.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:42:21 +03:30
Alireza HassaniandCursor eb9b8b9fa3 Accept full meta HTML in Tags & Badges (name is label only).
Admins paste complete <meta> tags as the value; the name field is only a list label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:39:20 +03:30
Alireza HassaniandCursor 68b985dee3 Add Website Tags & Badges management for custom meta and trust seals.
Business admins can manage head meta tags and eNamad/Samandehi footer HTML from the dashboard without hardcoding them in each storefront.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:07:17 +03:30
Alireza HassaniandCursor cb20fe3c40 Fix productWebsiteUrl import path for customer build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 23:02:55 +03:30
Alireza HassaniandCursor 06589600bd Improve customer orders: own list, payment/order status, and transaction details.
Also widen order item modals, link FA product names, and polish related dashboard UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 23:00:48 +03:30
Alireza HassaniandCursor 55071af98c Add پس کرایه checkbox beside shipping price and show it to customers.
Admin can mark a group as freight collect so the amount input is disabled and checkout shows پس کرایه at zero cost.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:37:30 +03:30
Alireza HassaniandCursor 41f21999d2 Show payment and order status with paid/all order tabs.
Default to successful (paid) orders, keep fulfillment status editable, and display gateway payment separately.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:22:24 +03:30
Alireza HassaniandCursor bc1c5f0747 Show transaction ID and gateway refs in order payment details.
Surface Meshkee transaction id, ZarinPal track/authority, card pan, and paid time in the transactions modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:15:12 +03:30
Alireza HassaniandCursor 016dd0b081 Hide payment steps from the admin order status modal.
Admins can only choose fulfillment steps; payment status stays gateway-driven.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:07:34 +03:30
Alireza HassaniandCursor c59ca4444f Add shipping cost management and checkout shipping fees.
Business store shipping groups/static rates UI, and customer checkout that quotes and applies shipping on delivery and payment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 21:52:47 +03:30
Alireza HassaniandCursor 95390933a7 Preserve PNG transparency for static images and show a checkerboard preview.
Static uploads were forced to JPEG with a white fill, which turned white icons into solid squares; PNG sources now stay PNG and CMS previews get a visible background.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 14:25:01 +03:30
Alireza HassaniandCursor 6d85a217e1 Add OTP-first customer signup and product catalog PDF field.
Registration collects name and phone, verifies SMS, then asks for a password only for new Meshkee accounts. Product add/edit supports an optional catalog PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 08:57:08 +03:30
Alireza HassaniandCursor e2950fa20e Add store installments module with calculator and public API docs.
Business dashboard can manage credit methods and preview plans; cropper and module flags support the new flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 23:17:51 +03:30
Alireza HassaniandCursor ff419603bf Add bank account templates and default invoice payment text.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 23:31:58 +03:30
Alireza HassaniandCursor 8465a29ce1 Unify category tree pickers and prefer free-text technical AI fields.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 20:02:05 +03:30
Alireza HassaniandCursor 838e84056e Clarify AI product create does not fill technical data.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 15:42:41 +03:30
Alireza HassaniandCursor 3eb6495f55 Fix Product title mapping in create store items search.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 15:30:07 +03:30
Alireza HassaniandCursor 00f2e0f729 Add custom color picker, move website link to store items, and fix sidebar logo aspect.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 15:28:14 +03:30
Alireza HassaniandCursor 592540cc7e Add multi_value technical inputs and FA AI prompts.
Support duplicatable list fields in form builder and value UIs, and guide fill prompts to put leftover specs in سایر ویژگی‌ها.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 13:50:13 +03:30
Alireza HassaniandCursor 0bbc5bbc12 Note that AI technical prompts fetch product page URLs.
Clarify category and product technical AI prompts so users know pasted links are opened for grounding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 12:40:34 +03:30
Alireza HassaniandCursor 61d145d92e Add used-product flag for store listings.
Merchants can mark items as used on create/edit, see a used badge on cards, and filter the store list for used products.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 12:17:20 +03:30
Alireza HassaniandCursor 32f0dbab63 Add AI fill for product technical data and localize the modal.
Users can fill technical fields from a prefilled prompt, and FA locale shows Persian titles and actions; SEO modal no longer shows a search icon.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 11:53:34 +03:30
Alireza HassaniandCursor 06e2442aaf Flatten transparent product images to white and allow cropper unzoom.
PNG/WebP uploads no longer bake to black on JPEG; product croppers can zoom out with white padding, and SEO tools move into the product overflow menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 08:39:32 +03:30
Alireza HassaniandCursor b743c09e4d Improve product AI UX, category search, and card actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 08:20:34 +03:30
Alireza HassaniandCursor d787a69195 Improve contact submissions UI and compact cropper actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 18:06:54 +03:30
Alireza HassaniandCursor c24f6c06f7 Add logo aspect ratios and businesses renewal sort with low-SMS alert.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 17:17:59 +03:30
Alireza HassaniandCursor e020c74fb1 Prefill DKIM selectors so Email DNS Update works.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:45:09 +03:30
Alireza HassaniandCursor 8630465d34 Allow Email DNS updates with RSA-only DKIM paste.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:26:05 +03:30
Alireza HassaniandCursor c344ddefdc Document SPF v=spf1 mx -all in Email DNS modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:29:53 +03:30
Alireza Hassani d35c2f92b6 Remove invoice action from superadmin Businesses list
Invoices belong on the business dashboard, not the superadmin list row.
2026-09-18 10:41:33 +03:30
238 changed files with 20945 additions and 1979 deletions
+9
View File
@@ -24,6 +24,7 @@ import { StorePage } from './pages/StorePage'
import { StoreItemsPage } from './pages/StoreItemsPage' import { StoreItemsPage } from './pages/StoreItemsPage'
import { StoreSpecialsPage } from './pages/StoreSpecialsPage' import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
import { CustomersPage } from './pages/CustomersPage' import { CustomersPage } from './pages/CustomersPage'
import { RollCallPage } from './pages/RollCallPage'
import { InvoicesPage } from './pages/InvoicesPage' import { InvoicesPage } from './pages/InvoicesPage'
import { IssueInvoicePage } from './pages/IssueInvoicePage' import { IssueInvoicePage } from './pages/IssueInvoicePage'
import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage' import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage'
@@ -37,6 +38,9 @@ import { OrdersPage } from './pages/OrdersPage'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { ShoppingCardsPage } from './pages/ShoppingCardsPage' import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
import { StoreSettingsPage } from './pages/StoreSettingsPage' import { StoreSettingsPage } from './pages/StoreSettingsPage'
import { StoreShippingPage } from './pages/StoreShippingPage'
import { StoreInstallmentsPage } from './pages/StoreInstallmentsPage'
import { AddStoreInstallmentPage } from './pages/AddStoreInstallmentPage'
import { BlogPage } from './pages/BlogPage' import { BlogPage } from './pages/BlogPage'
import { BlogListPage } from './pages/BlogListPage' import { BlogListPage } from './pages/BlogListPage'
import { AddNewBlogPage } from './pages/AddNewBlogPage' import { AddNewBlogPage } from './pages/AddNewBlogPage'
@@ -109,9 +113,14 @@ function App() {
<Route path="store" element={<StorePage />} /> <Route path="store" element={<StorePage />} />
<Route path="store/items" element={<StoreItemsPage />} /> <Route path="store/items" element={<StoreItemsPage />} />
<Route path="store/orders" element={<OrdersPage />} /> <Route path="store/orders" element={<OrdersPage />} />
<Route path="store/shipping" element={<StoreShippingPage />} />
<Route path="store/cards" element={<ShoppingCardsPage />} /> <Route path="store/cards" element={<ShoppingCardsPage />} />
<Route path="store/installments" element={<StoreInstallmentsPage />} />
<Route path="store/installments/new" element={<AddStoreInstallmentPage />} />
<Route path="store/installments/edit/:id" element={<AddStoreInstallmentPage />} />
<Route path="store/settings" element={<StoreSettingsPage />} /> <Route path="store/settings" element={<StoreSettingsPage />} />
<Route path="customers" element={<CustomersPage />} /> <Route path="customers" element={<CustomersPage />} />
<Route path="roll-call" element={<RollCallPage />} />
<Route path="finance" element={<FinancePage />} /> <Route path="finance" element={<FinancePage />} />
<Route path="invoices" element={<InvoicesPage />} /> <Route path="invoices" element={<InvoicesPage />} />
<Route path="invoices/new" element={<IssueInvoicePage />} /> <Route path="invoices/new" element={<IssueInvoicePage />} />
@@ -1,3 +1,8 @@
.modal {
width: min(820px, calc(100vw - 32px));
max-width: 820px;
}
.headerBlock { .headerBlock {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -10,6 +15,17 @@
line-height: 1.45; line-height: 1.45;
} }
.prompt {
width: 100%;
}
.promptHint {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.error { .error {
margin: 0; margin: 0;
padding: 10px 12px; padding: 10px 12px;
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react' import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService' import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories' import { CategoryTreeSelect } from './CategoryTreeSelect'
import { SearchableSelect } from './SearchableSelect'
import modalStyles from './CategoryModal.module.css' import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css' import aiStyles from '../styles/ai.module.css'
import styles from './AddProductByAiModal.module.css' import styles from './AddProductByAiModal.module.css'
@@ -26,26 +27,28 @@ export function AddProductByAiModal({
onClose, onClose,
onCreated, onCreated,
}: AddProductByAiModalProps) { }: AddProductByAiModalProps) {
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast() const { showToast } = useToast()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('') const [categoryId, setCategoryId] = useState('')
const [name, setName] = useState('') const [prompt, setPrompt] = useState('')
const [language, setLanguage] = useState<ProductAiLanguage>('en') const [language, setLanguage] = useState<ProductAiLanguage>(locale === 'fa' ? 'fa' : 'en')
const [isLoadingCategories, setIsLoadingCategories] = useState(false) const [isLoadingCategories, setIsLoadingCategories] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const categoryOptions = useMemo(() => flattenCategories(categories), [categories]) const promptDir = language === 'fa' ? 'rtl' : 'ltr'
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMounted(true) setMounted(true)
setClosing(false) setClosing(false)
setCategoryId('') setCategoryId('')
setName('') setPrompt('')
setLanguage('en') setLanguage(locale === 'fa' ? 'fa' : 'en')
setError('') setError('')
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
@@ -55,7 +58,7 @@ export function AddProductByAiModal({
}, ANIMATION_MS) }, ANIMATION_MS)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [open, mounted]) }, [open, mounted, locale])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -86,7 +89,7 @@ export function AddProductByAiModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load categories.') setError(t('products.ai.loadCategoriesError'))
} }
} finally { } finally {
setIsLoadingCategories(false) setIsLoadingCategories(false)
@@ -95,7 +98,7 @@ export function AddProductByAiModal({
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
if (!categoryId || !name.trim()) return if (!categoryId || !prompt.trim()) return
setIsSubmitting(true) setIsSubmitting(true)
setError('') setError('')
@@ -103,7 +106,7 @@ export function AddProductByAiModal({
try { try {
const result = await createProductByAi({ const result = await createProductByAi({
categoryId, categoryId,
name: name.trim(), name: prompt.trim(),
language, language,
}) })
showToast(result.message, 'success') showToast(result.message, 'success')
@@ -115,7 +118,7 @@ export function AddProductByAiModal({
} else if (err instanceof Error) { } else if (err instanceof Error) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to create product with AI.') setError(t('products.ai.createError'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -131,7 +134,7 @@ export function AddProductByAiModal({
role="presentation" role="presentation"
> >
<div <div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`} className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="add-product-ai-title" aria-labelledby="add-product-ai-title"
@@ -139,19 +142,16 @@ export function AddProductByAiModal({
<div className={`${modalStyles.header} ${styles.header}`}> <div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}> <div className={styles.headerBlock}>
<h2 id="add-product-ai-title" className={modalStyles.title}> <h2 id="add-product-ai-title" className={modalStyles.title}>
Add Product by AI {t('products.ai.title')}
</h2> </h2>
<p className={styles.subtitle}> <p className={styles.subtitle}>{t('products.ai.subtitle')}</p>
AI will generate product content and technical data. You can add images and
variations afterward.
</p>
</div> </div>
<button <button
type="button" type="button"
className={modalStyles.closeBtn} className={modalStyles.closeBtn}
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
aria-label="Close" aria-label={t('common.close')}
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -159,43 +159,45 @@ export function AddProductByAiModal({
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}> <form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label>Category</label> <label htmlFor="ai-product-category">{t('products.ai.category')}</label>
<SearchableSelect <CategoryTreeSelect
options={categoryOptions} categories={categories}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder="Search and select category..." placeholder={t('products.ai.categoryPlaceholder')}
allowEmpty={false} allowEmpty={false}
dropdownVariant="inFlow" disabled={isSubmitting || isLoadingCategories}
/> />
</div> </div>
<div className={modalStyles.field}> <div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
<label htmlFor="ai-product-name">Product name</label> <label htmlFor="ai-product-prompt">{t('products.ai.prompt')}</label>
<input <textarea
id="ai-product-name" id="ai-product-prompt"
value={name} value={prompt}
onChange={(e) => setName(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={ placeholder={t('products.ai.promptPlaceholder')}
language === 'fa' ? 'نام محصول را وارد کنید' : 'Enter product name' dir={promptDir}
} className={`${aiStyles.aiPromptTextarea} ${styles.prompt}${language === 'fa' ? ' faText' : ''}`}
dir={language === 'fa' ? 'rtl' : 'ltr'}
className={language === 'fa' ? 'faText' : undefined}
disabled={isSubmitting} disabled={isSubmitting}
rows={14}
required required
minLength={2}
lang={language === 'fa' ? 'fa' : 'en'}
/> />
<p className={styles.promptHint}>{t('products.ai.promptHint')}</p>
</div> </div>
<div className={`${modalStyles.field} ${fieldStyles.field}`}> <div className={`${modalStyles.field} ${fieldStyles.field}`}>
<label htmlFor="ai-product-language">Language</label> <label htmlFor="ai-product-language">{t('products.ai.language')}</label>
<select <select
id="ai-product-language" id="ai-product-language"
value={language} value={language}
onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)} onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)}
disabled={isSubmitting} disabled={isSubmitting}
> >
<option value="en">English</option> <option value="en">{t('products.ai.language.en')}</option>
<option value="fa">Persian (Farsi)</option> <option value="fa">{t('products.ai.language.fa')}</option>
</select> </select>
</div> </div>
@@ -208,24 +210,24 @@ export function AddProductByAiModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className={aiStyles.aiBtn} className={aiStyles.aiBtn}
disabled={ disabled={
isSubmitting || isLoadingCategories || !categoryId || !name.trim() isSubmitting || isLoadingCategories || !categoryId || !prompt.trim()
} }
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Sparkles size={16} /> <Sparkles size={16} />
Creating... {t('products.ai.creating')}
</> </>
) : ( ) : (
<> <>
<Sparkles size={16} /> <Sparkles size={16} />
Create product {t('products.ai.create')}
</> </>
)} )}
</button> </button>
@@ -3,10 +3,10 @@ import { createPortal } from 'react-dom'
import { Plus, Search, X } from 'lucide-react' import { Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages' import { COLOR_PRESETS, getColorHex, normalizeColorName } from '../data/colorPresets'
import { COLOR_PRESETS } from '../data/colorPresets'
import type { Variation, VariationType } from '../types/variation' import type { Variation, VariationType } from '../types/variation'
import { SIZE_OPTIONS, normalizeColorNames } from '../types/variation' import { SIZE_OPTIONS } from '../types/variation'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './VariationsModal.module.css' import styles from './VariationsModal.module.css'
@@ -18,15 +18,42 @@ interface AddVariationModalProps {
initial?: Variation | null initial?: Variation | null
} }
interface SelectedColor {
label: string
hex: string
}
const ANIMATION_MS = 220 const ANIMATION_MS = 220
const DEFAULT_CUSTOM_HEX = '#64748B'
function seedCustomValues(values: string[]) { function seedCustomValues(values: string[]) {
const trimmed = values.map((value) => value.trim()).filter(Boolean) const trimmed = values.map((value) => value.trim()).filter(Boolean)
return trimmed.length ? trimmed : [''] return trimmed.length ? trimmed : ['']
} }
function colorLabelKey(name: string): BusinessMessageKey { function normalizeHex(value: string): string {
return `categories.variations.color.${name}` as BusinessMessageKey const trimmed = value.trim()
if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed.toUpperCase()
if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed.toUpperCase()}`
return DEFAULT_CUSTOM_HEX
}
function colorsFromInitial(initial: Variation): SelectedColor[] {
return initial.values
.map((raw, index) => {
const label = raw.trim()
if (!label) return null
const canonical = normalizeColorName(label)
const provided = initial.colorHexes?.[index]
const hex =
(provided && /^#[0-9a-fA-F]{6}$/.test(provided.trim())
? provided.trim().toUpperCase()
: null) ??
getColorHex(canonical) ??
DEFAULT_CUSTOM_HEX
return { label: canonical, hex }
})
.filter((item): item is SelectedColor => item !== null)
} }
export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) { export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) {
@@ -38,10 +65,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [type, setType] = useState<VariationType>('color') const [type, setType] = useState<VariationType>('color')
const [customName, setCustomName] = useState('') const [customName, setCustomName] = useState('')
const [selectedColors, setSelectedColors] = useState<string[]>([]) const [selectedColors, setSelectedColors] = useState<SelectedColor[]>([])
const [selectedSizes, setSelectedSizes] = useState<string[]>([]) const [selectedSizes, setSelectedSizes] = useState<string[]>([])
const [customValues, setCustomValues] = useState<string[]>(['']) const [customValues, setCustomValues] = useState<string[]>([''])
const [colorQuery, setColorQuery] = useState('') const [colorQuery, setColorQuery] = useState('')
const [customColorName, setCustomColorName] = useState('')
const [customColorHex, setCustomColorHex] = useState(DEFAULT_CUSTOM_HEX)
const [customColorError, setCustomColorError] = useState('')
const valueInputRefs = useRef<(HTMLInputElement | null)[]>([]) const valueInputRefs = useRef<(HTMLInputElement | null)[]>([])
const pendingFocusIndex = useRef<number | null>(null) const pendingFocusIndex = useRef<number | null>(null)
@@ -56,12 +86,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
setMounted(true) setMounted(true)
setClosing(false) setClosing(false)
setColorQuery('') setColorQuery('')
setCustomColorName('')
setCustomColorHex(DEFAULT_CUSTOM_HEX)
setCustomColorError('')
if (initial) { if (initial) {
setType(initial.type) setType(initial.type)
setCustomName(initial.type === 'custom' ? initial.name : '') setCustomName(initial.type === 'custom' ? initial.name : '')
setSelectedColors( setSelectedColors(initial.type === 'color' ? colorsFromInitial(initial) : [])
initial.type === 'color' ? normalizeColorNames(initial.values) : [],
)
setSelectedSizes(initial.type === 'size' ? [...initial.values] : []) setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
setCustomValues( setCustomValues(
initial.type === 'custom' ? seedCustomValues(initial.values) : [''], initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
@@ -92,26 +123,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
return () => document.removeEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose]) }, [mounted, closing, onClose])
const selectedColorLabels = useMemo(
() => new Set(selectedColors.map((color) => color.label.toLowerCase())),
[selectedColors],
)
const filteredColors = useMemo(() => { const filteredColors = useMemo(() => {
const q = colorQuery.trim().toLowerCase() const q = colorQuery.trim().toLowerCase()
if (!q) return COLOR_PRESETS if (!q) return COLOR_PRESETS
return COLOR_PRESETS.filter((color) => { return COLOR_PRESETS.filter((color) => {
const en = color.name.toLowerCase() const en = color.name.toLowerCase()
const fa = t(colorLabelKey(color.name)).toLowerCase() const fa = localizeVariationOptionLabel(color.name, t).toLowerCase()
return en.includes(q) || fa.includes(q) return en.includes(q) || fa.includes(q)
}) })
}, [colorQuery, t]) }, [colorQuery, t])
if (!mounted) return null if (!mounted) return null
function toggleOption(value: string, selected: string[], setSelected: (v: string[]) => void) { function togglePresetColor(name: string, hex: string) {
setSelected( setSelectedColors((prev) => {
selected.includes(value) const exists = prev.some((item) => item.label === name)
? selected.filter((item) => item !== value) if (exists) return prev.filter((item) => item.label !== name)
: [...selected, value], return [...prev, { label: name, hex }]
})
setCustomColorError('')
}
function removeSelectedColor(label: string) {
setSelectedColors((prev) => prev.filter((item) => item.label !== label))
}
function toggleSize(value: string) {
setSelectedSizes((prev) =>
prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value],
) )
} }
function addCustomColor() {
const label = customColorName.trim()
if (!label) {
setCustomColorError(t('categories.variations.customColorNameRequired'))
return
}
const hex = normalizeHex(customColorHex)
const key = label.toLowerCase()
const canonical = normalizeColorName(label)
const alreadySelected =
selectedColorLabels.has(key) ||
selectedColorLabels.has(canonical.toLowerCase())
if (alreadySelected) {
setCustomColorError(t('categories.variations.customColorExists'))
return
}
setSelectedColors((prev) => [...prev, { label, hex }])
setCustomColorName('')
setCustomColorHex(DEFAULT_CUSTOM_HEX)
setCustomColorError('')
}
function updateCustomValue(index: number, value: string) { function updateCustomValue(index: number, value: string) {
setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v))) setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v)))
} }
@@ -142,7 +214,12 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
e.preventDefault() e.preventDefault()
if (type === 'color') { if (type === 'color') {
onSubmit({ name: t('categories.variations.type.color'), type: 'color', values: selectedColors }) onSubmit({
name: t('categories.variations.type.color'),
type: 'color',
values: selectedColors.map((color) => color.label),
colorHexes: selectedColors.map((color) => color.hex),
})
} else if (type === 'size') { } else if (type === 'size') {
onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes }) onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes })
} else { } else {
@@ -224,27 +301,22 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
</div> </div>
{selectedColors.length > 0 ? ( {selectedColors.length > 0 ? (
<div className={styles.selectedColors}> <div className={styles.selectedColors}>
{selectedColors.map((color) => { {selectedColors.map((color) => (
const preset = COLOR_PRESETS.find((item) => item.name === color) <button
return ( key={color.label}
<button type="button"
key={color} className={`${styles.chip} ${styles.chipSelected}`}
type="button" onClick={() => removeSelectedColor(color.label)}
className={`${styles.chip} ${styles.chipSelected}`} >
onClick={() => <span
toggleOption(color, selectedColors, setSelectedColors) className={styles.colorSwatch}
} style={{ backgroundColor: color.hex }}
> aria-hidden="true"
<span />
className={styles.colorSwatch} {localizeVariationOptionLabel(color.label, t)}
style={{ backgroundColor: preset?.hex ?? '#94a3b8' }} <X size={12} aria-hidden="true" />
aria-hidden="true" </button>
/> ))}
{t(colorLabelKey(color))}
<X size={12} aria-hidden="true" />
</button>
)
})}
</div> </div>
) : null} ) : null}
<div className={styles.chipGrid}> <div className={styles.chipGrid}>
@@ -256,22 +328,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
key={color.name} key={color.name}
type="button" type="button"
className={`${styles.chip} ${ className={`${styles.chip} ${
selectedColors.includes(color.name) ? styles.chipSelected : '' selectedColorLabels.has(color.name.toLowerCase())
? styles.chipSelected
: ''
}`} }`}
onClick={() => onClick={() => togglePresetColor(color.name, color.hex)}
toggleOption(color.name, selectedColors, setSelectedColors)
}
> >
<span <span
className={styles.colorSwatch} className={styles.colorSwatch}
style={{ backgroundColor: color.hex }} style={{ backgroundColor: color.hex }}
aria-hidden="true" aria-hidden="true"
/> />
{t(colorLabelKey(color.name))} {localizeVariationOptionLabel(color.name, t)}
</button> </button>
)) ))
)} )}
</div> </div>
<div className={styles.customColorBlock}>
<span className={styles.customColorTitle}>
{t('categories.variations.addCustomColor')}
</span>
<div className={styles.customColorRow}>
<input
type="text"
className={styles.customColorNameInput}
value={customColorName}
onChange={(e) => {
setCustomColorName(e.target.value)
if (customColorError) setCustomColorError('')
}}
placeholder={t('categories.variations.customColorNamePlaceholder')}
aria-label={t('categories.variations.customColorName')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addCustomColor()
}
}}
/>
<label className={styles.customColorPickerLabel}>
<span className={styles.srOnly}>{t('categories.variations.customColorHex')}</span>
<input
type="color"
className={styles.customColorPicker}
value={customColorHex}
onChange={(e) => setCustomColorHex(normalizeHex(e.target.value))}
/>
</label>
<button
type="button"
className={styles.addValueBtn}
onClick={addCustomColor}
>
<Plus size={14} />
{t('categories.variations.addCustomColorBtn')}
</button>
</div>
{customColorError ? (
<p className={styles.customColorError}>{customColorError}</p>
) : null}
</div>
</div> </div>
)} )}
@@ -284,7 +401,7 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
key={size} key={size}
type="button" type="button"
className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`} className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`}
onClick={() => toggleOption(size, selectedSizes, setSelectedSizes)} onClick={() => toggleSize(size)}
> >
{size} {size}
</button> </button>
+3 -1
View File
@@ -115,6 +115,7 @@ export function BrandModal({
onChange={setImage} onChange={setImage}
outputFormat="png" outputFormat="png"
accept="image/png" accept="image/png"
minZoom={0.4}
uploadLabel={t('brands.modal.uploadLogo')} uploadLabel={t('brands.modal.uploadLogo')}
hint={t('brands.modal.logoHint')} hint={t('brands.modal.logoHint')}
changeLabel={t('brands.modal.changeLogo')} changeLabel={t('brands.modal.changeLogo')}
@@ -142,7 +143,8 @@ export function BrandModal({
id="nameEn" id="nameEn"
name="nameEn" name="nameEn"
type="text" type="text"
dir={isFa ? 'rtl' : 'ltr'} dir="ltr"
lang="en"
placeholder={t('brands.modal.nameEnPlaceholder')} placeholder={t('brands.modal.nameEnPlaceholder')}
value={nameEn} value={nameEn}
onChange={(e) => setNameEn(e.target.value)} onChange={(e) => setNameEn(e.target.value)}
@@ -1,5 +1,6 @@
.modal { .modal {
max-width: 560px; width: min(820px, calc(100vw - 32px));
max-width: 820px;
} }
.headerBlock { .headerBlock {
@@ -12,7 +12,11 @@ export function buildCategoryAiPrompt(locale: 'fa' | 'en') {
if (locale === 'fa') { if (locale === 'fa') {
return `یک درخت دسته‌بندی محصول کاربردی برای فروشگاه من بساز. return `یک درخت دسته‌بندی محصول کاربردی برای فروشگاه من بساز.
نام انگلیسی، نام فارسی با خط پارسی، و توضیح کوتاه انگلیسی را برای هر دسته اضافه کن. برای هر دسته این موارد را مشخص کن:
- نام انگلیسی
- نام فارسی با خط پارسی
- توضیح کوتاه فارسی
۳ تا ۵ دسته اصلی با زیردسته‌های مرتبط بساز تا مرور برای خریداران راحت‌تر شود.` ۳ تا ۵ دسته اصلی با زیردسته‌های مرتبط بساز تا مرور برای خریداران راحت‌تر شود.`
} }
@@ -136,7 +140,7 @@ export function CategoryAiPromptModal({
className={aiStyles.aiPromptTextarea} className={aiStyles.aiPromptTextarea}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={8} rows={14}
disabled={isRunning} disabled={isRunning}
required required
lang={isFa ? 'fa' : 'en'} lang={isFa ? 'fa' : 'en'}
@@ -93,6 +93,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
position: relative;
} }
.field label { .field label {
+14 -9
View File
@@ -4,8 +4,8 @@ import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import type { Category, CategoryFormData } from '../types/category' import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories' import { getDescendantIds } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect' import { CategoryTreeSelect } from './CategoryTreeSelect'
import { SeoMetaFields } from './SeoMetaFields' import { SeoMetaFields } from './SeoMetaFields'
import styles from './CategoryModal.module.css' import styles from './CategoryModal.module.css'
@@ -88,9 +88,12 @@ export function CategoryModal({
}) })
} }
const parentOptions = flattenCategories( const parentCategories = editingCategory
categories.filter((category) => category.id !== editingCategory?.id), ? categories.filter(
) (category) =>
!getDescendantIds(categories, editingCategory.id).includes(category.id),
)
: categories
return createPortal( return createPortal(
<div <div
@@ -123,12 +126,13 @@ export function CategoryModal({
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}> <form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}> <div className={styles.field}>
<label>{t('categories.modal.parent')}</label> <label>{t('categories.modal.parent')}</label>
<SearchableSelect <CategoryTreeSelect
options={parentOptions} categories={parentCategories}
value={parentId} value={parentId}
onChange={setParentId} onChange={setParentId}
placeholder={t('categories.select.search')} placeholder={t('categories.select.search')}
dropdownVariant="inFlow" allowEmpty
disabled={isSubmitting}
/> />
</div> </div>
@@ -154,7 +158,8 @@ export function CategoryModal({
id="nameEn" id="nameEn"
name="nameEn" name="nameEn"
type="text" type="text"
dir={isFa ? 'rtl' : 'ltr'} dir="ltr"
lang="en"
placeholder={t('categories.modal.nameEnPlaceholder')} placeholder={t('categories.modal.nameEnPlaceholder')}
value={nameEn} value={nameEn}
onChange={(e) => setNameEn(e.target.value)} onChange={(e) => setNameEn(e.target.value)}
@@ -75,6 +75,8 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
direction: ltr;
unicode-bidi: isolate;
} }
.separator { .separator {
@@ -1,9 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useMemo } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { UserProductCategoryOption } from '../services/userProductsService' import type { UserProductCategoryOption } from '../services/userProductsService'
import styles from './CategorySearchSelect.module.css' import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
export interface FlatCategoryOption extends UserProductCategoryOption { export interface FlatCategoryOption extends UserProductCategoryOption {
depth: number depth: number
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
dropdownVariant?: 'overlay' | 'inFlow' dropdownVariant?: 'overlay' | 'inFlow'
} }
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) { /** @deprecated Prefer CategoryTreeSelect; kept for user-product category API shape. */
if (isFa) {
return category.nameFa?.trim() || category.name
}
return category.name || category.nameFa?.trim() || ''
}
export function flattenCategoryTree( export function flattenCategoryTree(
categories: UserProductCategoryOption[], categories: UserProductCategoryOption[],
): FlatCategoryOption[] { ): FlatCategoryOption[] {
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
} }
for (const list of byParent.values()) { for (const list of byParent.values()) {
list.sort((a, b) => { list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
return aLabel
})
} }
const result: FlatCategoryOption[] = [] const result: FlatCategoryOption[] = []
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
function walk(parentId: string | null, depth: number) { function walk(parentId: string | null, depth: number) {
const children = byParent.get(parentId) ?? [] const children = byParent.get(parentId) ?? []
for (const child of children) { for (const child of children) {
if (!ids.has(child.id)) continue
result.push({ ...child, depth }) result.push({ ...child, depth })
walk(child.id, depth + 1) walk(child.id, depth + 1)
} }
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
walk(null, 0) walk(null, 0)
const rooted = new Set(result.map((item) => item.id))
for (const category of categories) { for (const category of categories) {
if (category.parentId && !ids.has(category.parentId)) { if (!rooted.has(category.id)) {
if (!result.some((item) => item.id === category.id)) { result.push({ ...category, depth: 0 })
result.push({ ...category, depth: 0 })
walk(category.id, 1)
}
} }
} }
return result return result
} }
function filterCategoryTree( function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
flat: FlatCategoryOption[], return options.map((option) => ({
query: string, id: option.id,
): FlatCategoryOption[] { parentId: option.parentId,
const q = query.trim().toLowerCase() nameEn: option.name,
if (!q) return flat nameFa: option.nameFa?.trim() || '',
}))
const byId = new Map(flat.map((item) => [item.id, item]))
const matchedIds = new Set<string>()
for (const item of flat) {
const nameEn = item.name.toLowerCase()
const nameFa = (item.nameFa || '').toLowerCase()
if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
matchedIds.add(item.id)
let parentId = item.parentId
while (parentId) {
matchedIds.add(parentId)
parentId = byId.get(parentId)?.parentId ?? null
}
}
}
return flat.filter((item) => matchedIds.has(item.id))
}
function isLastChildInGroup(items: FlatCategoryOption[], index: number): boolean {
const current = items[index]
if (current.depth === 0) return false
const next = items[index + 1]
return !next || next.depth < current.depth
}
function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) {
if (depth === 0) return null
return (
<div className={styles.treeGutters} aria-hidden>
{Array.from({ length: depth }, (_, level) => {
const isBranchLevel = level === depth - 1
return (
<span
key={level}
className={[
styles.treeGutter,
isBranchLevel ? styles.treeGutterBranch : styles.treeGutterContinue,
isBranchLevel && isLast ? styles.treeGutterBranchLast : '',
]
.filter(Boolean)
.join(' ')}
/>
)
})}
</div>
)
} }
/** User-product marketplace categories — same tree UX as CategoryTreeSelect. */
export function CategorySearchSelect({ export function CategorySearchSelect({
options, options,
value, value,
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
allowEmpty = false, allowEmpty = false,
dropdownVariant = 'overlay', dropdownVariant = 'overlay',
}: CategorySearchSelectProps) { }: CategorySearchSelectProps) {
const t = useT() const categories = useMemo(() => toTreeItems(options), [options])
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const flat = useMemo(() => flattenCategoryTree(options), [options])
const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
const selected = flat.find((option) => option.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('customerProducts.form.fields.searchCategory')
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
useEffect(() => {
if (disabled) {
setOpen(false)
setQuery('')
}
}, [disabled])
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
const dropdownClassName = [
styles.dropdown,
dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay,
]
.filter(Boolean)
.join(' ')
return ( return (
<div <CategoryTreeSelect
className={styles.wrapper} id={id}
ref={containerRef} categories={categories}
dir={isFa ? 'rtl' : 'ltr'} value={value}
> onChange={onChange}
<div disabled={disabled}
className={[ placeholder={placeholder}
styles.inputWrap, allowEmpty={allowEmpty}
open ? styles.inputWrapOpen : '', dropdownVariant={dropdownVariant}
disabled ? styles.inputWrapDisabled : '', />
]
.filter(Boolean)
.join(' ')}
>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{allowEmpty && value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('customerProducts.form.fields.clearCategory')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={dropdownClassName} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('customerProducts.form.fields.noCategories')}</li>
) : (
filtered.map((option, index) => {
const isLastChild = isLastChildInGroup(filtered, index)
return (
<li
key={option.id}
className={[
styles.optionItem,
isLastChild ? styles.optionItemGroupEnd : '',
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
className={[
styles.option,
option.depth === 0 ? styles.optionParent : styles.optionChild,
value === option.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
{option.depth > 0 ? (
<CategoryTreeGutters depth={option.depth} isLast={isLastChild} />
) : null}
<span className={styles.optionBody}>
<span className={styles.optionLabel}>
{categoryLabel(option, isFa)}
</span>
{!isFa && option.nameFa ? (
<span className={styles.optionSecondary}>{option.nameFa}</span>
) : null}
{isFa && option.name && option.name !== option.nameFa ? (
<span className={styles.optionSecondary} dir="ltr">
{option.name}
</span>
) : null}
</span>
</button>
</li>
)
})
)}
</ul>
) : null}
</div>
) )
} }
@@ -0,0 +1,134 @@
.wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.wrapperOpen {
z-index: 20;
}
.treePanel {
position: static;
max-height: min(280px, 40vh);
overflow-y: auto;
margin-top: 0;
box-shadow: none;
}
.treeRow {
padding: 3px 6px;
min-height: 0;
}
.selectBtn {
align-items: center;
gap: 8px;
padding: 1px 0;
}
.checkbox {
width: 15px;
height: 15px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.55);
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: var(--primary);
background: var(--card-media-bg);
}
.checkboxChecked {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
}
.namePair {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 5px;
min-width: 0;
font-size: 13px;
line-height: 1.25;
}
.nameEn {
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
unicode-bidi: isolate;
}
.nameFa {
font-weight: 600;
color: var(--text-primary);
}
.nameSep {
color: var(--text-muted);
font-weight: 500;
}
.selectedList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(160px, 22vh);
overflow-y: auto;
}
.selectedRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(148, 163, 184, 0.06);
direction: rtl;
}
.selectedRow .namePair {
flex: 0 1 auto;
min-width: 0;
justify-content: flex-end;
}
.selectedRow .nameEn,
.selectedRow .nameFa {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.removeBtn {
flex-shrink: 0;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.15s, color 0.15s;
}
.removeBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.18);
color: var(--text-primary);
}
.removeBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -0,0 +1,341 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Minus, Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import type { CategoryTreeItem } from './CategoryTreeSelect'
import treeStyles from './CategoryTree.module.css'
import selectStyles from './CategoryTreeSelect.module.css'
import styles from './CategoryTreeMultiSelect.module.css'
interface CategoryTreeMultiSelectProps {
categories: CategoryTreeItem[]
selectedIds: string[]
onToggle: (id: string) => void
placeholder?: string
disabled?: boolean
id?: string
}
function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
return categories
.filter((category) => category.parentId === parentId)
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
}
function getAncestorIds(categories: CategoryTreeItem[], id: string) {
const byId = new Map(categories.map((category) => [category.id, category]))
const ids: string[] = []
let current = byId.get(id)
while (current?.parentId) {
ids.push(current.parentId)
current = byId.get(current.parentId)
}
return ids
}
function categoryMatches(category: CategoryTreeItem, query: string) {
const q = query.trim().toLowerCase()
if (!q) return true
return (
category.nameEn.toLowerCase().includes(q) ||
category.nameFa.toLowerCase().includes(q) ||
category.nameFa.includes(query.trim())
)
}
function visibleIdsForQuery(
categories: CategoryTreeItem[],
query: string,
): Set<string> | null {
const q = query.trim()
if (!q) return null
const matched = categories.filter((category) => categoryMatches(category, q))
const visible = new Set<string>()
for (const category of matched) {
visible.add(category.id)
for (const ancestorId of getAncestorIds(categories, category.id)) {
visible.add(ancestorId)
}
}
return visible
}
function CategoryNamePair({ category }: { category: CategoryTreeItem }) {
const nameEn = category.nameEn?.trim() || ''
const nameFa = category.nameFa?.trim() || ''
return (
<span className={styles.namePair} dir="ltr">
{nameEn ? (
<span className={styles.nameEn} dir="ltr" lang="en">
{nameEn}
</span>
) : null}
{nameEn && nameFa ? <span className={styles.nameSep}>·</span> : null}
{nameFa ? (
<span className={styles.nameFa} dir="rtl" lang="fa">
{nameFa}
</span>
) : null}
</span>
)
}
function TreeNodes({
categories,
parentId,
selectedIds,
expandedIds,
visibleIds,
forceExpanded,
disabled,
onToggleExpand,
onToggleSelect,
}: {
categories: CategoryTreeItem[]
parentId: string | null
selectedIds: Set<string>
expandedIds: Set<string>
visibleIds: Set<string> | null
forceExpanded: boolean
disabled: boolean
onToggleExpand: (id: string) => void
onToggleSelect: (id: string) => void
}) {
const t = useT()
const nodes = getChildren(categories, parentId).filter(
(category) => !visibleIds || visibleIds.has(category.id),
)
return (
<>
{nodes.map((category) => {
const childCount = getChildren(categories, category.id).some(
(child) => !visibleIds || visibleIds.has(child.id),
)
const expanded = forceExpanded || expandedIds.has(category.id)
const selected = selectedIds.has(category.id)
return (
<div key={category.id}>
<div
className={`${selectStyles.row} ${styles.treeRow} ${selected ? selectStyles.rowSelected : ''}`}
>
{childCount ? (
<Tooltip
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
>
<button
type="button"
className={selectStyles.toggleBtn}
aria-label={
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
}
aria-expanded={expanded}
onClick={() => onToggleExpand(category.id)}
disabled={disabled}
>
{expanded ? <Minus size={14} /> : <Plus size={14} />}
</button>
</Tooltip>
) : (
<span className={selectStyles.toggleSpacer} />
)}
<button
type="button"
className={`${selectStyles.selectBtn} ${styles.selectBtn}`}
onClick={() => onToggleSelect(category.id)}
disabled={disabled}
aria-pressed={selected}
>
<span
className={`${styles.checkbox} ${selected ? styles.checkboxChecked : ''}`}
aria-hidden="true"
>
{selected ? '✓' : ''}
</span>
<CategoryNamePair category={category} />
</button>
</div>
{childCount ? (
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
<div className={treeStyles.childrenInner}>
<TreeNodes
categories={categories}
parentId={category.id}
selectedIds={selectedIds}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={forceExpanded}
disabled={disabled}
onToggleExpand={onToggleExpand}
onToggleSelect={onToggleSelect}
/>
</div>
</div>
) : null}
</div>
)
})}
</>
)
}
export function CategoryTreeMultiSelect({
categories,
selectedIds,
onToggle,
placeholder,
disabled = false,
id,
}: CategoryTreeMultiSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const containerRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const searchPlaceholder = placeholder ?? t('categories.select.search')
const visibleIds = useMemo(
() => visibleIdsForQuery(categories, query),
[categories, query],
)
const selectedCategories = useMemo(
() =>
selectedIds
.map((id) => categories.find((category) => category.id === id))
.filter((category): category is CategoryTreeItem => Boolean(category)),
[categories, selectedIds],
)
useEffect(() => {
setExpandedIds((prev) => {
const next = new Set(prev)
for (const id of selectedIds) {
for (const ancestorId of getAncestorIds(categories, id)) {
next.add(ancestorId)
}
}
return next
})
}, [categories, selectedIds])
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
function handleToggleExpand(toggleId: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(toggleId)) next.delete(toggleId)
else next.add(toggleId)
return next
})
}
const searching = query.trim().length > 0
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
return (
<div
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${selectStyles.inputWrap} ${open ? selectStyles.inputWrapOpen : ''}`}>
<Search size={16} className={selectStyles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={selectStyles.input}
disabled={disabled}
placeholder={searchPlaceholder}
value={query}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
onClick={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{query ? (
<button
type="button"
className={selectStyles.clearBtn}
onClick={() => setQuery('')}
aria-label={t('categories.select.clear')}
disabled={disabled}
>
<X size={14} />
</button>
) : null}
</div>
{open && !disabled ? (
<div className={`${selectStyles.panel} ${styles.treePanel}`}>
{!hasVisible ? (
<p className={selectStyles.noResults}>{t('categories.select.empty')}</p>
) : (
<TreeNodes
categories={categories}
parentId={null}
selectedIds={selectedSet}
expandedIds={expandedIds}
visibleIds={visibleIds}
forceExpanded={searching}
disabled={disabled}
onToggleExpand={handleToggleExpand}
onToggleSelect={onToggle}
/>
)}
</div>
) : null}
{selectedCategories.length > 0 ? (
<div className={styles.selectedList}>
{selectedCategories.map((category) => (
<div key={category.id} className={styles.selectedRow}>
<CategoryNamePair category={category} />
<button
type="button"
className={styles.removeBtn}
onClick={() => onToggle(category.id)}
disabled={disabled}
aria-label={t('categories.select.clear')}
>
<X size={14} />
</button>
</div>
))}
</div>
) : null}
</div>
)
}
@@ -5,6 +5,10 @@
min-height: 0; min-height: 0;
} }
.wrapperOpen {
z-index: 130;
}
.inputWrap { .inputWrap {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -75,14 +79,8 @@
} }
.panel { .panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 120;
max-height: min(320px, 50vh); max-height: min(320px, 50vh);
overflow-y: auto; overflow-y: auto;
margin-top: 0;
padding: 4px; padding: 4px;
background: var(--elevated-surface); background: var(--elevated-surface);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
@@ -90,6 +88,14 @@
box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14); box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14);
} }
.panelOverlay {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 120;
}
.panelInFlow { .panelInFlow {
position: static; position: static;
margin-top: 6px; margin-top: 6px;
@@ -98,6 +104,29 @@
z-index: auto; z-index: auto;
} }
.noneOption {
width: 100%;
padding: 8px 10px;
margin-bottom: 4px;
border-radius: 8px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
text-align: start;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.noneOption:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--text-primary);
}
.noneOptionSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
font-weight: 500;
}
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -135,7 +164,7 @@
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: background 0.15s, color 0.15s, transform 0.2s; transition: background 0.15s, color 0.15s;
} }
.toggleBtn:hover { .toggleBtn:hover {
@@ -149,23 +178,6 @@
height: 24px; height: 24px;
} }
.chevron {
display: inline-flex;
transition: transform 0.2s ease;
}
:global([dir='rtl']) .chevron {
transform: scaleX(-1);
}
.chevronOpen {
transform: rotate(90deg);
}
:global([dir='rtl']) .chevronOpen {
transform: scaleX(-1) rotate(90deg);
}
.selectBtn { .selectBtn {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1,25 +1,54 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react' import { ChevronDown, Minus, Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import type { Category } from '../types/category'
import { getAncestorIds, getChildren } from '../utils/categories'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import treeStyles from './CategoryTree.module.css' import treeStyles from './CategoryTree.module.css'
import styles from './CategoryTreeSelect.module.css' import styles from './CategoryTreeSelect.module.css'
/** Minimal category shape for the shared tree picker. */
export interface CategoryTreeItem {
id: string
parentId: string | null
nameEn: string
nameFa: string
}
interface CategoryTreeSelectProps { interface CategoryTreeSelectProps {
categories: Category[] categories: CategoryTreeItem[]
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
id?: string id?: string
/** Overlay floats over content; inFlow expands the parent (use inside modals). */ /** Show empty option + clear control. */
allowEmpty?: boolean
/** Label for the empty option when `allowEmpty` is true. */
emptyLabel?: string
/** Overlay floats over content; inFlow expands the parent. */
dropdownVariant?: 'overlay' | 'inFlow' dropdownVariant?: 'overlay' | 'inFlow'
} }
function categoryLabel(category: Category, isFa: boolean) { function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
return categories
.filter((category) => category.parentId === parentId)
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
}
function getAncestorIds(categories: CategoryTreeItem[], id: string) {
const byId = new Map(categories.map((category) => [category.id, category]))
const ids: string[] = []
let current = byId.get(id)
while (current?.parentId) {
ids.push(current.parentId)
current = byId.get(current.parentId)
}
return ids
}
function categoryLabel(category: CategoryTreeItem, isFa: boolean) {
if (isFa) { if (isFa) {
return category.nameFa return category.nameFa
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}` ? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
@@ -30,7 +59,7 @@ function categoryLabel(category: Category, isFa: boolean) {
: category.nameFa : category.nameFa
} }
function categoryMatches(category: Category, query: string) { function categoryMatches(category: CategoryTreeItem, query: string) {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
if (!q) return true if (!q) return true
return ( return (
@@ -40,7 +69,10 @@ function categoryMatches(category: Category, query: string) {
) )
} }
function visibleIdsForQuery(categories: Category[], query: string): Set<string> | null { function visibleIdsForQuery(
categories: CategoryTreeItem[],
query: string,
): Set<string> | null {
const q = query.trim() const q = query.trim()
if (!q) return null if (!q) return null
@@ -67,7 +99,7 @@ function TreeNodes({
onToggle, onToggle,
onSelect, onSelect,
}: { }: {
categories: Category[] categories: CategoryTreeItem[]
parentId: string | null parentId: string | null
value: string value: string
expandedIds: Set<string> expandedIds: Set<string>
@@ -114,9 +146,7 @@ function TreeNodes({
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => onToggle(category.id)} onClick={() => onToggle(category.id)}
> >
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}> {expanded ? <Minus size={14} /> : <Plus size={14} />}
<ChevronRight size={16} />
</span>
</button> </button>
</Tooltip> </Tooltip>
) : ( ) : (
@@ -127,9 +157,19 @@ function TreeNodes({
className={styles.selectBtn} className={styles.selectBtn}
onClick={() => onSelect(category.id)} onClick={() => onSelect(category.id)}
> >
<span className={styles.name}>{primary}</span> <span
className={styles.name}
dir={isFa ? 'rtl' : 'ltr'}
lang={isFa ? 'fa' : 'en'}
>
{primary}
</span>
{secondary ? ( {secondary ? (
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}> <span
className={styles.nameSecondary}
dir={isFa ? 'ltr' : 'rtl'}
lang={isFa ? 'en' : 'fa'}
>
· {secondary} · {secondary}
</span> </span>
) : null} ) : null}
@@ -165,6 +205,8 @@ export function CategoryTreeSelect({
placeholder, placeholder,
disabled = false, disabled = false,
id, id,
allowEmpty = false,
emptyLabel,
dropdownVariant = 'overlay', dropdownVariant = 'overlay',
}: CategoryTreeSelectProps) { }: CategoryTreeSelectProps) {
const t = useT() const t = useT()
@@ -178,6 +220,7 @@ export function CategoryTreeSelect({
const selected = categories.find((category) => category.id === value) const selected = categories.find((category) => category.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : '' const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('categories.select.search') const searchPlaceholder = placeholder ?? t('categories.select.search')
const emptyOptionLabel = emptyLabel ?? t('categories.select.none')
const visibleIds = useMemo( const visibleIds = useMemo(
() => visibleIdsForQuery(categories, query), () => visibleIdsForQuery(categories, query),
@@ -213,17 +256,17 @@ export function CategoryTreeSelect({
}) })
}, [categories, value]) }, [categories, value])
function handleToggle(id: string) { function handleToggle(toggleId: string) {
setExpandedIds((prev) => { setExpandedIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(id)) next.delete(id) if (next.has(toggleId)) next.delete(toggleId)
else next.add(id) else next.add(toggleId)
return next return next
}) })
} }
function selectOption(id: string) { function selectOption(nextId: string) {
onChange(id) onChange(nextId)
setOpen(false) setOpen(false)
setQuery('') setQuery('')
} }
@@ -232,7 +275,11 @@ export function CategoryTreeSelect({
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0 const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
return ( return (
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}> <div
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}> <div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} aria-hidden /> <Search size={16} className={styles.searchIcon} aria-hidden />
<input <input
@@ -254,7 +301,7 @@ export function CategoryTreeSelect({
aria-expanded={open} aria-expanded={open}
aria-autocomplete="list" aria-autocomplete="list"
/> />
{value && !open && !disabled ? ( {allowEmpty && value && !open && !disabled ? (
<button <button
type="button" type="button"
className={styles.clearBtn} className={styles.clearBtn}
@@ -275,11 +322,20 @@ export function CategoryTreeSelect({
<div <div
className={[ className={[
styles.panel, styles.panel,
dropdownVariant === 'inFlow' ? styles.panelInFlow : '', dropdownVariant === 'inFlow' ? styles.panelInFlow : styles.panelOverlay,
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
> >
{allowEmpty ? (
<button
type="button"
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
onClick={() => selectOption('')}
>
{emptyOptionLabel}
</button>
) : null}
{!hasVisible ? ( {!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p> <p className={styles.noResults}>{t('categories.select.empty')}</p>
) : ( ) : (
@@ -1,16 +1,16 @@
.modal { .modal {
width: min(560px, calc(100vw - 32px)); width: min(720px, calc(100vw - 32px));
} }
.body { .body {
padding: 0 20px 20px; padding: 4px 24px 24px;
} }
.metaGrid { .metaGrid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px; gap: 16px 24px;
margin: 0 0 18px; margin: 0 0 20px;
} }
.metaItem { .metaItem {
@@ -18,44 +18,55 @@
} }
.metaItem dt { .metaItem dt {
margin: 0 0 4px; margin: 0 0 6px;
font-size: 11px; font-size: 12px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted); color: var(--text-muted);
} }
.metaItem dd { .metaItem dd {
margin: 0; margin: 0;
font-size: 14px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: var(--text-primary); color: var(--text-primary);
word-break: break-word; word-break: break-word;
} }
.ltrValue {
direction: ltr;
unicode-bidi: isolate;
text-align: start;
}
.sentAt {
display: inline-block;
direction: ltr;
unicode-bidi: isolate;
}
.messageBlock { .messageBlock {
padding-top: 16px; padding: 18px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2); border-top: 1px solid rgba(148, 163, 184, 0.2);
} }
.messageLabel { .messageTitle {
margin: 0 0 10px; margin: 0 0 10px;
font-size: 13px; font-size: 16px;
font-weight: 600; font-weight: 700;
color: var(--text-secondary); line-height: 1.4;
color: var(--text-primary);
} }
.messageText { .messageText {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
line-height: 1.65; line-height: 1.75;
color: var(--text-primary); color: var(--text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
@media (max-width: 520px) { @media (max-width: 560px) {
.metaGrid { .metaGrid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -21,8 +21,15 @@ function formatDateTime(value: string, locale: 'en' | 'fa') {
if (Number.isNaN(d.getTime())) return { date: value, time: '' } if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US' const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return { return {
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }), date: d.toLocaleDateString(intlLocale, {
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }), year: 'numeric',
month: 'short',
day: '2-digit',
}),
time: d.toLocaleTimeString(intlLocale, {
hour: '2-digit',
minute: '2-digit',
}),
} }
} }
@@ -76,12 +83,9 @@ export function ContactSubmissionDetailModal({
aria-labelledby="contact-submission-title" aria-labelledby="contact-submission-title"
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <h2 id="contact-submission-title" className={modalStyles.title}>
<h2 id="contact-submission-title" className={modalStyles.title}> {t('website.contact.detailTitle')}
{t('website.contact.detailTitle')} </h2>
</h2>
<p className={modalStyles.subtitle}>{submission.title}</p>
</div>
<button <button
type="button" type="button"
className={modalStyles.closeBtn} className={modalStyles.closeBtn}
@@ -100,26 +104,26 @@ export function ContactSubmissionDetailModal({
</div> </div>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>{t('website.contact.col.email')}</dt> <dt>{t('website.contact.col.email')}</dt>
<dd>{submission.email ?? '—'}</dd> <dd className={styles.ltrValue}>{submission.email ?? '—'}</dd>
</div> </div>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>{t('website.contact.col.cell')}</dt> <dt>{t('website.contact.col.cell')}</dt>
<dd> <dd className={styles.ltrValue}>
{submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'} {submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'}
</dd> </dd>
</div> </div>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>{t('website.contact.col.date')}</dt> <dt>{t('website.contact.detailSentAt')}</dt>
<dd>{date}</dd> <dd>
</div> <span className={styles.sentAt} dir="ltr">
<div className={styles.metaItem}> {time ? `${date} · ${time}` : date}
<dt>{t('website.contact.col.time')}</dt> </span>
<dd>{time || '—'}</dd> </dd>
</div> </div>
</dl> </dl>
<div className={styles.messageBlock}> <div className={styles.messageBlock}>
<h3 className={styles.messageLabel}>{t('website.contact.detailMessage')}</h3> <h3 className={styles.messageTitle}>{submission.title}</h3>
<p className={styles.messageText}>{submission.text}</p> <p className={styles.messageText}>{submission.text}</p>
</div> </div>
</div> </div>
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { ProductVariant } from '../types/productVariant' import type { ProductVariant } from '../types/productVariant'
import { buildVariantLabel } from '../types/productVariant' import { buildVariantLabel } from '../types/productVariant'
import type { ProductVariantAttribute } from '../services/productVariantService' import type { ProductVariantAttribute } from '../services/productVariantService'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import styles from './VariationsModal.module.css' import styles from './VariationsModal.module.css'
interface CreateProductVariantModalProps { interface CreateProductVariantModalProps {
@@ -27,6 +29,7 @@ export function CreateProductVariantModal({
onClose, onClose,
onSubmit, onSubmit,
}: CreateProductVariantModalProps) { }: CreateProductVariantModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [variationId, setVariationId] = useState('') const [variationId, setVariationId] = useState('')
@@ -150,7 +153,7 @@ export function CreateProductVariantModal({
> >
{options.map((option) => ( {options.map((option) => (
<option key={option.id} value={option.id}> <option key={option.id} value={option.id}>
{option.label} {localizeVariationOptionLabel(option.label, t)}
</option> </option>
))} ))}
</select> </select>
@@ -155,6 +155,31 @@
opacity: 0.55; opacity: 0.55;
} }
.usedCheckLabel {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 12px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
user-select: none;
}
.usedCheck {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
}
.usedCheck:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.compactInput:disabled { .compactInput:disabled {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { PackagePlus, Plus, X } from 'lucide-react' import { PackagePlus, Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService' import { listAllProducts, listProducts } from '../services/productService'
import { import {
getProductVariationValues, getProductVariationValues,
type ProductVariationSelection, type ProductVariationSelection,
@@ -22,11 +22,15 @@ import {
getVariationOptions, getVariationOptions,
type StoreItemDraftRow, type StoreItemDraftRow,
} from '../utils/storeItemRows' } from '../utils/storeItemRows'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { import {
ConfirmBulkAddStoreItemsModal, ConfirmBulkAddStoreItemsModal,
type BulkAddResult, type BulkAddResult,
} from './ConfirmBulkAddStoreItemsModal' } from './ConfirmBulkAddStoreItemsModal'
import { ProductSearchSelect } from './ProductSearchSelect' import {
ProductSearchSelect,
type ProductSearchOption,
} from './ProductSearchSelect'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
import rowStyles from './CreateStoreItemsModal.module.css' import rowStyles from './CreateStoreItemsModal.module.css'
@@ -40,6 +44,7 @@ interface CreateStoreItemsModalProps {
} }
const ANIMATION_MS = 220 const ANIMATION_MS = 220
const PRODUCT_SEARCH_PAGE_SIZE = 20
export function CreateStoreItemsModal({ export function CreateStoreItemsModal({
open, open,
@@ -53,20 +58,29 @@ export function CreateStoreItemsModal({
const { showToast } = useToast() const { showToast } = useToast()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([]) const [searchResults, setSearchResults] = useState<ProductSearchOption[]>([])
const [selectedProduct, setSelectedProduct] = useState<ProductSearchOption | null>(null)
const [productId, setProductId] = useState('') const [productId, setProductId] = useState('')
const [isUsed, setIsUsed] = useState(false)
const [variations, setVariations] = useState<ProductVariationSelection[]>([]) const [variations, setVariations] = useState<ProductVariationSelection[]>([])
const [rows, setRows] = useState<StoreItemDraftRow[]>([]) const [rows, setRows] = useState<StoreItemDraftRow[]>([])
const [isLoadingProducts, setIsLoadingProducts] = useState(false) const [isSearchingProducts, setIsSearchingProducts] = useState(false)
const [isLoadingVariations, setIsLoadingVariations] = useState(false) const [isLoadingVariations, setIsLoadingVariations] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [isBulkCreating, setIsBulkCreating] = useState(false) const [isBulkCreating, setIsBulkCreating] = useState(false)
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false) const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkResult, setBulkResult] = useState<BulkAddResult | null>(null) const [bulkResult, setBulkResult] = useState<BulkAddResult | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const searchControllerRef = useRef<AbortController | null>(null)
const busy = isSubmitting || isBulkCreating const busy = isSubmitting || isBulkCreating
const productOptions = useMemo(() => {
if (!selectedProduct) return searchResults
if (searchResults.some((item) => item.id === selectedProduct.id)) return searchResults
return [selectedProduct, ...searchResults]
}, [searchResults, selectedProduct])
const formVariations = useMemo( const formVariations = useMemo(
() => variations.filter((variation) => getVariationOptions(variation).length > 0), () => variations.filter((variation) => getVariationOptions(variation).length > 0),
[variations], [variations],
@@ -118,6 +132,9 @@ export function CreateStoreItemsModal({
setMounted(false) setMounted(false)
setClosing(false) setClosing(false)
setProductId('') setProductId('')
setIsUsed(false)
setSelectedProduct(null)
setSearchResults([])
setVariations([]) setVariations([])
setRows([]) setRows([])
setError('') setError('')
@@ -129,13 +146,54 @@ export function CreateStoreItemsModal({
}, [open, mounted]) }, [open, mounted])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) {
searchControllerRef.current?.abort()
const controller = new AbortController() return
void loadProducts(controller.signal) }
return () => controller.abort()
}, [open]) }, [open])
const searchProducts = useCallback(
async (query: string) => {
searchControllerRef.current?.abort()
if (!query) {
setSearchResults([])
setIsSearchingProducts(false)
return
}
const controller = new AbortController()
searchControllerRef.current = controller
setIsSearchingProducts(true)
try {
const data = await listProducts(1, PRODUCT_SEARCH_PAGE_SIZE, controller.signal, {
name: query,
})
if (controller.signal.aborted) return
setSearchResults(
data.items.map((item) => ({
id: item.id,
title: item.nameEn,
nameFa: item.nameFa,
})),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (controller.signal.aborted) return
setSearchResults([])
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('storeItems.create.errorProducts'))
}
} finally {
if (!controller.signal.aborted) setIsSearchingProducts(false)
}
},
[t],
)
useEffect(() => { useEffect(() => {
if (!productId) { if (!productId) {
setVariations([]) setVariations([])
@@ -148,31 +206,6 @@ export function CreateStoreItemsModal({
return () => controller.abort() return () => controller.abort()
}, [productId]) }, [productId])
async function loadProducts(signal?: AbortSignal) {
setIsLoadingProducts(true)
setError('')
try {
const items = await listAllProducts(signal)
setProducts(
items.map((item) => ({
id: item.id,
title: item.title,
nameFa: item.nameFa,
})),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('storeItems.create.errorProducts'))
}
} finally {
setIsLoadingProducts(false)
}
}
async function loadVariations(nextProductId: string, signal?: AbortSignal) { async function loadVariations(nextProductId: string, signal?: AbortSignal) {
setIsLoadingVariations(true) setIsLoadingVariations(true)
setError('') setError('')
@@ -213,6 +246,15 @@ export function CreateStoreItemsModal({
return return
} }
setProductId(nextProductId) setProductId(nextProductId)
if (!nextProductId) {
setSelectedProduct(null)
return
}
const match =
productOptions.find((item) => item.id === nextProductId) ??
searchResults.find((item) => item.id === nextProductId) ??
null
setSelectedProduct(match)
} }
function updateRow(rowId: string, patch: Partial<StoreItemDraftRow>) { function updateRow(rowId: string, patch: Partial<StoreItemDraftRow>) {
@@ -281,6 +323,7 @@ export function CreateStoreItemsModal({
try { try {
await batchCreateStoreItems({ await batchCreateStoreItems({
productId, productId,
isUsed,
items: buildPayload(), items: buildPayload(),
}) })
onCreated?.() onCreated?.()
@@ -297,7 +340,7 @@ export function CreateStoreItemsModal({
} }
function openBulkConfirm() { function openBulkConfirm() {
if (busy || isLoadingProducts || products.length === 0) return if (busy) return
setBulkResult(null) setBulkResult(null)
setBulkConfirmOpen(true) setBulkConfirmOpen(true)
} }
@@ -315,7 +358,7 @@ export function CreateStoreItemsModal({
} }
async function handleAddAllProducts() { async function handleAddAllProducts() {
if (isBulkCreating || isLoadingProducts) return if (isBulkCreating) return
setIsBulkCreating(true) setIsBulkCreating(true)
setError('') setError('')
@@ -327,14 +370,11 @@ export function CreateStoreItemsModal({
const skip = new Set(existingProductIds) const skip = new Set(existingProductIds)
try { try {
const catalog = const catalog = (await listAllProducts()).map((item) => ({
products.length > 0 id: item.id,
? products title: item.title,
: (await listAllProducts()).map((item) => ({ nameFa: item.nameFa,
id: item.id, }))
title: item.title,
nameFa: item.nameFa,
}))
for (const product of catalog) { for (const product of catalog) {
if (skip.has(product.id)) { if (skip.has(product.id)) {
@@ -412,15 +452,28 @@ export function CreateStoreItemsModal({
<div className={modalStyles.field}> <div className={modalStyles.field}>
<ProductSearchSelect <ProductSearchSelect
id="store-item-product" id="store-item-product"
options={products} options={productOptions}
value={productId} value={productId}
onChange={handleProductChange} onChange={handleProductChange}
onQueryChange={(query) => void searchProducts(query)}
isSearching={isSearchingProducts}
placeholder={t('storeItems.create.productPlaceholder')} placeholder={t('storeItems.create.productPlaceholder')}
disabled={isLoadingProducts || busy} disabled={busy}
aria-label={t('storeItems.create.productPlaceholder')} aria-label={t('storeItems.create.productPlaceholder')}
/> />
</div> </div>
<label className={rowStyles.usedCheckLabel}>
<input
type="checkbox"
className={rowStyles.usedCheck}
checked={isUsed}
onChange={(e) => setIsUsed(e.target.checked)}
disabled={busy}
/>
<span>{t('storeItems.create.isUsed')}</span>
</label>
{productId && isLoadingVariations && ( {productId && isLoadingVariations && (
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p> <p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
)} )}
@@ -462,7 +515,7 @@ export function CreateStoreItemsModal({
<option value="">—</option> <option value="">—</option>
{options.map((option) => ( {options.map((option) => (
<option key={option.id} value={option.id}> <option key={option.id} value={option.id}>
{option.label} {localizeVariationOptionLabel(option.label, t)}
</option> </option>
))} ))}
</select> </select>
@@ -537,7 +590,7 @@ export function CreateStoreItemsModal({
type="button" type="button"
className={rowStyles.footerBulkBtn} className={rowStyles.footerBulkBtn}
onClick={openBulkConfirm} onClick={openBulkConfirm}
disabled={busy || isLoadingProducts || products.length === 0} disabled={busy}
> >
<PackagePlus size={16} /> <PackagePlus size={16} />
{t('storeItems.create.addAllProducts')} {t('storeItems.create.addAllProducts')}
@@ -140,7 +140,7 @@
} }
.chartDay { .chartDay {
gap: 1px; gap: 2px;
} }
.chartMonth { .chartMonth {
@@ -172,19 +172,26 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 180px; height: 180px;
gap: 2px; gap: 3px;
} }
.barWrap { .barWrap {
position: relative; position: relative;
display: flex; display: flex;
flex: 0 0 auto; flex: 1 1 0;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
width: 100%;
min-width: 0;
height: 100%; height: 100%;
z-index: 1; z-index: 1;
} }
.chartMonth .barWrap {
flex: 0 0 auto;
width: auto;
}
.barWrap:hover, .barWrap:hover,
.barWrap:focus-within { .barWrap:focus-within {
z-index: 1000; z-index: 1000;
@@ -219,24 +226,24 @@
border-radius: 6px; border-radius: 6px;
box-shadow: 0 6px 18px rgba(31, 38, 135, 0.18); box-shadow: 0 6px 18px rgba(31, 38, 135, 0.18);
z-index: 10000; z-index: 10000;
unicode-bidi: isolate;
} }
.bar { .bar {
position: relative; position: relative;
flex: 1 1 0; flex: none;
width: 100%; width: 40%;
max-width: 8px; max-width: 7px;
min-width: 2px; min-width: 4px;
border-radius: 3px 3px 1px 1px; border-radius: 3px 3px 1px 1px;
transition: height 0.3s ease; transition: height 0.3s ease;
cursor: default; cursor: default;
} }
.chartMonth .bar { .chartMonth .bar {
flex: none; width: 12px;
width: 10px;
max-width: none; max-width: none;
min-width: 10px; min-width: 12px;
border-radius: 4px 4px 2px 2px; border-radius: 4px 4px 2px 2px;
} }
@@ -246,8 +253,8 @@
} }
.barMonth { .barMonth {
width: 10px; width: 12px;
max-width: 10px; max-width: 12px;
} }
.label { .label {
@@ -43,15 +43,19 @@ function formatDayLabel(dateKey: string, locale: string): string {
}) })
} }
/** Tooltip label with month context (e.g. ۶ شهریور / Sep 6). */ /** Tooltip label with month context (e.g. ۲ مهر / Sep 6). */
function formatDayTooltip(dateKey: string, locale: string): string { function formatDayTooltip(dateKey: string, locale: string): string {
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', { return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
numberingSystem: 'latn', ...(locale === 'fa' ? {} : { numberingSystem: 'latn' as const }),
}) })
} }
function formatTooltipCount(value: number, locale: string): string {
return value.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')
}
/** Full calendar date for subtitle ranges (e.g. ۴ شهریور ۱۴۰۵ / September 4, 2026). */ /** Full calendar date for subtitle ranges (e.g. ۴ شهریور ۱۴۰۵ / September 4, 2026). */
function formatRangeDate(dateKey: string, locale: string): string { function formatRangeDate(dateKey: string, locale: string): string {
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', { return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
@@ -236,12 +240,12 @@ export function DailyActivityChart({
const primaryTip = t(primaryBarTitleKey, { const primaryTip = t(primaryBarTitleKey, {
day: item.label, day: item.label,
month: item.label, month: item.label,
count: item.primary, count: formatTooltipCount(item.primary, locale),
}) })
const secondaryTip = t(secondaryBarTitleKey, { const secondaryTip = t(secondaryBarTitleKey, {
day: item.label, day: item.label,
month: item.label, month: item.label,
count: item.secondary, count: formatTooltipCount(item.secondary, locale),
}) })
return ( return (
@@ -254,7 +258,13 @@ export function DailyActivityChart({
height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0), height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0),
}} }}
> >
<span className={styles.barTooltip}>{primaryTip}</span> <span
className={styles.barTooltip}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
{primaryTip}
</span>
</div> </div>
</div> </div>
{showSecondary ? ( {showSecondary ? (
@@ -265,7 +275,13 @@ export function DailyActivityChart({
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0), height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
}} }}
> >
<span className={styles.barTooltip}>{secondaryTip}</span> <span
className={styles.barTooltip}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
{secondaryTip}
</span>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -289,11 +305,11 @@ export function DailyActivityChart({
const tooltipDay = formatDayTooltip(item.date, locale) const tooltipDay = formatDayTooltip(item.date, locale)
const primaryTip = t(primaryBarTitleKey, { const primaryTip = t(primaryBarTitleKey, {
day: tooltipDay, day: tooltipDay,
count: item.count, count: formatTooltipCount(item.count, locale),
}) })
const secondaryTip = t(secondaryBarTitleKey, { const secondaryTip = t(secondaryBarTitleKey, {
day: tooltipDay, day: tooltipDay,
count: secondaryCount, count: formatTooltipCount(secondaryCount, locale),
}) })
return ( return (
@@ -306,7 +322,13 @@ export function DailyActivityChart({
height: Math.max(primaryHeight, item.count > 0 ? 4 : 0), height: Math.max(primaryHeight, item.count > 0 ? 4 : 0),
}} }}
> >
<span className={styles.barTooltip}>{primaryTip}</span> <span
className={styles.barTooltip}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
{primaryTip}
</span>
</div> </div>
</div> </div>
{showSecondary ? ( {showSecondary ? (
@@ -317,7 +339,13 @@ export function DailyActivityChart({
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0), height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
}} }}
> >
<span className={styles.barTooltip}>{secondaryTip}</span> <span
className={styles.barTooltip}
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
>
{secondaryTip}
</span>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -19,6 +19,7 @@ import {
getVariationOptions, getVariationOptions,
type StoreItemDraftRow, type StoreItemDraftRow,
} from '../utils/storeItemRows' } from '../utils/storeItemRows'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
import rowStyles from './CreateStoreItemsModal.module.css' import rowStyles from './CreateStoreItemsModal.module.css'
@@ -72,6 +73,7 @@ export function EditStoreItemsModal({
const [variations, setVariations] = useState<ProductVariationSelection[]>([]) const [variations, setVariations] = useState<ProductVariationSelection[]>([])
const [rows, setRows] = useState<StoreItemDraftRow[]>([]) const [rows, setRows] = useState<StoreItemDraftRow[]>([])
const [removedIds, setRemovedIds] = useState<string[]>([]) const [removedIds, setRemovedIds] = useState<string[]>([])
const [isUsed, setIsUsed] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -123,6 +125,7 @@ export function EditStoreItemsModal({
setClosing(false) setClosing(false)
setRemovedIds([]) setRemovedIds([])
setRows(itemsToRows(items)) setRows(itemsToRows(items))
setIsUsed(items.some((item) => item.isUsed))
setError('') setError('')
const controller = new AbortController() const controller = new AbortController()
void loadVariations(productId, controller.signal) void loadVariations(productId, controller.signal)
@@ -223,6 +226,7 @@ export function EditStoreItemsModal({
try { try {
const data = await syncProductStoreItems({ const data = await syncProductStoreItems({
productId, productId,
isUsed,
items: rows.map((row) => ({ items: rows.map((row) => ({
id: row.storeItemId, id: row.storeItemId,
selections: formVariations.flatMap((variation) => { selections: formVariations.flatMap((variation) => {
@@ -294,6 +298,17 @@ export function EditStoreItemsModal({
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p> <p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
) : ( ) : (
<> <>
<label className={rowStyles.usedCheckLabel}>
<input
type="checkbox"
className={rowStyles.usedCheck}
checked={isUsed}
onChange={(e) => setIsUsed(e.target.checked)}
disabled={isSubmitting}
/>
<span>{t('storeItems.create.isUsed')}</span>
</label>
<div <div
className={rowStyles.itemRowHeader} className={rowStyles.itemRowHeader}
style={{ gridTemplateColumns: gridTemplate }} style={{ gridTemplateColumns: gridTemplate }}
@@ -329,7 +344,7 @@ export function EditStoreItemsModal({
<option value="">—</option> <option value="">—</option>
{options.map((option) => ( {options.map((option) => (
<option key={option.id} value={option.id}> <option key={option.id} value={option.id}>
{option.label} {localizeVariationOptionLabel(option.label, t)}
</option> </option>
))} ))}
</select> </select>
@@ -274,7 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('website.linkGenerator.pickCategory')} placeholder={t('website.linkGenerator.pickCategory')}
dropdownVariant="inFlow" allowEmpty={false}
/> />
)} )}
</div> </div>
@@ -98,6 +98,16 @@
background: #1e293b; background: #1e293b;
} }
.cropAreaTransparent {
background:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(-45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e2e8f0 75%),
linear-gradient(-45deg, transparent 75%, #e2e8f0 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
.cropControls { .cropControls {
padding: 14px 16px; padding: 14px 16px;
display: flex; display: flex;
@@ -186,11 +196,6 @@
align-self: stretch; align-self: stretch;
} }
.compact .changeBtn {
align-self: stretch;
justify-content: center;
}
.compact .uploadZone { .compact .uploadZone {
gap: 4px; gap: 4px;
font-size: 11px; font-size: 11px;
@@ -202,8 +207,36 @@
line-height: 1.3; line-height: 1.3;
} }
.compact .cropControls {
padding: 10px;
gap: 10px;
}
.compact .zoomLabel {
gap: 8px;
font-size: 11px;
}
.compact .cropActions {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.compact .cancelBtn,
.compact .applyBtn {
width: 100%;
justify-content: center;
text-align: center;
box-sizing: border-box;
padding: 8px 10px;
font-size: 12px;
}
.compact .changeBtn { .compact .changeBtn {
display: flex; display: flex;
align-self: stretch;
justify-content: center;
padding: 6px 10px; padding: 6px 10px;
font-size: 11px; font-size: 11px;
} }
+55 -5
View File
@@ -10,7 +10,16 @@ interface ImageCropperProps {
onChange: (value: string | null) => void onChange: (value: string | null) => void
/** Crop aspect ratio. Defaults to square (1). */ /** Crop aspect ratio. Defaults to square (1). */
aspect?: number aspect?: number
/**
* Output format. When omitted, follows the uploaded file
* (PNG stays PNG with transparent padding; other images become JPEG with white padding).
*/
outputFormat?: 'jpeg' | 'png' outputFormat?: 'jpeg' | 'png'
/** Fill color for JPEG and for crop areas outside the image. */
fillColor?: string
/** Minimum zoom. Values below 1 shrink the image inside the crop frame. */
minZoom?: number
maxZoom?: number
accept?: string accept?: string
uploadLabel?: string uploadLabel?: string
hint?: string hint?: string
@@ -19,11 +28,25 @@ interface ImageCropperProps {
compact?: boolean compact?: boolean
} }
function isPngSource(file: File): boolean {
return (
file.type === 'image/png' ||
file.name.toLowerCase().endsWith('.png')
)
}
function isPngDataUrl(src: string): boolean {
return /^data:image\/png/i.test(src)
}
export function ImageCropper({ export function ImageCropper({
value, value,
onChange, onChange,
aspect = 1, aspect = 1,
outputFormat = 'jpeg', outputFormat,
fillColor,
minZoom = 0.4,
maxZoom = 3,
accept = 'image/*', accept = 'image/*',
uploadLabel, uploadLabel,
hint, hint,
@@ -35,10 +58,17 @@ export function ImageCropper({
const resolvedHint = hint ?? t('products.form.thumbnailHint') const resolvedHint = hint ?? t('products.form.thumbnailHint')
const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange') const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange')
const [imageSrc, setImageSrc] = useState<string | null>(null) const [imageSrc, setImageSrc] = useState<string | null>(null)
const [sourceIsPng, setSourceIsPng] = useState(false)
const [crop, setCrop] = useState({ x: 0, y: 0 }) const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1) const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(null) const [croppedArea, setCroppedArea] = useState<Area | null>(null)
const resolvedFormat: 'jpeg' | 'png' =
outputFormat ?? (sourceIsPng ? 'png' : 'jpeg')
const resolvedFillColor =
fillColor ?? (resolvedFormat === 'jpeg' ? '#ffffff' : undefined)
const useTransparentPad = resolvedFormat === 'png' && !resolvedFillColor
useEffect(() => { useEffect(() => {
setCrop({ x: 0, y: 0 }) setCrop({ x: 0, y: 0 })
setZoom(1) setZoom(1)
@@ -52,6 +82,8 @@ export function ImageCropper({
function handleFile(e: React.ChangeEvent<HTMLInputElement>) { function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
const png = isPngSource(file)
setSourceIsPng(png)
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
setImageSrc(reader.result as string) setImageSrc(reader.result as string)
@@ -65,7 +97,10 @@ export function ImageCropper({
async function applyCrop() { async function applyCrop() {
if (!imageSrc || !croppedArea) return if (!imageSrc || !croppedArea) return
const cropped = await getCroppedImage(imageSrc, croppedArea, outputFormat) const format: 'jpeg' | 'png' =
outputFormat ?? (isPngDataUrl(imageSrc) || sourceIsPng ? 'png' : 'jpeg')
const fill = fillColor ?? (format === 'jpeg' ? '#ffffff' : undefined)
const cropped = await getCroppedImage(imageSrc, croppedArea, format, fill)
onChange(cropped) onChange(cropped)
setImageSrc(null) setImageSrc(null)
setZoom(1) setZoom(1)
@@ -101,6 +136,10 @@ export function ImageCropper({
alignSelf: 'center', alignSelf: 'center',
} }
const cropSurfaceStyle: React.CSSProperties = useTransparentPad
? { ...frameStyle }
: { ...frameStyle, background: resolvedFillColor ?? '#1e293b' }
return ( return (
<div className={`${styles.wrapper} ${compact ? styles.compact : ''}`}> <div className={`${styles.wrapper} ${compact ? styles.compact : ''}`}>
{value && !imageSrc && ( {value && !imageSrc && (
@@ -128,16 +167,27 @@ export function ImageCropper({
{imageSrc && ( {imageSrc && (
<div className={styles.cropPanel}> <div className={styles.cropPanel}>
<div className={styles.cropArea} style={frameStyle}> <div
className={`${styles.cropArea} ${useTransparentPad ? styles.cropAreaTransparent : ''}`}
style={cropSurfaceStyle}
>
<Cropper <Cropper
key={String(aspect)} key={String(aspect)}
image={imageSrc} image={imageSrc}
crop={crop} crop={crop}
zoom={zoom} zoom={zoom}
minZoom={minZoom}
maxZoom={maxZoom}
aspect={aspect} aspect={aspect}
onCropChange={setCrop} onCropChange={setCrop}
onZoomChange={setZoom} onZoomChange={setZoom}
onCropComplete={onCropComplete} onCropComplete={onCropComplete}
restrictPosition={minZoom >= 1}
style={{
containerStyle: useTransparentPad
? { background: 'transparent' }
: { background: resolvedFillColor ?? '#1e293b' },
}}
/> />
</div> </div>
<div className={styles.cropControls}> <div className={styles.cropControls}>
@@ -145,8 +195,8 @@ export function ImageCropper({
{t('products.form.zoom')} {t('products.form.zoom')}
<input <input
type="range" type="range"
min={1} min={minZoom}
max={3} max={maxZoom}
step={0.05} step={0.05}
value={zoom} value={zoom}
onChange={(e) => setZoom(Number(e.target.value))} onChange={(e) => setZoom(Number(e.target.value))}
@@ -135,6 +135,15 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap;
}
.blockHeaderActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
} }
.blockTitle { .blockTitle {
@@ -1,14 +1,16 @@
import { Pencil, Plus, X } from 'lucide-react' import { Pencil, Plus, X } from 'lucide-react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import type { InvoiceItemTemplate } from '../types/invoice' import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
import { formatIrtInput } from '../utils/irtPrice' import { formatIrtInput } from '../utils/irtPrice'
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft' import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
import { import {
draftAccountFromAccountTemplate,
draftItemFromItemTemplate, draftItemFromItemTemplate,
emptyDraftAccount, emptyDraftAccount,
emptyDraftItem, emptyDraftItem,
emptyDraftKeyPoint, emptyDraftKeyPoint,
emptyDraftPaymentMethod,
} from '../utils/invoiceDraft' } from '../utils/invoiceDraft'
import formStyles from './InvoiceForm.module.css' import formStyles from './InvoiceForm.module.css'
import styles from './InvoiceDraftFields.module.css' import styles from './InvoiceDraftFields.module.css'
@@ -19,32 +21,50 @@ function focusKeyPointInput(index: number) {
el?.select() el?.select()
} }
function focusPaymentMethodInput(index: number) {
const el = document.querySelector<HTMLInputElement>(`input[data-payment-method-index="${index}"]`)
el?.focus()
el?.select()
}
type Props = { type Props = {
itemTemplates: InvoiceItemTemplate[] itemTemplates: InvoiceItemTemplate[]
accountTemplates: InvoiceAccountTemplate[]
items: DraftLineItem[] items: DraftLineItem[]
keyPoints: DraftKeyPoint[] keyPoints: DraftKeyPoint[]
paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[] accounts: DraftAccount[]
selectedItemTemplateId: string selectedItemTemplateId: string
onSelectedItemTemplateId: (id: string) => void onSelectedItemTemplateId: (id: string) => void
selectedAccountTemplateId: string
onSelectedAccountTemplateId: (id: string) => void
onItemsChange: (items: DraftLineItem[]) => void onItemsChange: (items: DraftLineItem[]) => void
onKeyPointsChange: (points: DraftKeyPoint[]) => void onKeyPointsChange: (points: DraftKeyPoint[]) => void
onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
onAccountsChange: (accounts: DraftAccount[]) => void onAccountsChange: (accounts: DraftAccount[]) => void
showItemTemplatePicker?: boolean showItemTemplatePicker?: boolean
showAccountTemplatePicker?: boolean
/** Rendered after line items (before key points / accounts), e.g. totals row. */ /** Rendered after line items (before key points / accounts), e.g. totals row. */
afterItems?: ReactNode afterItems?: ReactNode
} }
export function InvoiceDraftFields({ export function InvoiceDraftFields({
itemTemplates, itemTemplates,
accountTemplates,
items, items,
keyPoints, keyPoints,
paymentMethods,
accounts, accounts,
selectedItemTemplateId, selectedItemTemplateId,
onSelectedItemTemplateId, onSelectedItemTemplateId,
selectedAccountTemplateId,
onSelectedAccountTemplateId,
onItemsChange, onItemsChange,
onKeyPointsChange, onKeyPointsChange,
onPaymentMethodsChange,
onAccountsChange, onAccountsChange,
showItemTemplatePicker = true, showItemTemplatePicker = true,
showAccountTemplatePicker = true,
afterItems, afterItems,
}: Props) { }: Props) {
const t = useT() const t = useT()
@@ -70,6 +90,13 @@ export function InvoiceDraftFields({
onSelectedItemTemplateId('') onSelectedItemTemplateId('')
} }
function addFromAccountTemplate() {
const template = accountTemplates.find((entry) => entry.id === selectedAccountTemplateId)
if (!template) return
onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)])
onSelectedAccountTemplateId('')
}
return ( return (
<> <>
<div className={styles.itemsStack}> <div className={styles.itemsStack}>
@@ -250,16 +277,107 @@ export function InvoiceDraftFields({
<div className={styles.blockSection}> <div className={styles.blockSection}>
<div className={styles.blockHeader}> <div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4> <h4 className={styles.blockTitle}>{t('invoiceDraft.paymentMethods')}</h4>
<button <button
type="button" type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`} className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])} onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
> >
<Plus size={16} /> <Plus size={16} />
{t('invoiceDraft.addAccount')} {t('invoiceDraft.addPaymentMethod')}
</button> </button>
</div> </div>
{paymentMethods.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noPaymentMethods')}</p>
) : (
<div className={styles.repeatStack}>
{paymentMethods.map((method, index) => (
<div key={method.key} className={styles.repeatRow}>
<div className={`${formStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
<input
data-payment-method-index={index}
value={method.text}
onChange={(e) =>
onPaymentMethodsChange(
paymentMethods.map((m) =>
m.key === method.key ? { ...m, text: e.target.value } : m,
),
)
}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
e.preventDefault()
const next = index + 1
if (next < paymentMethods.length) {
focusPaymentMethodInput(next)
return
}
onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])
window.setTimeout(() => focusPaymentMethodInput(next), 0)
}}
placeholder={t('invoiceDraft.paymentMethodPlaceholder')}
aria-label={t('invoiceDraft.paymentMethodAria', { index: index + 1 })}
/>
</div>
<button
type="button"
className={styles.removeFieldBtn}
onClick={() =>
onPaymentMethodsChange(paymentMethods.filter((m) => m.key !== method.key))
}
aria-label={t('invoiceDraft.removePaymentMethod')}
title={t('invoiceDraft.removePaymentMethod')}
>
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
<div className={styles.blockHeaderActions}>
{showAccountTemplatePicker ? (
<div className={styles.itemActionsLeft}>
<select
id="account-template-pick"
className={styles.compactSelect}
value={selectedAccountTemplateId}
onChange={(e) => onSelectedAccountTemplateId(e.target.value)}
aria-label={t('invoiceDraft.addFromAccountTemplateAria')}
>
<option value="">{t('invoiceDraft.addFromAccountTemplate')}</option>
{accountTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.bankName}
{template.accountHolderName ? ` — ${template.accountHolderName}` : ''}
</option>
))}
</select>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
onClick={addFromAccountTemplate}
disabled={!selectedAccountTemplateId}
>
<Plus size={14} />
{t('invoiceDraft.add')}
</button>
</div>
) : null}
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
>
<Plus size={16} />
{t('invoiceDraft.addAccount')}
</button>
</div>
</div>
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p> <p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p>
) : ( ) : (
@@ -13,7 +13,7 @@
font-size: var(--field-font-size); font-size: var(--field-font-size);
font-family: inherit; font-family: inherit;
color: var(--text-primary); color: var(--text-primary);
text-align: left; text-align: start;
background: var(--surface); background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35); border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -64,19 +64,37 @@
} }
.dropdown { .dropdown {
position: static; position: fixed;
margin-top: 4px; z-index: 1200;
z-index: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: none; overflow: hidden;
overflow: visible; isolation: isolate;
background: var(--elevated-surface); background: var(--toast-bg, rgba(255, 255, 255, 0.94));
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
animation: menuIn 0.16s ease;
}
.dropdown::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
backdrop-filter: blur(var(--blur-glass, 16px)) saturate(1.8);
-webkit-backdrop-filter: blur(var(--blur-glass, 16px)) saturate(1.8);
}
@keyframes menuIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }
.searchWrap { .searchWrap {
@@ -110,6 +128,8 @@
} }
.optionsList { .optionsList {
flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
margin: 0; margin: 0;
padding: 6px; padding: 6px;
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, Search } from 'lucide-react' import { Check, ChevronDown, Search } from 'lucide-react'
import styles from './MultiSelectDropdown.module.css' import styles from './MultiSelectDropdown.module.css'
@@ -6,6 +7,8 @@ export interface MultiSelectOption<T extends string | number = number> {
value: T value: T
label: string label: string
depth?: number depth?: number
/** Extra text matched by search (e.g. EN+FA names, tags). */
keywords?: string
} }
interface MultiSelectDropdownProps<T extends string | number = number> { interface MultiSelectDropdownProps<T extends string | number = number> {
@@ -19,6 +22,13 @@ interface MultiSelectDropdownProps<T extends string | number = number> {
id?: string id?: string
} }
type MenuPosition = {
top: number
left: number
width: number
maxHeight: number
}
export function MultiSelectDropdown<T extends string | number = number>({ export function MultiSelectDropdown<T extends string | number = number>({
options, options,
value, value,
@@ -32,8 +42,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [triggerLabel, setTriggerLabel] = useState(placeholder) const [triggerLabel, setTriggerLabel] = useState(placeholder)
const [menuPos, setMenuPos] = useState<MenuPosition | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null) const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLSpanElement>(null) const measureRef = useRef<HTMLSpanElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
@@ -45,6 +57,29 @@ export function MultiSelectDropdown<T extends string | number = number>({
[options, value], [options, value],
) )
const updateMenuPosition = () => {
const trigger = triggerRef.current
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const gap = 4
const viewportPad = 8
const spaceBelow = window.innerHeight - rect.bottom - gap - viewportPad
const spaceAbove = rect.top - gap - viewportPad
const preferBelow = spaceBelow >= 180 || spaceBelow >= spaceAbove
const maxHeight = Math.max(160, Math.min(320, preferBelow ? spaceBelow : spaceAbove))
const top = preferBelow
? rect.bottom + gap
: Math.max(viewportPad, rect.top - gap - maxHeight)
setMenuPos({
top,
left: rect.left,
width: rect.width,
maxHeight,
})
}
useLayoutEffect(() => { useLayoutEffect(() => {
const trigger = triggerRef.current const trigger = triggerRef.current
const measure = measureRef.current const measure = measureRef.current
@@ -74,9 +109,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
const style = getComputedStyle(triggerEl) const style = getComputedStyle(triggerEl)
measureEl.style.font = style.font measureEl.style.font = style.font
const padding = const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
parseFloat(style.paddingLeft) +
parseFloat(style.paddingRight)
const gap = parseFloat(style.columnGap || style.gap || '10') const gap = parseFloat(style.columnGap || style.gap || '10')
const reservedWidth = padding + gap + 16 + (searchable ? gap + 16 : 0) const reservedWidth = padding + gap + 16 + (searchable ? gap + 16 : 0)
const availableWidth = triggerEl.clientWidth - reservedWidth const availableWidth = triggerEl.clientWidth - reservedWidth
@@ -103,9 +136,32 @@ export function MultiSelectDropdown<T extends string | number = number>({
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase() const query = searchQuery.trim().toLowerCase()
if (!searchable || !query) return options if (!searchable || !query) return options
return options.filter((option) => option.label.toLowerCase().includes(query)) return options.filter((option) => {
const haystack = `${option.label} ${option.keywords ?? ''}`.toLowerCase()
return haystack.includes(query)
})
}, [options, searchQuery, searchable]) }, [options, searchQuery, searchable])
useLayoutEffect(() => {
if (!open) {
setMenuPos(null)
return
}
updateMenuPosition()
function onReposition() {
updateMenuPosition()
}
window.addEventListener('resize', onReposition)
window.addEventListener('scroll', onReposition, true)
return () => {
window.removeEventListener('resize', onReposition)
window.removeEventListener('scroll', onReposition, true)
}
}, [open])
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setSearchQuery('') setSearchQuery('')
@@ -113,9 +169,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
} }
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { const target = e.target as Node
setOpen(false) if (containerRef.current?.contains(target)) return
} if (menuRef.current?.contains(target)) return
setOpen(false)
} }
function handleEscape(e: KeyboardEvent) { function handleEscape(e: KeyboardEvent) {
@@ -134,7 +191,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
if (open && searchable) { if (open && searchable) {
searchInputRef.current?.focus() searchInputRef.current?.focus()
} }
}, [open, searchable]) }, [open, searchable, menuPos])
function toggleOption(optionValue: T) { function toggleOption(optionValue: T) {
if (value.includes(optionValue)) { if (value.includes(optionValue)) {
@@ -144,6 +201,68 @@ export function MultiSelectDropdown<T extends string | number = number>({
onChange([...value, optionValue]) onChange([...value, optionValue])
} }
const menu =
open && menuPos
? createPortal(
<div
ref={menuRef}
className={styles.dropdown}
style={{
top: menuPos.top,
left: menuPos.left,
width: menuPos.width,
maxHeight: menuPos.maxHeight,
}}
role="presentation"
>
{searchable && (
<div className={styles.searchWrap}>
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
<input
ref={searchInputRef}
type="search"
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
<ul className={styles.optionsList} role="listbox" aria-multiselectable="true">
{filteredOptions.length === 0 ? (
<li className={styles.empty}>
{options.length === 0 ? 'No options available.' : 'No matching options.'}
</li>
) : (
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<li key={String(option.value)}>
<button
type="button"
role="option"
aria-selected={selected}
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
style={{ paddingInlineStart: `${10 + (option.depth ?? 0) * 18}px` }}
onClick={() => toggleOption(option.value)}
>
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
{selected && <Check size={12} strokeWidth={3} />}
</span>
<span>{option.label}</span>
</button>
</li>
)
})
)}
</ul>
</div>,
document.body,
)
: null
return ( return (
<div className={styles.wrapper} ref={containerRef}> <div className={styles.wrapper} ref={containerRef}>
<span ref={measureRef} className={styles.measureText} aria-hidden="true" /> <span ref={measureRef} className={styles.measureText} aria-hidden="true" />
@@ -163,54 +282,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
<span className={styles.triggerText}>{triggerLabel}</span> <span className={styles.triggerText}>{triggerLabel}</span>
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} /> <ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
</button> </button>
{menu}
{open && (
<div className={styles.dropdown}>
{searchable && (
<div className={styles.searchWrap}>
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
<input
ref={searchInputRef}
type="search"
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
<ul className={styles.optionsList} role="listbox" aria-multiselectable="true">
{filteredOptions.length === 0 ? (
<li className={styles.empty}>
{options.length === 0 ? 'No options available.' : 'No matching options.'}
</li>
) : (
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<li key={String(option.value)}>
<button
type="button"
role="option"
aria-selected={selected}
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
style={{ paddingLeft: `${10 + (option.depth ?? 0) * 18}px` }}
onClick={() => toggleOption(option.value)}
>
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
{selected && <Check size={12} strokeWidth={3} />}
</span>
<span>{option.label}</span>
</button>
</li>
)
})
)}
</ul>
</div>
)}
</div> </div>
) )
} }
@@ -1,5 +1,6 @@
.modalWide { .modalWide {
max-width: 720px; max-width: 960px;
width: min(960px, calc(100vw - 32px));
} }
.body { .body {
@@ -42,7 +43,7 @@
.itemRow { .itemRow {
display: grid; display: grid;
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px; grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
@@ -105,7 +106,7 @@
.tableHead { .tableHead {
display: grid; display: grid;
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px; grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
gap: 10px; gap: 10px;
padding: 0 12px 2px; padding: 0 12px 2px;
font-size: 11px; font-size: 11px;
@@ -140,6 +141,126 @@
color: var(--primary); color: var(--primary);
} }
.shippingBlock {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.05);
}
.shippingSectionTitle {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.addressText {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.shippingGroupList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.shippingGroupCard {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 0;
border: none;
border-radius: 0;
background: transparent;
}
.shippingGroupHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.shippingGroupTitle {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.shippingGroupAmount {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.shippingMethod {
font-size: 11px;
font-weight: 600;
color: var(--primary);
}
.shippingItemNames {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
}
.itemTitleLink {
display: inline;
font-size: 13px;
font-weight: 600;
color: var(--primary);
line-height: 1.3;
text-decoration: none;
}
.itemTitleLink:hover {
text-decoration: underline;
}
.totalsBlock {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.totalsRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.totalsValue {
font-weight: 600;
color: var(--text-primary);
}
.totalsRowGrand {
padding-top: 6px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
font-weight: 700;
color: var(--text-primary);
}
.empty { .empty {
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
+144 -12
View File
@@ -5,6 +5,7 @@ import { useT } from '../i18n/useT'
import type { Order } from '../services/orderService' import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
import styles from './OrderItemsModal.module.css' import styles from './OrderItemsModal.module.css'
@@ -30,6 +31,56 @@ function totalQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0) return order.items.reduce((sum, item) => sum + item.quantity, 0)
} }
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function parseOrderShipping(shippingAddress: Record<string, unknown>) {
const root = asRecord(shippingAddress) ?? {}
const deliveryMode =
root.deliveryMode === 'pickup' || root.deliveryMode === 'delivery'
? root.deliveryMode
: root.province === 'Pickup'
? 'pickup'
: 'delivery'
const shipping = asRecord(root.shipping)
const groupsRaw = Array.isArray(shipping?.groups) ? shipping.groups : []
const groups = groupsRaw
.map((entry) => {
const row = asRecord(entry)
if (!row) return null
const groupId = typeof row.groupId === 'string' ? row.groupId : ''
if (!groupId) return null
return {
groupId,
title: typeof row.title === 'string' ? row.title : '',
methodLabel: typeof row.methodLabel === 'string' ? row.methodLabel : null,
amount: typeof row.amount === 'number' && Number.isFinite(row.amount) ? row.amount : 0,
pasKraye: row.pasKraye === true,
itemNames: Array.isArray(row.itemNames)
? row.itemNames.filter((name): name is string => typeof name === 'string')
: [],
}
})
.filter((group): group is NonNullable<typeof group> => Boolean(group))
const addressParts = [
typeof root.province === 'string' ? root.province : '',
typeof root.city === 'string' ? root.city : '',
typeof root.address === 'string' ? root.address : '',
typeof root.postalCode === 'string' ? root.postalCode : '',
].filter((part) => part && part !== 'Pickup' && part !== 'Store' && part !== 'In-store pickup')
return {
deliveryMode,
addressLine: addressParts.join(' · '),
groups,
hasPasKraye: groups.some((group) => group.pasKraye),
}
}
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) { export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
const t = useT() const t = useT()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
@@ -61,6 +112,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
if (!mounted || !order) return null if (!mounted || !order) return null
const itemCount = totalQuantity(order) const itemCount = totalQuantity(order)
const shippingInfo = parseOrderShipping(order.shippingAddress)
return createPortal( return createPortal(
<div <div
@@ -105,6 +157,44 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</span> </span>
</div> </div>
<div className={styles.shippingBlock}>
<div className={styles.shippingSectionTitle}>{t('orderItems.delivery')}</div>
{shippingInfo.deliveryMode === 'pickup' ? (
<p className={styles.addressText}>{t('orderItems.pickup')}</p>
) : shippingInfo.addressLine ? (
<p className={styles.addressText}>{shippingInfo.addressLine}</p>
) : null}
{shippingInfo.groups.length > 0 ? (
<ul className={styles.shippingGroupList}>
{shippingInfo.groups.map((group) => (
<li key={group.groupId} className={styles.shippingGroupCard}>
<div className={styles.shippingGroupHeader}>
<span className={styles.shippingGroupTitle}>{group.title}</span>
<span className={styles.shippingGroupAmount}>
{group.pasKraye
? t('orderItems.pasKraye')
: group.amount > 0
? formatIrtPrice(group.amount)
: t('orderItems.freeShipping')}
</span>
</div>
{group.methodLabel ? (
<div className={styles.shippingMethod}>{group.methodLabel}</div>
) : null}
{shippingInfo.groups.length > 1 && group.itemNames.length > 0 ? (
<ul className={styles.shippingItemNames}>
{group.itemNames.map((name) => (
<li key={`${group.groupId}-${name}`}>{name}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
) : null}
</div>
{order.items.length === 0 ? ( {order.items.length === 0 ? (
<p className={styles.empty}>{t('orderItems.empty')}</p> <p className={styles.empty}>{t('orderItems.empty')}</p>
) : ( ) : (
@@ -117,7 +207,15 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<span>{t('orderItems.lineTotal')}</span> <span>{t('orderItems.lineTotal')}</span>
</div> </div>
<ul className={styles.itemList}> <ul className={styles.itemList}>
{order.items.map((item) => ( {order.items.map((item) => {
const displayTitle = item.productNameFa?.trim() || item.productTitle
const productHref = buildWebsiteContentUrl('product', {
id: item.productId,
nameFa: item.productNameFa,
title: item.productTitle,
slug: item.productSlug,
})
return (
<li key={item.id} className={styles.itemRow}> <li key={item.id} className={styles.itemRow}>
<div className={styles.itemThumb}> <div className={styles.itemThumb}>
{item.productImage ? ( {item.productImage ? (
@@ -127,7 +225,18 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
)} )}
</div> </div>
<div> <div>
<div className={styles.itemTitle}>{item.productTitle}</div> {productHref ? (
<a
className={styles.itemTitleLink}
href={productHref}
target="_blank"
rel="noopener noreferrer"
>
{displayTitle}
</a>
) : (
<div className={styles.itemTitle}>{displayTitle}</div>
)}
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div> <div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
{item.variantSku && ( {item.variantSku && (
<div className={styles.itemSku}> <div className={styles.itemSku}>
@@ -139,20 +248,43 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div> <div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div>
<div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div> <div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
</li> </li>
))} )
})}
</ul> </ul>
</div> </div>
)} )}
<div className={styles.summary}> <div className={styles.totalsBlock}>
<span className={styles.summaryLabel}> <div className={styles.totalsRow}>
{t('orderItems.summary', { <span>{t('orderItems.subtotal')}</span>
count: itemCount, <span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
itemsLabel: </div>
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'), <div className={styles.totalsRow}>
})} <span>{t('orderItems.shipping')}</span>
</span> <span className={styles.totalsValue}>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span> {order.shippingTotal > 0
? formatIrtPrice(order.shippingTotal)
: shippingInfo.hasPasKraye
? t('orderItems.pasKraye')
: t('orderItems.freeShipping')}
</span>
</div>
{order.discountTotal > 0 ? (
<div className={styles.totalsRow}>
<span>{t('orderItems.discount')}</span>
<span className={styles.totalsValue}>{formatIrtPrice(order.discountTotal)}</span>
</div>
) : null}
<div className={`${styles.totalsRow} ${styles.totalsRowGrand}`}>
<span>
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
</div>
</div> </div>
<div className={modalStyles.actions}> <div className={modalStyles.actions}>
@@ -5,6 +5,17 @@
margin-bottom: 4px; margin-bottom: 4px;
} }
.paymentHint {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(var(--primary-rgb) / 0.06);
border: 1px solid rgba(var(--primary-rgb) / 0.18);
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
}
.stepOption { .stepOption {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6,7 +6,10 @@ import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { updateOrder, type Order } from '../services/orderService' import { updateOrder, type Order } from '../services/orderService'
import type { OrderProcessStep } from '../services/settingsService' import {
isSystemPaymentProcessStep,
type OrderProcessStep,
} from '../services/settingsService'
import { defaultStepColorForId, normalizeStepColor, stepBadgeStyle } from '../utils/stepColors' import { defaultStepColorForId, normalizeStepColor, stepBadgeStyle } from '../utils/stepColors'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
import styles from './OrderStepModal.module.css' import styles from './OrderStepModal.module.css'
@@ -71,6 +74,7 @@ export function OrderStepModal({
async function handleSelectStep(stepId: string) { async function handleSelectStep(stepId: string) {
if (!order || savingStepId) return if (!order || savingStepId) return
if (isSystemPaymentProcessStep(stepId)) return
if (stepId === order.processStepId) { if (stepId === order.processStepId) {
onClose() onClose()
@@ -104,6 +108,8 @@ export function OrderStepModal({
order.processStepLabel?.trim() || order.processStepLabel?.trim() ||
currentStep?.label || currentStep?.label ||
order.processStepId order.processStepId
const selectableSteps = steps.filter((step) => !isSystemPaymentProcessStep(step.id))
const paymentIsCurrent = isSystemPaymentProcessStep(order.processStepId)
return createPortal( return createPortal(
<div <div
@@ -151,8 +157,12 @@ export function OrderStepModal({
</div> </div>
)} )}
{paymentIsCurrent && (
<p className={styles.paymentHint}>{t('orderStep.paymentHint')}</p>
)}
<div className={styles.stepList} role="listbox" aria-label={t('orderStep.listAria')}> <div className={styles.stepList} role="listbox" aria-label={t('orderStep.listAria')}>
{steps.map((step, index) => { {selectableSteps.map((step, index) => {
const selected = order.processStepId === step.id const selected = order.processStepId === step.id
const saving = savingStepId === step.id const saving = savingStepId === step.id
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index)) const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
@@ -178,7 +188,7 @@ export function OrderStepModal({
})} })}
</div> </div>
{!steps.length && ( {!selectableSteps.length && (
<p className={styles.emptyText}>{t('orderStep.empty')}</p> <p className={styles.emptyText}>{t('orderStep.empty')}</p>
)} )}
</div> </div>
@@ -49,14 +49,34 @@
.txMeta { .txMeta {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 16px; gap: 8px 16px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
} }
.txMetaItem {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.txMetaLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.txMetaItem strong { .txMetaItem strong {
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; font-weight: 600;
word-break: break-all;
}
.txMono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
letter-spacing: 0.01em;
} }
.statusBadge { .statusBadge {
@@ -96,7 +96,12 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
if (!mounted || !order) return null if (!mounted || !order) return null
function transactionDetails(transaction: OrderTransaction) { function transactionDetails(transaction: OrderTransaction) {
const rows: { label: string; value: string; isStatus?: boolean }[] = [ const rows: { label: string; value: string; isStatus?: boolean; mono?: boolean }[] = [
{
label: t('orderTransactions.transactionId'),
value: transaction.id,
mono: true,
},
{ {
label: t('orderTransactions.status'), label: t('orderTransactions.status'),
value: t(STATUS_KEYS[transaction.status] ?? 'orderTransactions.status.pending'), value: t(STATUS_KEYS[transaction.status] ?? 'orderTransactions.status.pending'),
@@ -108,6 +113,13 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
}, },
] ]
if (transaction.paidAt) {
rows.push({
label: t('orderTransactions.paidAt'),
value: formatDateTime(transaction.paidAt, dateLocale),
})
}
if (transaction.type === 'pos' && transaction.posType) { if (transaction.type === 'pos' && transaction.posType) {
rows.push({ label: t('orderTransactions.posType'), value: transaction.posType }) rows.push({ label: t('orderTransactions.posType'), value: transaction.posType })
} }
@@ -116,11 +128,38 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
rows.push({ label: t('orderTransactions.account'), value: transaction.transferAccount }) rows.push({ label: t('orderTransactions.account'), value: transaction.transferAccount })
} }
if (transaction.transferRefNumber) { if (transaction.transferRefNumber) {
rows.push({ label: t('orderTransactions.refCode'), value: transaction.transferRefNumber }) rows.push({
label: t('orderTransactions.refCode'),
value: transaction.transferRefNumber,
mono: true,
})
} }
} }
if (transaction.type === 'e_payment_gate' && transaction.gatewayType) { if (transaction.type === 'e_payment_gate') {
rows.push({ label: t('orderTransactions.gateway'), value: transaction.gatewayType }) if (transaction.gatewayType) {
rows.push({ label: t('orderTransactions.gateway'), value: transaction.gatewayType })
}
if (transaction.gatewayTrackId) {
rows.push({
label: t('orderTransactions.gatewayTrackId'),
value: transaction.gatewayTrackId,
mono: true,
})
}
if (transaction.gatewayRef) {
rows.push({
label: t('orderTransactions.gatewayRef'),
value: transaction.gatewayRef,
mono: true,
})
}
if (transaction.cardPan) {
rows.push({
label: t('orderTransactions.cardPan'),
value: transaction.cardPan,
mono: true,
})
}
} }
if (transaction.notes) { if (transaction.notes) {
rows.push({ label: t('orderTransactions.notes'), value: transaction.notes }) rows.push({ label: t('orderTransactions.notes'), value: transaction.notes })
@@ -176,13 +215,15 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
<div className={styles.txMeta}> <div className={styles.txMeta}>
{details.map((detail) => ( {details.map((detail) => (
<div key={detail.label} className={styles.txMetaItem}> <div key={detail.label} className={styles.txMetaItem}>
{detail.label}:{' '} <span className={styles.txMetaLabel}>{detail.label}</span>
{detail.isStatus ? ( {detail.isStatus ? (
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}> <span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
{detail.value} {detail.value}
</span> </span>
) : ( ) : (
<strong>{detail.value}</strong> <strong className={detail.mono ? styles.txMono : undefined}>
{detail.value}
</strong>
)} )}
</div> </div>
))} ))}
@@ -4,6 +4,42 @@
padding: 32px; padding: 32px;
} }
@keyframes homeReveal {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.revealItem {
opacity: 0;
animation: homeReveal 0.58s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--reveal-delay, 0ms);
}
.revealItemFill {
display: flex;
min-height: 0;
}
.revealItemFill > * {
flex: 1;
width: 100%;
min-height: 100%;
}
@media (prefers-reduced-motion: reduce) {
.revealItem {
opacity: 1;
animation: none;
transform: none;
}
}
.pageHeader { .pageHeader {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -76,7 +76,7 @@ export function PortfolioCard({
</h3> </h3>
) : null} ) : null}
{titleEn ? ( {titleEn ? (
<p className={cardStyles.titleEn} lang="en" dir={isFa ? 'rtl' : 'ltr'}> <p className={cardStyles.titleEn} lang="en" dir="ltr">
{titleEn} {titleEn}
</p> </p>
) : null} ) : null}
@@ -155,6 +155,10 @@
border-radius: 50px; border-radius: 50px;
direction: rtl; direction: rtl;
unicode-bidi: plaintext; unicode-bidi: plaintext;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
} }
.bodyLtr { .bodyLtr {
@@ -202,7 +206,8 @@
.variantBadge, .variantBadge,
.commentBadge, .commentBadge,
.translateBadge { .translateBadge,
.actionBadge {
position: absolute; position: absolute;
top: -4px; top: -4px;
right: -4px; right: -4px;
@@ -241,3 +246,12 @@
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
color: #ef4444; color: #ef4444;
} }
.menuItemDanger {
color: #ef4444;
}
.menuItemDanger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
+104 -83
View File
@@ -3,12 +3,14 @@ import { Link } from 'react-router-dom'
import { import {
Pencil, Pencil,
Globe, Globe,
Info,
Trash2, Trash2,
Layers, Layers,
MessageSquare,
ClipboardList, ClipboardList,
ImageOff, ImageOff,
Info,
MessageSquare,
Search,
type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import type { Product } from '../types/product' import type { Product } from '../types/product'
@@ -17,8 +19,7 @@ import { Tooltip } from './Tooltip'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { hasBusinessModule } from '../utils/businessModules' import { hasBusinessModule } from '../utils/businessModules'
import { formatProductCategoryChip } from '../services/productService' import { formatProductCategoryChip } from '../services/productService'
import { buildWebsiteContentUrl } from '../utils/websiteLinks' import { ProductCardOverflowMenu } from './ProductCardOverflowMenu'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import styles from './ProductCard.module.css' import styles from './ProductCard.module.css'
interface ProductCardProps { interface ProductCardProps {
@@ -29,11 +30,21 @@ interface ProductCardProps {
onTranslate: (id: string) => void onTranslate: (id: string) => void
onQuickInfo: (id: string) => void onQuickInfo: (id: string) => void
onComments: (id: string) => void onComments: (id: string) => void
onSeo: (id: string) => void
onTechnicalInfo: (id: string) => void onTechnicalInfo: (id: string) => void
onRemove: (id: string) => void onRemove: (id: string) => void
onVariations: (id: string) => void onVariations: (id: string) => void
} }
type CardAction = {
key: string
label: string
icon: LucideIcon
onClick: () => void
danger?: boolean
badge?: number
}
export function ProductCard({ export function ProductCard({
product, product,
commentCount, commentCount,
@@ -42,6 +53,7 @@ export function ProductCard({
onTranslate, onTranslate,
onQuickInfo, onQuickInfo,
onComments, onComments,
onSeo,
onTechnicalInfo, onTechnicalInfo,
onRemove, onRemove,
onVariations, onVariations,
@@ -59,7 +71,6 @@ export function ProductCard({
: '' : ''
: product.nameFa : product.nameFa
const categoryLabel = formatProductCategoryChip(product, isFa) const categoryLabel = formatProductCategoryChip(product, isFa)
const websiteUrl = buildWebsiteContentUrl('product', product)
const imageSrc = product.image?.trim() || '' const imageSrc = product.image?.trim() || ''
const [imageFailed, setImageFailed] = useState(false) const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed const showImage = Boolean(imageSrc) && !imageFailed
@@ -68,6 +79,70 @@ export function ProductCard({
setImageFailed(false) setImageFailed(false)
}, [imageSrc]) }, [imageSrc])
const primaryActions: CardAction[] = [
{
key: 'edit',
label: t('products.card.edit'),
icon: Pencil,
onClick: () => onEdit(product.id),
},
...(showTranslationControls
? [
{
key: 'translate',
label: t('products.card.translate'),
icon: Globe,
onClick: () => onTranslate(product.id),
badge: translationCount > 0 ? translationCount : undefined,
} satisfies CardAction,
]
: []),
{
key: 'variations',
label: t('products.card.variations'),
icon: Layers,
onClick: () => onVariations(product.id),
badge: variantCount > 0 ? variantCount : undefined,
},
{
key: 'technical',
label: t('products.card.technical'),
icon: ClipboardList,
onClick: () => onTechnicalInfo(product.id),
},
]
const menuActions = [
{
key: 'seo',
label: t('products.card.seo'),
icon: Search,
onClick: () => onSeo(product.id),
},
{
key: 'quickInfo',
label: t('products.card.quickInfo'),
icon: Info,
onClick: () => onQuickInfo(product.id),
},
{
key: 'comments',
label:
commentCount > 0
? `${t('products.card.comments')} (${commentCount})`
: t('products.card.comments'),
icon: MessageSquare,
onClick: () => onComments(product.id),
},
{
key: 'remove',
label: t('products.card.remove'),
icon: Trash2,
onClick: () => onRemove(product.id),
danger: true,
},
]
return ( return (
<article className={styles.card}> <article className={styles.card}>
<Link to={`/products/detail/${product.id}`} className={styles.clickable}> <Link to={`/products/detail/${product.id}`} className={styles.clickable}>
@@ -103,89 +178,35 @@ export function ProductCard({
</> </>
)} )}
{categoryLabel ? ( {categoryLabel ? (
<span className={styles.categoryChip}>{categoryLabel}</span> <span className={styles.categoryChip} title={categoryLabel}>
{categoryLabel}
</span>
) : null} ) : null}
</div> </div>
</Link> </Link>
<div className={styles.controls}> <div className={styles.controls}>
<Tooltip label={t('products.card.edit')}> {primaryActions.map((action) => {
<button type="button" onClick={() => onEdit(product.id)} aria-label={t('products.card.edit')}> const Icon = action.icon
<Pencil size={16} /> return (
</button> <Tooltip key={action.key} label={action.label}>
</Tooltip> <button
<OpenOnWebsiteControl href={websiteUrl} /> type="button"
{showTranslationControls ? ( className={styles.iconBtn}
<Tooltip label={t('products.card.translate')}> onClick={action.onClick}
<button aria-label={
type="button" action.badge != null ? `${action.label} (${action.badge})` : action.label
className={styles.iconBtn} }
onClick={() => onTranslate(product.id)} >
aria-label={ <Icon size={16} />
translationCount > 0 {action.badge != null ? (
? `${t('products.card.translate')} (${translationCount})` <span className={styles.actionBadge}>{action.badge}</span>
: t('products.card.translate') ) : null}
} </button>
> </Tooltip>
<Globe size={16} /> )
{translationCount > 0 && ( })}
<span className={styles.translateBadge}>{translationCount}</span> <ProductCardOverflowMenu productName={primaryName} items={menuActions} />
)}
</button>
</Tooltip>
) : null}
<Tooltip label={t('products.card.quickInfo')}>
<button
type="button"
onClick={() => onQuickInfo(product.id)}
aria-label={t('products.card.quickInfo')}
>
<Info size={16} />
</button>
</Tooltip>
<Tooltip label={t('products.card.variations')}>
<button
type="button"
className={styles.iconBtn}
onClick={() => onVariations(product.id)}
aria-label={`${t('products.card.variations')} (${variantCount})`}
>
<Layers size={16} />
{variantCount > 0 && <span className={styles.variantBadge}>{variantCount}</span>}
</button>
</Tooltip>
<Tooltip label={t('products.card.comments')}>
<button
type="button"
className={styles.iconBtn}
onClick={() => onComments(product.id)}
aria-label={`${t('products.card.comments')} (${commentCount})`}
>
<MessageSquare size={16} />
{commentCount > 0 && (
<span className={styles.commentBadge}>{commentCount}</span>
)}
</button>
</Tooltip>
<Tooltip label={t('products.card.technical')}>
<button
type="button"
onClick={() => onTechnicalInfo(product.id)}
aria-label={t('products.card.technical')}
>
<ClipboardList size={16} />
</button>
</Tooltip>
<Tooltip label={t('products.card.remove')}>
<button
type="button"
className={styles.danger}
onClick={() => onRemove(product.id)}
aria-label={t('products.card.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div> </div>
</article> </article>
) )
@@ -0,0 +1,156 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MoreHorizontal, type LucideIcon } from 'lucide-react'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import { fitFixedPopover } from '../utils/fitFixedPopover'
import menuStyles from './CustomerActionsOverflowMenu.module.css'
import cardStyles from './ProductCard.module.css'
type PopoverPosition = {
top: number
left: number
}
export type ProductCardOverflowItem = {
key: string
label: string
icon: LucideIcon
onClick: () => void
danger?: boolean
}
interface ProductCardOverflowMenuProps {
productName: string
items: ProductCardOverflowItem[]
disabled?: boolean
}
export function ProductCardOverflowMenu({
productName,
items,
disabled = false,
}: ProductCardOverflowMenuProps) {
const t = useT()
const [open, setOpen] = useState(false)
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
function updatePopoverPosition() {
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 200, height: 44 * Math.max(items.length, 1) + 12 }
setPopoverPos(fitFixedPopover(rect, size, 'end'))
}
function toggleOpen() {
if (disabled || items.length === 0) return
if (open) {
setOpen(false)
return
}
updatePopoverPosition()
setOpen(true)
}
useLayoutEffect(() => {
if (!open) return
updatePopoverPosition()
}, [open])
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node
if (wrapRef.current?.contains(target)) return
if (popoverRef.current?.contains(target)) return
setOpen(false)
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
function handleReposition() {
updatePopoverPosition()
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
window.addEventListener('resize', handleReposition)
window.addEventListener('scroll', handleReposition, true)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
window.removeEventListener('resize', handleReposition)
window.removeEventListener('scroll', handleReposition, true)
}
}, [open])
if (items.length === 0) return null
const moreLabel = t('products.card.moreActions')
return (
<div className={menuStyles.wrap} ref={wrapRef}>
<Tooltip label={moreLabel}>
<button
ref={triggerRef}
type="button"
className={`${cardStyles.iconBtn} ${open ? menuStyles.triggerOpen : ''}`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleOpen()
}}
aria-label={`${moreLabel}: ${productName}`}
aria-haspopup="menu"
aria-expanded={open}
disabled={disabled}
>
<MoreHorizontal size={16} />
</button>
</Tooltip>
{open &&
popoverPos &&
createPortal(
<div
ref={popoverRef}
className={menuStyles.menu}
role="menu"
aria-label={`${moreLabel}: ${productName}`}
style={{
top: popoverPos.top,
left: popoverPos.left,
}}
>
{items.map(({ key, label, icon: Icon, onClick, danger }) => (
<button
key={key}
type="button"
className={`${menuStyles.item}${danger ? ` ${cardStyles.menuItemDanger}` : ''}`}
role="menuitem"
onClick={() => {
setOpen(false)
onClick()
}}
>
<Icon size={16} />
<span>{label}</span>
</button>
))}
</div>,
document.body,
)}
</div>
)
}
@@ -34,6 +34,12 @@ function formatTechnicalValue(item: ProductTechnicalValue): string {
const labels = item.optionLabels ?? (Array.isArray(item.value) ? item.value : []) const labels = item.optionLabels ?? (Array.isArray(item.value) ? item.value : [])
return labels.length ? labels.join(', ') : '—' return labels.length ? labels.join(', ') : '—'
} }
if (item.type === 'multi_value') {
const items = Array.isArray(item.value)
? item.value.map((entry) => String(entry).trim()).filter(Boolean)
: []
return items.length ? items.join('، ') : '—'
}
if (item.type === 'select') { if (item.type === 'select') {
return item.optionLabel ?? (item.value ? String(item.value) : '—') return item.optionLabel ?? (item.value ? String(item.value) : '—')
} }
@@ -239,7 +239,7 @@ export function ProductFieldAiPromptModal({
className={aiStyles.aiPromptTextarea} className={aiStyles.aiPromptTextarea}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={10} rows={14}
disabled={isRunning} disabled={isRunning}
required required
lang={isFa ? 'fa' : 'en'} lang={isFa ? 'fa' : 'en'}
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react' import { ChevronDown, Loader2, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import styles from './SearchableSelect.module.css' import styles from './SearchableSelect.module.css'
export const PRODUCT_SEARCH_DEBOUNCE_MS = 350
export interface ProductSearchOption { export interface ProductSearchOption {
id: string id: string
title: string title: string
@@ -18,6 +20,13 @@ interface ProductSearchSelectProps {
disabled?: boolean disabled?: boolean
id?: string id?: string
minSearchLength?: number minSearchLength?: number
isSearching?: boolean
/**
* When set, typing is debounced and the parent fetches results.
* Local filtering is skipped.
*/
onQueryChange?: (query: string) => void
debounceMs?: number
'aria-label'?: string 'aria-label'?: string
} }
@@ -36,6 +45,9 @@ export function ProductSearchSelect({
disabled = false, disabled = false,
id, id,
minSearchLength = 3, minSearchLength = 3,
isSearching = false,
onQueryChange,
debounceMs = PRODUCT_SEARCH_DEBOUNCE_MS,
'aria-label': ariaLabel, 'aria-label': ariaLabel,
}: ProductSearchSelectProps) { }: ProductSearchSelectProps) {
const t = useT() const t = useT()
@@ -45,20 +57,35 @@ export function ProductSearchSelect({
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const onQueryChangeRef = useRef(onQueryChange)
onQueryChangeRef.current = onQueryChange
const selected = options.find((option) => option.id === value) const selected = options.find((option) => option.id === value)
const selectedLabel = selected ? formatProductLabel(selected, preferFa) : '' const selectedLabel = selected ? formatProductLabel(selected, preferFa) : ''
const trimmedQuery = query.trim()
const canSearch = trimmedQuery.length >= minSearchLength
useEffect(() => {
const cb = onQueryChangeRef.current
if (!cb) return
if (!canSearch) {
cb('')
return
}
const timer = window.setTimeout(() => cb(trimmedQuery), debounceMs)
return () => window.clearTimeout(timer)
}, [canSearch, trimmedQuery, debounceMs])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const trimmed = query.trim() if (!canSearch) return []
if (trimmed.length < minSearchLength) return [] if (onQueryChange) return options
const q = trimmed.toLowerCase() const q = trimmedQuery.toLowerCase()
return options.filter( return options.filter(
(option) => (option) =>
option.title.toLowerCase().includes(q) || option.title.toLowerCase().includes(q) ||
option.nameFa.includes(trimmed), option.nameFa.includes(trimmedQuery),
) )
}, [options, query, minSearchLength]) }, [canSearch, onQueryChange, options, trimmedQuery])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -76,13 +103,14 @@ export function ProductSearchSelect({
onChange(id) onChange(id)
setOpen(false) setOpen(false)
setQuery('') setQuery('')
onQueryChangeRef.current?.('')
} }
function handleFocus() { function handleFocus() {
if (!disabled) setOpen(true) if (!disabled) setOpen(true)
} }
const showDropdown = open && query.trim().length >= minSearchLength const showDropdown = open && (canSearch || trimmedQuery.length > 0)
return ( return (
<div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}> <div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}>
@@ -115,17 +143,19 @@ export function ProductSearchSelect({
<X size={14} /> <X size={14} />
</button> </button>
)} )}
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} /> {isSearching && open ? (
<Loader2 size={16} className={`${styles.chevron} ${styles.spin}`} />
) : (
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
)}
</div> </div>
{showDropdown && ( {showDropdown && canSearch && (
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox"> <ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox">
{filtered.length === 0 ? ( {isSearching && filtered.length === 0 ? (
<li className={styles.noResults}> <li className={styles.noResults}>{t('storeItems.search.searching')}</li>
{options.length === 0 ) : filtered.length === 0 ? (
? t('storeItems.search.noProducts') <li className={styles.noResults}>{t('storeItems.search.noMatch')}</li>
: t('storeItems.search.noMatch')}
</li>
) : ( ) : (
filtered.map((option) => ( filtered.map((option) => (
<li key={option.id}> <li key={option.id}>
@@ -172,7 +202,7 @@ export function ProductSearchSelect({
</ul> </ul>
)} )}
{open && query.trim().length > 0 && query.trim().length < minSearchLength && ( {open && trimmedQuery.length > 0 && trimmedQuery.length < minSearchLength && (
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}> <ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}>
<li className={styles.noResults}> <li className={styles.noResults}>
{t('storeItems.search.minChars', { count: minSearchLength })} {t('storeItems.search.minChars', { count: minSearchLength })}
@@ -0,0 +1,3 @@
.modal {
width: min(560px, calc(100vw - 32px));
}
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import { getProduct, updateProduct } from '../services/productService'
import { seoMetaPayload } from '../utils/seoMeta'
import { SeoMetaFields } from './SeoMetaFields'
import styles from './VariationsModal.module.css'
import seoStyles from './ProductSeoModal.module.css'
interface ProductSeoModalProps {
open: boolean
productId: string
productName: string
onClose: () => void
}
const ANIMATION_MS = 220
export function ProductSeoModal({
open,
productId,
productName,
onClose,
}: ProductSeoModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open || !productId) return
const controller = new AbortController()
void loadData(controller.signal)
return () => controller.abort()
}, [open, productId])
async function loadData(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const product = await getProduct(productId, signal)
setSeoMetaTitle(product.seoMetaTitle ?? '')
setSeoMetaDescription(product.seoMetaDescription ?? '')
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('products.seo.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function handleSave() {
setIsSaving(true)
setError('')
try {
await updateProduct(productId, seoMetaPayload(seoMetaTitle, seoMetaDescription))
onClose()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('products.seo.errorSave'))
}
} finally {
setIsSaving(false)
}
}
if (!mounted) return null
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !isSaving && onClose()}
>
<div
className={`${styles.modal} ${seoStyles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="product-seo-title"
>
<div className={styles.header}>
<div>
<h3 id="product-seo-title" className={styles.title}>
{t('products.seo.title')}
</h3>
{productName ? <p className={styles.subtitle}>{productName}</p> : null}
</div>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={isSaving}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={styles.body}>
{isLoading ? (
<p className={styles.emptyText}>{t('products.seo.loading')}</p>
) : (
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSaving}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
)}
{error ? <p className={styles.errorText}>{error}</p> : null}
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSaving}
>
{t('common.cancel')}
</button>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSave()}
disabled={isLoading || isSaving}
>
{isSaving ? t('products.seo.saving') : t('products.seo.save')}
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import { import {
fillProductTechnicalInfoByAi,
getProductTechnicalInfo, getProductTechnicalInfo,
saveProductTechnicalInfo, saveProductTechnicalInfo,
valuesToFormState, valuesToFormState,
@@ -12,8 +15,11 @@ import type {
ProductTechnicalFormValues, ProductTechnicalFormValues,
TechnicalFieldType, TechnicalFieldType,
} from '../types/technicalForm' } from '../types/technicalForm'
import { ProductTechnicalValuesAiPromptModal } from './ProductTechnicalValuesAiPromptModal'
import { TechnicalMultiValueEditor } from './TechnicalMultiValueEditor'
import styles from './VariationsModal.module.css' import styles from './VariationsModal.module.css'
import fieldStyles from './TechnicalFormModal.module.css' import fieldStyles from './TechnicalFormModal.module.css'
import aiStyles from '../styles/ai.module.css'
interface ProductTechnicalInfoModalProps { interface ProductTechnicalInfoModalProps {
open: boolean open: boolean
@@ -32,12 +38,17 @@ export function ProductTechnicalInfoModal({
categoryId, categoryId,
onClose, onClose,
}: ProductTechnicalInfoModalProps) { }: ProductTechnicalInfoModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [form, setForm] = useState<CategoryTechnicalForm | null>(null) const [form, setForm] = useState<CategoryTechnicalForm | null>(null)
const [values, setValues] = useState<ProductTechnicalFormValues>({}) const [values, setValues] = useState<ProductTechnicalFormValues>({})
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isAiOpen, setIsAiOpen] = useState(false)
const [isAiRunning, setIsAiRunning] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [infoMessage, setInfoMessage] = useState('') const [infoMessage, setInfoMessage] = useState('')
@@ -45,8 +56,10 @@ export function ProductTechnicalInfoModal({
if (open) { if (open) {
setMounted(true) setMounted(true)
setClosing(false) setClosing(false)
setIsAiOpen(false)
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
setIsAiOpen(false)
const timer = setTimeout(() => { const timer = setTimeout(() => {
setMounted(false) setMounted(false)
setClosing(false) setClosing(false)
@@ -75,16 +88,16 @@ export function ProductTechnicalInfoModal({
if (data.message) { if (data.message) {
setInfoMessage(data.message) setInfoMessage(data.message)
} else if (!categoryId) { } else if (!categoryId) {
setInfoMessage('Assign a category to this product before adding technical data.') setInfoMessage(t('products.technical.needCategory'))
} else if (!data.form) { } else if (!data.form) {
setInfoMessage('No technical form is defined for this product category.') setInfoMessage(t('products.technical.noForm'))
} }
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load technical data.') setError(t('products.technical.loadError'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -92,13 +105,13 @@ export function ProductTechnicalInfoModal({
} }
useEffect(() => { useEffect(() => {
if (!mounted || closing) return if (!mounted || closing || isAiOpen) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
} }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose]) }, [mounted, closing, isAiOpen, onClose])
if (!mounted) return null if (!mounted) return null
@@ -123,8 +136,11 @@ export function ProductTechnicalInfoModal({
return form.fields.every((field) => { return form.fields.every((field) => {
if (!field.isRequired) return true if (!field.isRequired) return true
const value = values[field.key] const value = values[field.key]
if (field.type === 'multi_select') { if (field.type === 'multi_select' || field.type === 'multi_value') {
return Array.isArray(value) && value.length > 0 return (
Array.isArray(value) &&
value.some((item) => String(item).trim().length > 0)
)
} }
return typeof value === 'string' && value.trim().length > 0 return typeof value === 'string' && value.trim().length > 0
}) })
@@ -145,13 +161,26 @@ export function ProductTechnicalInfoModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save technical data.') setError(t('products.technical.saveError'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
} }
async function handleAiRun(prompt: string) {
setIsAiRunning(true)
setError('')
try {
const result = await fillProductTechnicalInfoByAi(productId, prompt)
setValues((prev) => ({ ...prev, ...result.values }))
setInfoMessage(t('products.technical.ai.filled'))
setIsAiOpen(false)
} finally {
setIsAiRunning(false)
}
}
function renderField(field: CategoryTechnicalForm['fields'][number]) { function renderField(field: CategoryTechnicalForm['fields'][number]) {
const value = values[field.key] const value = values[field.key]
const type = field.type as TechnicalFieldType const type = field.type as TechnicalFieldType
@@ -173,7 +202,7 @@ export function ProductTechnicalInfoModal({
value={typeof value === 'string' ? value : ''} value={typeof value === 'string' ? value : ''}
onChange={(e) => setFieldValue(field.key, e.target.value)} onChange={(e) => setFieldValue(field.key, e.target.value)}
> >
<option value="">Select...</option> <option value="">{t('products.technical.selectPlaceholder')}</option>
{field.options.map((option) => ( {field.options.map((option) => (
<option key={option.id} value={option.value}> <option key={option.id} value={option.value}>
{option.label} {option.label}
@@ -201,6 +230,19 @@ export function ProductTechnicalInfoModal({
) )
} }
if (type === 'multi_value') {
const items = Array.isArray(value) ? value.map(String) : []
return (
<TechnicalMultiValueEditor
id={`tech-${field.key}`}
values={items}
onChange={(next) => setFieldValue(field.key, next)}
addLabel={t('products.technical.addValue')}
removeLabel={t('products.technical.removeValue')}
/>
)
}
return ( return (
<input <input
id={`tech-${field.key}`} id={`tech-${field.key}`}
@@ -211,68 +253,106 @@ export function ProductTechnicalInfoModal({
) )
} }
const canUseAi = Boolean(form && form.fields.length > 0)
return createPortal( return createPortal(
<div <>
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div <div
className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={(e) => e.stopPropagation()} onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="product-technical-title"
> >
<div className={styles.header}> <div
<div> className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`}
<h3 id="product-technical-title" className={styles.title}> onClick={(e) => e.stopPropagation()}
Technical Data role="dialog"
</h3> aria-modal="true"
{productName && <p className={styles.subtitle}>{productName}</p>} aria-labelledby="product-technical-title"
</div> lang={isFa ? 'fa' : 'en'}
<button className={styles.closeBtn} onClick={onClose} aria-label="Close"> dir={isFa ? 'rtl' : 'ltr'}
<X size={20} /> >
</button> <div className={styles.header}>
</div> <div>
<h3 id="product-technical-title" className={styles.title}>
<div className={styles.body}> {t('products.technical.title')}
{isLoading ? ( </h3>
<p className={styles.emptyText}>Loading technical data...</p> {productName && <p className={styles.subtitle}>{productName}</p>}
) : infoMessage && !form ? ( </div>
<p className={styles.emptyText}>{infoMessage}</p>
) : form && form.fields.length === 0 ? (
<p className={styles.emptyText}>This category form has no fields yet.</p>
) : (
form?.fields.map((field) => (
<div key={field.id} className={styles.field}>
<label htmlFor={`tech-${field.key}`}>
{field.label}
{field.isRequired && ' *'}
</label>
{renderField(field)}
</div>
))
)}
{error && <p className={styles.errorText}>{error}</p>}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Cancel
</button>
<button <button
type="button" className={styles.closeBtn}
className={styles.submitBtn} onClick={onClose}
onClick={() => void handleSave()} aria-label={t('common.close')}
disabled={!form || !isFormValid() || isSaving || isLoading}
> >
{isSaving ? 'Saving…' : 'Save'} <X size={20} />
</button> </button>
</div> </div>
<div className={styles.body}>
{isLoading ? (
<p className={styles.emptyText}>{t('products.technical.loading')}</p>
) : infoMessage && !form ? (
<p className={styles.emptyText}>{infoMessage}</p>
) : form && form.fields.length === 0 ? (
<p className={styles.emptyText}>{t('products.technical.emptyFields')}</p>
) : (
form?.fields.map((field) => (
<div key={field.id} className={styles.field}>
<label htmlFor={`tech-${field.key}`}>
{field.label}
{field.isRequired && ' *'}
</label>
{renderField(field)}
</div>
))
)}
{infoMessage && form ? (
<p className={styles.emptyText}>{infoMessage}</p>
) : null}
{error && <p className={styles.errorText}>{error}</p>}
<div className={fieldStyles.productTechnicalActions}>
<div className={fieldStyles.actionsMain}>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSave()}
disabled={!form || !isFormValid() || isSaving || isLoading || isAiRunning}
>
{isSaving ? t('products.technical.saving') : t('products.technical.save')}
</button>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
{t('common.cancel')}
</button>
</div>
{canUseAi ? (
<button
type="button"
className={`${aiStyles.aiBtn} ${fieldStyles.actionsAi}`}
onClick={() => setIsAiOpen(true)}
disabled={isLoading || isSaving || isAiRunning}
>
<Sparkles size={16} />
{isAiRunning
? t('products.form.aiGenerating')
: t('products.technical.generateAi')}
</button>
) : null}
</div>
</div>
</div> </div>
</div> </div>
</div>
, <ProductTechnicalValuesAiPromptModal
document.body, open={isAiOpen}
productName={productName}
fields={form?.fields ?? []}
isRunning={isAiRunning}
onClose={() => {
if (!isAiRunning) setIsAiOpen(false)
}}
onRun={handleAiRun}
/>
</>,
document.body,
) )
} }
@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { CategoryTechnicalForm } from '../types/technicalForm'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css'
export function buildProductTechnicalValuesAiPrompt(ctx: {
productName: string
fields: CategoryTechnicalForm['fields']
}) {
const productName = ctx.productName || '—'
const hasOtherFeatures = ctx.fields.some(
(field) =>
field.type === 'multi_value' &&
/سایر\s*ویژگی/.test(field.label.replace(/\u200c/g, '')),
)
const fieldLines = ctx.fields
.map((field) => {
const options =
field.type === 'select' || field.type === 'multi_select'
? field.options.map((option) => option.value).join(' | ')
: ''
const optionPart = options ? ` — گزینه‌ها: ${options}` : ''
const isOtherFeatures =
field.type === 'multi_value' &&
/سایر\s*ویژگی/.test(field.label.replace(/\u200c/g, ''))
const multiValueHint = isOtherFeatures
? ' — هر مشخصه/ویژگی دیگری از محصول که در بقیه فیلدها جا نشده (آرایه رشته‌ها)'
: field.type === 'multi_value'
? ' — آرایه‌ای از رشته‌های متنی آزاد'
: ''
return `- ${field.key} (${field.label}, ${field.type}${field.isRequired ? '، الزامی' : ''})${optionPart}${multiValueHint}`
})
.join('\n')
const otherFeaturesRule = hasOtherFeatures
? `- فیلد «سایر ویژگی‌ها» را با هر مشخصات یا ویژگی دیگری از محصول پر کن که در فیلدهای مشخص دیگر جا نمی‌گیرد (مثلاً قابلیت‌ها، لوازم همراه، استانداردها، نکات خاص صفحه محصول). هر مورد را به‌صورت یک آیتم جدا در آرایه بنویس.`
: `- برای فیلدهای multi_value یک آرایه از مقادیر متنی کوتاه بده؛ اگر برچسب آن‌ها «سایر ویژگی‌ها» است، هر مشخصهٔ باقی‌ماندهٔ محصول را آنجا بگذار.`
return `برای محصول زیر، مقادیر فرم اطلاعات فنی را پر کن.
محصول: ${productName}
فیلدهای فرم:
${fieldLines || '—'}
الزامات:
- فقط برای همین فیلدها مقدار بده و از کلیدهای دقیق فیلد استفاده کن.
- برای select / multi_select فقط از مقادیر مجاز گزینه‌ها استفاده کن.
${otherFeaturesRule}
- متن‌ها را به فارسی و کوتاه و فنی بنویس.
- مشخصات غیرواقعی اختراع نکن؛ اگر مطمئن نیستی آن فیلد را خالی بگذار.
- در صورت نیاز، لینک صفحه محصول را در ادامه همین پرامپت اضافه کن (سیستم صفحه را باز می‌کند و از متن آن استفاده می‌کند).`
}
interface ProductTechnicalValuesAiPromptModalProps {
open: boolean
productName: string
fields: CategoryTechnicalForm['fields']
isRunning: boolean
onClose: () => void
onRun: (prompt: string) => Promise<void>
}
const ANIMATION_MS = 220
export function ProductTechnicalValuesAiPromptModal({
open,
productName,
fields,
isRunning,
onClose,
onRun,
}: ProductTechnicalValuesAiPromptModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState('')
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setPrompt(
buildProductTechnicalValuesAiPrompt({
productName,
fields,
}),
)
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, productName, fields])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isRunning) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, isRunning, onClose])
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmed = prompt.trim()
if (trimmed.length < 10) {
setError(t('products.form.aiPromptTooShort'))
return
}
setError('')
try {
await onRun(trimmed)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError(t('products.technical.ai.error'))
}
}
}
if (!mounted) return null
return createPortal(
<div
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="product-technical-ai-prompt-title"
lang="fa"
dir="rtl"
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h2 id="product-technical-ai-prompt-title" className={modalStyles.title}>
{t('products.technical.ai.title')}
</h2>
<p className={styles.subtitle}>{t('products.technical.ai.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isRunning}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
<label htmlFor="product-technical-ai-prompt">{t('products.form.aiPrompt')}</label>
<textarea
id="product-technical-ai-prompt"
className={aiStyles.aiPromptTextarea}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={14}
disabled={isRunning}
required
lang="fa"
dir="rtl"
/>
</div>
{error ? <p className={styles.error}>{error}</p> : null}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isRunning}
>
{t('products.form.cancel')}
</button>
<button
type="submit"
className={aiStyles.aiBtn}
disabled={isRunning || prompt.trim().length < 10}
>
<Sparkles size={16} />
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
countSelectedOptions, countSelectedOptions,
@@ -8,6 +10,7 @@ import {
saveProductVariationValues, saveProductVariationValues,
type ProductVariationSelection, type ProductVariationSelection,
} from '../services/productVariationService' } from '../services/productVariationService'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { MultiSelectDropdown } from './MultiSelectDropdown' import { MultiSelectDropdown } from './MultiSelectDropdown'
import styles from './VariationsModal.module.css' import styles from './VariationsModal.module.css'
@@ -30,6 +33,9 @@ export function ProductVariantsModal({
onClose, onClose,
onVariantsChange, onVariantsChange,
}: ProductVariantsModalProps) { }: ProductVariantsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [variations, setVariations] = useState<ProductVariationSelection[]>([]) const [variations, setVariations] = useState<ProductVariationSelection[]>([])
@@ -72,7 +78,7 @@ export function ProductVariantsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load product variations.') setError(t('products.variants.loadError'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -90,6 +96,12 @@ export function ProductVariantsModal({
if (!mounted) return null if (!mounted) return null
function variationLabel(variation: ProductVariationSelection) {
if (variation.type === 'color') return t('categories.variations.type.color')
if (variation.type === 'size') return t('categories.variations.type.size')
return variation.name
}
function handleSelectionChange(variationId: string, optionIds: string[]) { function handleSelectionChange(variationId: string, optionIds: string[]) {
setVariations((prev) => setVariations((prev) =>
prev.map((variation) => prev.map((variation) =>
@@ -113,7 +125,7 @@ export function ProductVariantsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save product variations.') setError(t('products.variants.saveError'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -131,54 +143,63 @@ export function ProductVariantsModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="product-variants-title" aria-labelledby="product-variants-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={styles.header}> <div className={styles.header}>
<div> <div>
<h3 id="product-variants-title" className={styles.title}> <h3 id="product-variants-title" className={styles.title}>
Product Variations {t('products.variants.title')}
</h3> </h3>
{productName && <p className={styles.subtitle}>{productName}</p>} {productName && <p className={styles.subtitle}>{productName}</p>}
</div> </div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close"> <button
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{!categoryId && ( {!categoryId && (
<p className={styles.errorText}> <p className={styles.errorText}>{t('products.variants.needCategory')}</p>
Assign a category to this product before managing variations.
</p>
)} )}
{error && <p className={styles.errorText}>{error}</p>} {error && <p className={styles.errorText}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.emptyText}>Loading variations...</p> <p className={styles.emptyText}>{t('products.variants.loading')}</p>
) : variations.length === 0 ? ( ) : variations.length === 0 ? (
<p className={styles.emptyText}> <p className={styles.emptyText}>
{categoryId {categoryId
? 'This category has no variations. Add variations on the category first.' ? t('products.variants.emptyCategory')
: 'No variations available.'} : t('products.variants.empty')}
</p> </p>
) : ( ) : (
<div className={styles.variationFields}> <div className={styles.variationFields}>
{variations.map((variation) => ( {variations.map((variation) => {
<div key={variation.id} className={styles.field}> const name = variationLabel(variation)
<label htmlFor={`variation-${variation.id}`}>{variation.name}</label> return (
<MultiSelectDropdown <div key={variation.id} className={styles.field}>
id={`variation-${variation.id}`} <label htmlFor={`variation-${variation.id}`}>{name}</label>
options={variation.options.map((option) => ({ <MultiSelectDropdown
value: option.id, id={`variation-${variation.id}`}
label: option.label, options={variation.options.map((option) => ({
}))} value: option.id,
value={variation.selectedOptionIds} label: localizeVariationOptionLabel(option.label, t),
onChange={(optionIds) => handleSelectionChange(variation.id, optionIds)} }))}
placeholder={`Select ${variation.name.toLowerCase()} values`} value={variation.selectedOptionIds}
disabled={isSaving} onChange={(optionIds) =>
/> handleSelectionChange(variation.id, optionIds)
</div> }
))} placeholder={t('products.variants.selectValues', { name })}
disabled={isSaving}
/>
</div>
)
})}
</div> </div>
)} )}
@@ -189,7 +210,7 @@ export function ProductVariantsModal({
onClick={onClose} onClick={onClose}
disabled={isSaving} disabled={isSaving}
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -197,13 +218,12 @@ export function ProductVariantsModal({
onClick={() => void handleSave()} onClick={() => void handleSave()}
disabled={!categoryId || isLoading || isSaving || variations.length === 0} disabled={!categoryId || isLoading || isSaving || variations.length === 0}
> >
{isSaving ? 'Saving…' : 'Save variations'} {isSaving ? t('products.variants.saving') : t('products.variants.save')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
, document.body,
document.body,
) )
} }
@@ -0,0 +1,176 @@
.modal {
max-width: 520px;
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes overlayFadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
.error {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.timeRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.tasksBlock {
margin-top: 4px;
margin-bottom: 16px;
}
.tasksHeader {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.tasksLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.addTaskRowBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px dashed rgba(var(--primary-rgb) / 0.4);
border-radius: var(--radius-sm);
transition: background 0.2s, border-color 0.2s, color 0.2s;
box-sizing: border-box;
}
.addTaskRowBtn:hover {
background: rgba(var(--primary-rgb) / 0.14);
border-color: rgba(var(--primary-rgb) / 0.55);
}
.taskList {
display: flex;
flex-direction: column;
gap: 8px;
}
.taskRow {
display: flex;
align-items: center;
gap: 8px;
}
.taskRow input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
min-height: var(--field-height);
box-sizing: border-box;
}
.taskRow input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
}
.removeTaskBtn {
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.removeTaskBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
}
.removeTaskBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
.cancelBtn,
.saveBtn {
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-sm);
transition: background 0.2s, transform 0.15s;
}
.cancelBtn {
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.2);
}
.saveBtn {
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-dark-rgb) / 0.3);
}
.saveBtn:hover:not(:disabled) {
transform: translateY(-1px);
}
.cancelBtn:disabled,
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 480px) {
.timeRow {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,279 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
createRollCall,
updateRollCall,
type RollCall,
type RollCallInput,
} from '../services/rollCallService'
import modalStyles from './VariationsModal.module.css'
import styles from './RollCallFormModal.module.css'
interface RollCallFormModalProps {
open: boolean
rollCall: RollCall | null
onClose: () => void
onSaved: (rollCall: RollCall) => void
}
const ANIMATION_MS = 220
function emptyTasks() {
return ['']
}
export function RollCallFormModal({
open,
rollCall,
onClose,
onSaved,
}: RollCallFormModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [workDate, setWorkDate] = useState('')
const [startTime, setStartTime] = useState('09:00')
const [endTime, setEndTime] = useState('17:00')
const [tasks, setTasks] = useState<string[]>(emptyTasks())
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setError('')
if (rollCall) {
setWorkDate(rollCall.workDate)
setStartTime(rollCall.startTime)
setEndTime(rollCall.endTime)
setTasks(
rollCall.tasks.length > 0
? rollCall.tasks.map((task) => task.title)
: emptyTasks(),
)
} else {
setWorkDate(new Date().toISOString().slice(0, 10))
setStartTime('09:00')
setEndTime('17:00')
setTasks(emptyTasks())
}
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, rollCall])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted) return null
const isEdit = Boolean(rollCall?.id)
function updateTask(index: number, value: string) {
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
}
function addTask() {
setTasks((prev) => [...prev, ''])
}
function removeTask(index: number) {
setTasks((prev) => {
if (prev.length <= 1) return ['']
return prev.filter((_, i) => i !== index)
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const cleanedTasks = tasks.map((task) => task.trim()).filter(Boolean)
if (!workDate || !startTime || !endTime) {
setError(t('rollCall.error.incomplete'))
return
}
if (cleanedTasks.length === 0) {
setError(t('rollCall.error.tasksRequired'))
return
}
const input: RollCallInput = {
workDate,
startTime,
endTime,
tasks: cleanedTasks.map((title) => ({ title })),
}
setSaving(true)
try {
const result = rollCall
? await updateRollCall(rollCall.id, input)
: await createRollCall(input)
onSaved(result.rollCall)
showToast(
rollCall ? t('rollCall.toast.updated') : t('rollCall.toast.created'),
'success',
)
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : t('rollCall.error.save')
setError(message)
showToast(message, 'error')
} finally {
setSaving(false)
}
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="roll-call-form-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="roll-call-form-title" className={modalStyles.title}>
{isEdit ? t('rollCall.modal.editTitle') : t('rollCall.modal.addTitle')}
</h2>
<p className={modalStyles.subtitle}>{t('rollCall.modal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<form className={modalStyles.body} onSubmit={handleSubmit}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={modalStyles.field}>
<label htmlFor="roll-call-date">{t('rollCall.field.date')}</label>
<input
id="roll-call-date"
type="date"
value={workDate}
onChange={(e) => setWorkDate(e.target.value)}
required
/>
</div>
<div className={styles.timeRow}>
<div className={modalStyles.field}>
<label htmlFor="roll-call-start">{t('rollCall.field.startTime')}</label>
<input
id="roll-call-start"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="roll-call-end">{t('rollCall.field.endTime')}</label>
<input
id="roll-call-end"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
<div className={styles.tasksBlock}>
<div className={styles.tasksHeader}>
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
</div>
<div className={styles.taskList}>
{tasks.map((task, index) => (
<div key={index} className={styles.taskRow}>
<input
type="text"
value={task}
onChange={(e) => updateTask(index, e.target.value)}
placeholder={t('rollCall.field.taskPlaceholder')}
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
/>
<button
type="button"
className={styles.removeTaskBtn}
onClick={() => removeTask(index)}
aria-label={t('rollCall.removeTask')}
disabled={tasks.length === 1 && !task.trim()}
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
<Plus size={18} strokeWidth={2.5} />
{t('rollCall.addTask')}
</button>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={saving}
>
{t('rollCall.cancel')}
</button>
<button type="submit" className={styles.saveBtn} disabled={saving}>
{saving ? t('rollCall.saving') : t('rollCall.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { updateRollCall, type RollCall } from '../services/rollCallService'
import modalStyles from './VariationsModal.module.css'
import styles from './RollCallFormModal.module.css'
interface RollCallTasksModalProps {
open: boolean
rollCall: RollCall | null
onClose: () => void
onSaved: (rollCall: RollCall) => void
}
const ANIMATION_MS = 220
export function RollCallTasksModal({
open,
rollCall,
onClose,
onSaved,
}: RollCallTasksModalProps) {
const t = useT()
const { locale, dir } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [tasks, setTasks] = useState<string[]>([''])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setError('')
setTasks(
rollCall && rollCall.tasks.length > 0
? rollCall.tasks.map((task) => task.title)
: [''],
)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, rollCall])
useEffect(() => {
if (!mounted || closing) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onClose])
if (!mounted || !rollCall) return null
function updateTask(index: number, value: string) {
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
}
function addTask() {
setTasks((prev) => [...prev, ''])
}
function removeTask(index: number) {
setTasks((prev) => {
if (prev.length <= 1) return ['']
return prev.filter((_, i) => i !== index)
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!rollCall) return
setError('')
const cleaned = tasks.map((task) => task.trim()).filter(Boolean)
setSaving(true)
try {
const result = await updateRollCall(rollCall.id, {
tasks: cleaned.map((title) => ({ title })),
})
onSaved(result.rollCall)
showToast(t('rollCall.toast.tasksSaved'), 'success')
onClose()
} catch (err) {
const message =
err instanceof ApiError ? err.message : t('rollCall.error.save')
setError(message)
showToast(message, 'error')
} finally {
setSaving(false)
}
}
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="roll-call-tasks-title"
lang={locale}
dir={dir}
>
<div className={modalStyles.header}>
<div>
<h2 id="roll-call-tasks-title" className={modalStyles.title}>
{t('rollCall.tasksModal.title')}
</h2>
<p className={modalStyles.subtitle}>{t('rollCall.tasksModal.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<form className={modalStyles.body} onSubmit={handleSubmit}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.tasksBlock}>
<div className={styles.tasksHeader}>
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
</div>
<div className={styles.taskList}>
{tasks.map((task, index) => (
<div key={index} className={styles.taskRow}>
<input
type="text"
value={task}
onChange={(e) => updateTask(index, e.target.value)}
placeholder={t('rollCall.field.taskPlaceholder')}
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
/>
<button
type="button"
className={styles.removeTaskBtn}
onClick={() => removeTask(index)}
aria-label={t('rollCall.removeTask')}
disabled={tasks.length === 1 && !task.trim()}
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
<Plus size={18} strokeWidth={2.5} />
{t('rollCall.addTask')}
</button>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={saving}
>
{t('rollCall.cancel')}
</button>
<button type="submit" className={styles.saveBtn} disabled={saving}>
{saving ? t('rollCall.saving') : t('rollCall.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -96,11 +96,21 @@
.dropdownInFlow { .dropdownInFlow {
position: static; position: static;
margin-top: 6px; margin-top: 6px;
max-height: none; max-height: min(280px, 40vh);
overflow: visible; overflow-y: auto;
z-index: auto; z-index: auto;
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes dropdownIn { @keyframes dropdownIn {
from { from {
opacity: 0; opacity: 0;
@@ -217,6 +227,8 @@
.optionEn { .optionEn {
font-weight: 500; font-weight: 500;
text-align: start; text-align: start;
direction: ltr;
unicode-bidi: isolate;
} }
.optionEn[dir='rtl'] { .optionEn[dir='rtl'] {
@@ -195,13 +195,17 @@ export function SearchableSelect({
{cat.nameEn ? ( {cat.nameEn ? (
<> <>
<span className={styles.optionSep}>·</span> <span className={styles.optionSep}>·</span>
<span className={styles.optionEn}>{cat.nameEn}</span> <span className={styles.optionEn} dir="ltr" lang="en">
{cat.nameEn}
</span>
</> </>
) : null} ) : null}
</> </>
) : ( ) : (
<> <>
<span className={styles.optionEn}>{cat.nameEn}</span> <span className={styles.optionEn} dir="ltr" lang="en">
{cat.nameEn}
</span>
{cat.nameFa ? ( {cat.nameFa ? (
<> <>
<span className={styles.optionSep}>·</span> <span className={styles.optionSep}>·</span>
@@ -0,0 +1,362 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.modal {
width: 100%;
max-width: 560px;
max-height: calc(100vh - 40px);
overflow: auto;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.closeBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.error {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label,
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.hint {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}
.field input,
.field textarea {
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
box-sizing: border-box;
}
.field input {
min-height: var(--field-height);
}
.field input:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.field textarea {
resize: vertical;
min-height: calc(var(--field-height) + 24px);
}
.chipGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 50px;
transition: all 0.15s;
cursor: pointer;
}
.chip:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.chipSelected {
background: rgba(var(--primary-rgb) / 0.12);
border-color: var(--primary);
color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb) / 0.2);
}
.chipFa {
font-weight: 600;
}
.chipEn {
font-size: 11px;
font-weight: 600;
opacity: 0.72;
font-family: var(--font-en), var(--font-ui);
letter-spacing: 0.01em;
}
.chipSelected .chipEn {
opacity: 0.85;
}
.amountRow {
display: flex;
align-items: center;
gap: 10px;
}
.amountRow input {
flex: 1;
min-width: 0;
}
.amountUnit {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.pasKrayeCheck {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-inline-start: 4px;
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(148, 163, 184, 0.08);
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.pasKrayeCheck input {
width: 15px;
height: 15px;
accent-color: var(--primary);
cursor: pointer;
}
.amountRow input:disabled {
opacity: 0.55;
cursor: not-allowed;
background: rgba(148, 163, 184, 0.1);
}
.checkRow {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(148, 163, 184, 0.06);
cursor: pointer;
user-select: none;
}
.checkRow input {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--primary);
cursor: pointer;
}
.checkText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.checkTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.checkHint {
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
direction: rtl;
}
.cancelBtn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.15);
}
.submitBtn {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
transition: transform 0.2s, box-shadow 0.2s;
}
.submitBtn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.submitBtn:disabled,
.cancelBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.overlayIn {
animation: overlayFadeIn 0.2s ease forwards;
}
.overlayOut {
animation: overlayFadeOut 0.2s ease forwards;
}
.modalIn {
animation: modalFadeIn 0.25s ease forwards;
}
.modalOut {
animation: modalFadeOut 0.22s ease forwards;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modalFadeOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
@@ -0,0 +1,314 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type {
ShippingCostGroup,
ShippingCostGroupFormPayload,
ShippingMethodId,
} from '../types/shippingCostGroup'
import { SHIPPING_METHOD_IDS } from '../types/shippingCostGroup'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './ShippingCostGroupModal.module.css'
const METHOD_EN_LABELS: Record<ShippingMethodId, string> = {
post: 'Post',
tipax: 'Tipax',
mahex: 'Mahex',
freight: 'Freight',
air: 'Air',
}
interface ShippingCostGroupModalProps {
open: boolean
onClose: () => void
onSubmit: (data: ShippingCostGroupFormPayload) => void
editingGroup?: ShippingCostGroup | null
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function ShippingCostGroupModal({
open,
onClose,
onSubmit,
editingGroup = null,
isSubmitting = false,
}: ShippingCostGroupModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [nameFa, setNameFa] = useState('')
const [nameEn, setNameEn] = useState('')
const [description, setDescription] = useState('')
const [methods, setMethods] = useState<ShippingMethodId[]>([])
const [defaultAmount, setDefaultAmount] = useState('')
const [pasKraye, setPasKraye] = useState(false)
const [isDefault, setIsDefault] = useState(false)
const [formError, setFormError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setNameFa(editingGroup?.nameFa ?? '')
setNameEn(editingGroup?.nameEn ?? '')
setDescription(editingGroup?.description ?? '')
setMethods(editingGroup?.methods ?? [])
setPasKraye(editingGroup?.pasKraye ?? false)
setDefaultAmount(
editingGroup
? formatIrtInput(String(Math.round(editingGroup.defaultAmount)))
: '',
)
setIsDefault(editingGroup?.isDefault ?? false)
setFormError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, editingGroup, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, isSubmitting])
if (!mounted) return null
function toggleMethod(method: ShippingMethodId) {
setMethods((prev) =>
prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method],
)
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setFormError('')
const trimmedFa = nameFa.trim()
const trimmedEn = nameEn.trim()
const amount = pasKraye ? 0 : parseIrtInput(defaultAmount)
if (!trimmedFa || !trimmedEn) {
setFormError(t('store.shipping.form.errorRequired'))
return
}
if (!methods.length) {
setFormError(t('store.shipping.form.errorMethods'))
return
}
if (!pasKraye && (amount === null || amount < 0)) {
setFormError(t('store.shipping.form.errorAmount'))
return
}
onSubmit({
nameFa: trimmedFa,
nameEn: trimmedEn,
description: description.trim() || undefined,
methods,
defaultAmount: amount ?? 0,
pasKraye,
isDefault,
})
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !isSubmitting && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="shipping-group-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="shipping-group-modal-title" className={styles.title}>
{editingGroup ? t('store.shipping.form.editTitle') : t('store.shipping.form.addTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="shipping-name-fa">{t('store.shipping.form.nameFa')}</label>
<input
id="shipping-name-fa"
type="text"
dir="rtl"
className="faText"
placeholder={t('store.shipping.form.nameFaPlaceholder')}
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="shipping-name-en">{t('store.shipping.form.nameEn')}</label>
<input
id="shipping-name-en"
type="text"
dir="ltr"
lang="en"
placeholder={t('store.shipping.form.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="shipping-description">{t('store.shipping.form.description')}</label>
<textarea
id="shipping-description"
rows={3}
dir={isFa ? 'rtl' : 'ltr'}
placeholder={t('store.shipping.form.descriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('store.shipping.form.methods')}</span>
<p className={styles.hint}>{t('store.shipping.form.methodsHint')}</p>
<div className={styles.chipGrid} role="group" aria-label={t('store.shipping.form.methods')}>
{SHIPPING_METHOD_IDS.map((method) => {
const checked = methods.includes(method)
return (
<button
key={method}
type="button"
className={`${styles.chip} ${checked ? styles.chipSelected : ''}`}
onClick={() => toggleMethod(method)}
disabled={isSubmitting}
aria-pressed={checked}
>
<span className={styles.chipFa}>
{t(`store.shipping.method.${method}` as `store.shipping.method.${typeof method}`)}
</span>
<span className={styles.chipEn} dir="ltr" lang="en">
{METHOD_EN_LABELS[method]}
</span>
</button>
)
})}
</div>
</div>
<div className={styles.field}>
<label htmlFor="shipping-default-amount">
{t('store.shipping.form.defaultAmount')}
</label>
<p className={styles.hint}>{t('store.shipping.form.defaultAmountHint')}</p>
<div className={styles.amountRow}>
<input
id="shipping-default-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
placeholder={t('store.shipping.form.defaultAmountPlaceholder')}
value={defaultAmount}
onChange={(e) => setDefaultAmount(formatIrtInput(e.target.value))}
disabled={isSubmitting || pasKraye}
required={!pasKraye}
/>
<span className={styles.amountUnit}>IRT</span>
<label className={styles.pasKrayeCheck} htmlFor="shipping-pas-kraye">
<input
id="shipping-pas-kraye"
type="checkbox"
checked={pasKraye}
onChange={(e) => {
const checked = e.target.checked
setPasKraye(checked)
if (checked) setDefaultAmount('')
}}
disabled={isSubmitting}
/>
<span>{t('store.shipping.form.pasKraye')}</span>
</label>
</div>
{pasKraye ? (
<p className={styles.hint}>{t('store.shipping.form.pasKrayeHint')}</p>
) : null}
</div>
<label className={styles.checkRow} htmlFor="shipping-is-default">
<input
id="shipping-is-default"
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
disabled={isSubmitting}
/>
<span className={styles.checkText}>
<span className={styles.checkTitle}>
{t('store.shipping.form.isDefault')}
</span>
<span className={styles.checkHint}>
{t('store.shipping.form.isDefaultHint')}
</span>
</span>
</label>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('store.shipping.form.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('store.shipping.form.saving')
: editingGroup
? t('store.shipping.form.update')
: t('store.shipping.form.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,264 @@
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: transparent;
transition: background 0.2s, opacity 0.2s, border-color 0.2s;
}
.row:hover {
background: rgba(var(--primary-rgb) / 0.04);
border-color: rgba(var(--primary-rgb) / 0.28);
}
.rowDisabled {
opacity: 0.55;
pointer-events: none;
}
.titleBlock {
flex: 1 1 320px;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 2px;
}
.names {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nameFa {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nameSep {
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
}
.nameEn {
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
}
.defaultBadge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
border: 1px solid rgba(var(--primary-rgb) / 0.2);
}
.description {
margin: 0;
font-size: 12px;
line-height: 1.35;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metrics {
flex: 0 1 auto;
max-width: 48%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-inline-start: auto;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
min-height: 32px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.16);
}
.chipLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.chipValue {
display: inline-flex;
align-items: baseline;
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
}
.chipNumber {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
line-height: 1.2;
}
.methodChip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border: 1px solid rgba(var(--primary-rgb) / 0.18);
white-space: nowrap;
}
.controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-inline-start: 12px;
padding-inline-start: 14px;
border-inline-start: 1px solid rgba(148, 163, 184, 0.28);
}
.controlBtn {
position: relative;
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.controlBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controlBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exceptionBtn {
color: var(--primary);
}
.exceptionBadge {
position: absolute;
top: 2px;
inset-inline-end: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1;
color: white;
background: var(--primary);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.categoryBadge {
position: absolute;
top: 2px;
inset-inline-end: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1;
color: #0f172a;
background: #ffffff;
border: 1px solid rgba(148, 163, 184, 0.4);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
:global(html:not([data-theme='dark'])) .categoryBadge {
color: #0f172a;
background: #f8fafc;
border-color: rgba(148, 163, 184, 0.5);
}
.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
@media (max-width: 900px) {
.row {
flex-wrap: wrap;
}
.titleBlock {
flex: 1 1 100%;
min-width: 0;
}
.metrics {
flex: 1 1 auto;
max-width: none;
order: 3;
margin-inline-start: 0;
justify-content: flex-start;
}
.controls {
margin-inline-start: auto;
padding-inline-start: 0;
border-inline-start: none;
}
}
@@ -0,0 +1,131 @@
import { Layers, MapPin, Pencil, Trash2 } from 'lucide-react'
import type { ShippingCostGroup, ShippingMethodId } from '../types/shippingCostGroup'
import { getGroupProvinceExceptions } from '../services/shippingCostGroupService'
import { useT } from '../i18n/useT'
import { formatIrtPrice } from '../utils/irtPrice'
import { Tooltip } from './Tooltip'
import styles from './ShippingCostGroupRow.module.css'
interface ShippingCostGroupRowProps {
group: ShippingCostGroup
disabled?: boolean
onEdit: (id: string) => void
onRemove: (id: string) => void
onExceptions: (id: string) => void
onCategories: (id: string) => void
}
function methodLabelKey(method: ShippingMethodId) {
return `store.shipping.method.${method}` as const
}
export function ShippingCostGroupRow({
group,
disabled = false,
onEdit,
onRemove,
onExceptions,
onCategories,
}: ShippingCostGroupRowProps) {
const t = useT()
const nameFa = group.nameFa?.trim() || ''
const nameEn = group.nameEn?.trim() || ''
const exceptionCount = getGroupProvinceExceptions(group).length
const categoryCount = group.categoryIds?.length ?? group.categories?.length ?? 0
return (
<div className={`${styles.row} ${disabled ? styles.rowDisabled : ''}`}>
<div className={styles.titleBlock}>
<div className={styles.names}>
{nameFa ? <span className={styles.nameFa}>{nameFa}</span> : null}
{nameFa && nameEn ? <span className={styles.nameSep}>·</span> : null}
{nameEn ? (
<span className={styles.nameEn} dir="ltr" lang="en">
{nameEn}
</span>
) : null}
{group.isDefault ? (
<span className={styles.defaultBadge}>
{t('store.shipping.row.defaultBadge')}
</span>
) : null}
</div>
{group.description ? (
<p className={styles.description}>{group.description}</p>
) : null}
</div>
<div className={styles.metrics}>
<div className={styles.chip}>
<span className={styles.chipLabel}>{t('store.shipping.row.defaultAmount')}</span>
<span className={styles.chipValue}>
<span className={styles.chipNumber}>
{group.pasKraye
? t('store.shipping.form.pasKraye')
: formatIrtPrice(group.defaultAmount)}
</span>
</span>
</div>
{group.methods.map((method) => (
<div key={method} className={styles.methodChip}>
{t(methodLabelKey(method))}
</div>
))}
</div>
<div className={styles.controls}>
<Tooltip label={t('store.shipping.tooltip.categories')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => onCategories(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.categories')}
>
<Layers size={16} />
{categoryCount > 0 ? (
<span className={styles.categoryBadge}>{categoryCount}</span>
) : null}
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.exceptions')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.exceptionBtn}`}
onClick={() => onExceptions(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.exceptions')}
>
<MapPin size={16} />
{exceptionCount > 0 ? (
<span className={styles.exceptionBadge}>{exceptionCount}</span>
) : null}
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => onEdit(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(group.id)}
disabled={disabled}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -0,0 +1,210 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
listProductCategories,
mapProductCategoryToUi,
} from '../services/productCategoryService'
import { CategoryTreeMultiSelect } from './CategoryTreeMultiSelect'
import type { CategoryTreeItem } from './CategoryTreeSelect'
import modalStyles from './VariationsModal.module.css'
import styles from './PickStoreSpecialItemsModal.module.css'
interface ShippingGroupCategoriesModalProps {
open: boolean
groupName: string
selectedCategoryIds: string[]
onClose: () => void
onSave: (categoryIds: string[]) => Promise<void> | void
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function ShippingGroupCategoriesModal({
open,
groupName,
selectedCategoryIds,
onClose,
onSave,
isSubmitting = false,
}: ShippingGroupCategoriesModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [categories, setCategories] = useState<CategoryTreeItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setSelectedIds(selectedCategoryIds)
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when open toggles
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [open])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting && !saving) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, isSubmitting, saving])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listProductCategories(signal)
setCategories(
items.map(mapProductCategoryToUi).map((category) => ({
id: category.id,
parentId: category.parentId,
nameEn: category.nameEn,
nameFa: category.nameFa,
})),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.groupCategories.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function toggleSelection(categoryId: string) {
setSelectedIds((prev) =>
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
)
}
async function handleSave() {
setSaving(true)
setError('')
try {
await onSave(selectedIds)
onClose()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('store.shipping.groupCategories.errorSave'))
}
} finally {
setSaving(false)
}
}
const busy = isSubmitting || saving
const treeReady = useMemo(() => categories.length > 0, [categories.length])
if (!mounted) return null
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !busy && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="shipping-group-categories-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className={modalStyles.header}>
<div>
<h2 id="shipping-group-categories-title" className={modalStyles.title}>
{t('store.shipping.groupCategories.title')}
</h2>
<p className={modalStyles.subtitle}>
{t('store.shipping.groupCategories.subtitle', { name: groupName })}
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={busy}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
{error ? <p className={styles.error}>{error}</p> : null}
{isLoading && !treeReady ? (
<p className={styles.empty}>{t('store.shipping.groupCategories.loading')}</p>
) : (
<CategoryTreeMultiSelect
categories={categories}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder={t('categories.select.search')}
disabled={busy}
/>
)}
<div className={styles.footer}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={busy}
>
{t('store.shipping.form.cancel')}
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={() => void handleSave()}
disabled={busy}
>
{busy
? t('store.shipping.form.saving')
: t('store.shipping.groupCategories.save')}
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,200 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.modal {
width: 100%;
max-width: 560px;
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
overflow: hidden;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}
.headerText {
flex: 1;
min-width: 0;
}
.title {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.subtitle {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
}
.headerActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.addBtn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
}
.addBtn:disabled,
.closeBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.closeBtn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
}
.closeBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.body {
padding: 16px 20px 20px;
overflow: auto;
}
.empty {
margin: 0;
padding: 28px 8px;
text-align: center;
font-size: 13px;
color: var(--text-muted);
}
.list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius-sm);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.row:last-child {
border-bottom: none;
}
.province {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.amount {
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
unicode-bidi: isolate;
}
.controls {
display: flex;
align-items: center;
gap: 2px;
}
.controlBtn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
}
.controlBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controlBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@@ -0,0 +1,265 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Pencil, Plus, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { CityOption } from '../services/citiesService'
import type { ShippingCostGroupProvinceException } from '../types/shippingCostGroup'
import { useT } from '../i18n/useT'
import { formatIrtPrice } from '../utils/irtPrice'
import { StaticShippingExceptionModal } from './StaticShippingExceptionModal'
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
import { Tooltip } from './Tooltip'
import styles from './ShippingGroupExceptionsModal.module.css'
interface ShippingGroupExceptionsModalProps {
open: boolean
onClose: () => void
groupName: string
provinces: CityOption[]
exceptions: ShippingCostGroupProvinceException[]
isSubmitting?: boolean
onSave: (items: Array<{ provinceId: string; amount: number }>) => Promise<void> | void
}
const ANIMATION_MS = 220
export function ShippingGroupExceptionsModal({
open,
onClose,
groupName,
provinces,
exceptions,
isSubmitting = false,
onSave,
}: ShippingGroupExceptionsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [items, setItems] = useState<ShippingCostGroupProvinceException[]>([])
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<ShippingCostGroupProvinceException | null>(null)
const [deleteTarget, setDeleteTarget] =
useState<ShippingCostGroupProvinceException | null>(null)
const [saving, setSaving] = useState(false)
const provinceNameById = useMemo(() => {
const map = new Map<string, string>()
for (const province of provinces) {
map.set(
province.id,
isFa
? province.nameFa || province.nameEn
: province.nameEn || province.nameFa,
)
}
return map
}, [provinces, isFa])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setAddOpen(false)
setEditing(null)
setDeleteTarget(null)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (open) setItems(exceptions)
}, [open, exceptions])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !saving && !isSubmitting && !addOpen && !deleteTarget) {
onClose()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, saving, isSubmitting, addOpen, deleteTarget])
if (!mounted) return null
async function persist(next: Array<{ provinceId: string; amount: number }>) {
setSaving(true)
try {
await onSave(next)
} finally {
setSaving(false)
}
}
async function handleUpsert(data: { provinceId: string; amount: number }) {
const next = editing
? items.map((item) =>
item.provinceId === editing.provinceId
? { ...item, provinceId: data.provinceId, amount: data.amount }
: item,
)
: [
...items.filter((item) => item.provinceId !== data.provinceId),
{
id: `tmp-${data.provinceId}`,
provinceId: data.provinceId,
amount: data.amount,
},
]
await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
setAddOpen(false)
setEditing(null)
}
async function handleDelete() {
if (!deleteTarget) return
const next = items.filter((item) => item.provinceId !== deleteTarget.provinceId)
await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
setDeleteTarget(null)
}
const busy = saving || isSubmitting
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !busy && !addOpen && !deleteTarget && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="group-exceptions-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div className={styles.headerText}>
<h3 id="group-exceptions-modal-title" className={styles.title}>
{t('store.shipping.groupException.title')}
</h3>
<p className={styles.subtitle}>
{t('store.shipping.groupException.subtitle', { name: groupName })}
</p>
</div>
<div className={styles.headerActions}>
<Tooltip label={t('store.shipping.groupException.add')}>
<button
type="button"
className={styles.addBtn}
onClick={() => {
setEditing(null)
setAddOpen(true)
}}
disabled={busy}
aria-label={t('store.shipping.groupException.add')}
>
<Plus size={18} />
</button>
</Tooltip>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={busy}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
</div>
<div className={styles.body}>
{items.length === 0 ? (
<p className={styles.empty}>{t('store.shipping.groupException.empty')}</p>
) : (
<ul className={styles.list}>
{items.map((item) => (
<li key={item.provinceId} className={styles.row}>
<span className={styles.province}>
{provinceNameById.get(item.provinceId) ?? item.provinceId}
</span>
<span className={styles.amount}>{formatIrtPrice(item.amount)}</span>
<div className={styles.controls}>
<Tooltip label={t('store.shipping.tooltip.edit')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => {
setEditing(item)
setAddOpen(true)
}}
disabled={busy}
aria-label={t('store.shipping.tooltip.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('store.shipping.tooltip.remove')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => setDeleteTarget(item)}
disabled={busy}
aria-label={t('store.shipping.tooltip.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<StaticShippingExceptionModal
open={addOpen}
provinces={provinces}
usedProvinceIds={items.map((item) => item.provinceId)}
editing={
editing
? { provinceId: editing.provinceId, amount: editing.amount }
: null
}
isSubmitting={busy}
onClose={() => {
if (!busy) {
setAddOpen(false)
setEditing(null)
}
}}
onSubmit={(data) => void handleUpsert(data)}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('store.shipping.groupException.deleteTitle')}
message={
deleteTarget
? t('store.shipping.groupException.deleteMessage', {
name:
provinceNameById.get(deleteTarget.provinceId) ??
deleteTarget.provinceId,
})
: ''
}
onConfirm={() => void handleDelete()}
onCancel={() => !busy && setDeleteTarget(null)}
/>
</div>,
document.body,
)
}
@@ -25,22 +25,24 @@
.brandLogo { .brandLogo {
display: block; display: block;
width: 40px; /* Same height as brandName + domain stack; width follows image aspect ratio */
height: 40px; height: calc(13px * 1.2 + 2px + 11px * 1.2);
width: auto;
max-width: 7.5rem;
object-fit: contain; object-fit: contain;
flex-shrink: 0; flex-shrink: 0;
background: transparent; background: transparent;
} }
.brandLogoUploaded { .brandLogoUploaded {
border-radius: 8px; border-radius: 0;
object-fit: cover; object-fit: contain;
background: var(--card-media-bg); background: transparent;
} }
.brandEditBtn { .brandEditBtn {
width: 40px; width: calc(13px * 1.2 + 2px + 11px * 1.2);
height: 40px; height: calc(13px * 1.2 + 2px + 11px * 1.2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -69,6 +71,7 @@
font-weight: 500; font-weight: 500;
color: var(--text-muted); color: var(--text-muted);
margin-top: 2px; margin-top: 2px;
line-height: 1.2;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
+46 -11
View File
@@ -26,7 +26,9 @@ import type { BusinessMessageKey } from '../i18n/messages'
import { import {
BUSINESS_PROFILE_UPDATED_EVENT, BUSINESS_PROFILE_UPDATED_EVENT,
getActiveBusinessDomain, getActiveBusinessDomain,
getActiveBusinessId,
} from '../lib/businessContext' } from '../lib/businessContext'
import { canAccessRollCall } from '../utils/rollCallAccess'
import { isAbortError } from '../lib/api' import { isAbortError } from '../lib/api'
import { getBusinessProfile } from '../services/businessProfileService' import { getBusinessProfile } from '../services/businessProfileService'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
@@ -68,16 +70,19 @@ function isGroupActive(group: NavGroup, pathname: string) {
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) { if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
return true return true
} }
if (group.id === 'content') { if (group.id === 'content') {
if ( if (
pathname.startsWith('/blog') || pathname.startsWith('/blog') ||
pathname.startsWith('/videos') || pathname.startsWith('/videos') ||
pathname.startsWith('/instructions') || pathname.startsWith('/instructions') ||
pathname.startsWith('/workshops') pathname.startsWith('/workshops')
) { ) {
return true return true
}
} }
}
if (group.id === 'customers' && pathname.startsWith('/roll-call')) {
return true
}
return group.children.some( return group.children.some(
(child) => pathname === child.to || pathname.startsWith(`${child.to}/`), (child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
) )
@@ -154,10 +159,40 @@ export function Sidebar({ open = false }: SidebarProps) {
{ labelKey: 'nav.store.orders', to: '/store/orders' }, { labelKey: 'nav.store.orders', to: '/store/orders' },
{ labelKey: 'nav.store.shipping', to: '/store/shipping' }, { labelKey: 'nav.store.shipping', to: '/store/shipping' },
{ labelKey: 'nav.store.cards', to: '/store/cards' }, { labelKey: 'nav.store.cards', to: '/store/cards' },
...(hasBusinessModule(enabledModules, 'store_installments')
? [
{
labelKey: 'nav.store.installments' as BusinessMessageKey,
to: '/store/installments',
},
]
: []),
{ labelKey: 'nav.store.settings', to: '/store/settings' }, { labelKey: 'nav.store.settings', to: '/store/settings' },
], ],
}, },
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' }, ...(canAccessRollCall(user, getActiveBusinessId(), enabledModules)
? [
{
type: 'group' as const,
id: 'customers',
icon: Users,
labelKey: 'nav.customers' as BusinessMessageKey,
basePath: '/customers',
children: [
{ labelKey: 'nav.customers.list' as BusinessMessageKey, to: '/customers' },
{ labelKey: 'nav.rollCall' as BusinessMessageKey, to: '/roll-call' },
],
},
]
: [
{
type: 'link' as const,
id: 'customers',
icon: Users,
labelKey: 'nav.customers' as BusinessMessageKey,
to: '/customers',
},
]),
{ {
type: 'group', type: 'group',
id: 'finance', id: 'finance',
@@ -241,7 +276,7 @@ export function Sidebar({ open = false }: SidebarProps) {
} }
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId) return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
}) })
}, [enabledModules]) }, [enabledModules, user])
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -0,0 +1,181 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 260;
padding: 20px;
}
.modal {
width: 100%;
max-width: 440px;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.error {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field select {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.field input:focus,
.field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.amountRow {
display: flex;
align-items: center;
gap: 10px;
}
.amountRow input {
flex: 1;
min-width: 0;
font-family: var(--font-en), var(--font-ui);
}
.amountUnit {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-family: var(--font-en), var(--font-ui);
direction: ltr;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
direction: rtl;
}
.cancelBtn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.15);
}
.submitBtn {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
}
.submitBtn:disabled,
.cancelBtn:disabled,
.closeBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@@ -0,0 +1,196 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { CityOption } from '../services/citiesService'
import { useT } from '../i18n/useT'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './StaticShippingExceptionModal.module.css'
interface StaticShippingExceptionModalProps {
open: boolean
onClose: () => void
onSubmit: (data: { provinceId: string; amount: number }) => void
provinces: CityOption[]
usedProvinceIds: string[]
editing?: { provinceId: string; amount: number } | null
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function StaticShippingExceptionModal({
open,
onClose,
onSubmit,
provinces,
usedProvinceIds,
editing = null,
isSubmitting = false,
}: StaticShippingExceptionModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [provinceId, setProvinceId] = useState('')
const [amount, setAmount] = useState('')
const [formError, setFormError] = useState('')
const availableProvinces = useMemo(() => {
return provinces.filter(
(province) =>
province.id === editing?.provinceId ||
!usedProvinceIds.includes(province.id),
)
}, [provinces, usedProvinceIds, editing?.provinceId])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setProvinceId(editing?.provinceId ?? '')
setAmount(
editing
? formatIrtInput(String(Math.round(editing.amount)))
: '',
)
setFormError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, editing, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, isSubmitting])
if (!mounted) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setFormError('')
const parsedAmount = parseIrtInput(amount)
if (!provinceId) {
setFormError(t('store.shipping.static.exception.errorProvince'))
return
}
if (parsedAmount === null || parsedAmount < 0) {
setFormError(t('store.shipping.form.errorAmount'))
return
}
onSubmit({ provinceId, amount: parsedAmount })
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={() => !isSubmitting && onClose()}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="static-exception-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="static-exception-modal-title" className={styles.title}>
{editing
? t('store.shipping.static.exception.editTitle')
: t('store.shipping.static.exception.addTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="static-exception-province">
{t('store.shipping.static.exception.province')}
</label>
<select
id="static-exception-province"
value={provinceId}
onChange={(e) => setProvinceId(e.target.value)}
disabled={isSubmitting || Boolean(editing)}
required
>
<option value="">{t('store.shipping.static.exception.provincePlaceholder')}</option>
{availableProvinces.map((province) => (
<option key={province.id} value={province.id}>
{isFa ? province.nameFa || province.nameEn : province.nameEn || province.nameFa}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="static-exception-amount">
{t('store.shipping.static.exception.amount')}
</label>
<div className={styles.amountRow}>
<input
id="static-exception-amount"
type="text"
inputMode="numeric"
dir="ltr"
lang="en"
placeholder={t('store.shipping.static.amountPlaceholder')}
value={amount}
onChange={(e) => setAmount(formatIrtInput(e.target.value))}
disabled={isSubmitting}
required
/>
<span className={styles.amountUnit}>IRT</span>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('store.shipping.form.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('store.shipping.form.saving')
: editing
? t('store.shipping.form.update')
: t('store.shipping.form.save')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -7,6 +7,7 @@
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
@@ -45,8 +46,8 @@
.image { .image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
padding: 10px; display: block;
} }
.imagePlaceholder { .imagePlaceholder {
@@ -64,7 +65,8 @@
} }
.festivalBadge, .festivalBadge,
.stockBadge { .stockBadge,
.usedBadge {
position: absolute; position: absolute;
top: 8px; top: 8px;
padding: 4px 10px; padding: 4px 10px;
@@ -86,6 +88,14 @@
color: #7c3aed; color: #7c3aed;
} }
.usedBadge {
inset-inline-start: 8px;
top: auto;
bottom: 8px;
color: #b45309;
background: rgba(255, 251, 235, 0.95);
}
.stockBadge { .stockBadge {
inset-inline-end: 8px; inset-inline-end: 8px;
color: var(--primary); color: var(--primary);
@@ -191,7 +201,8 @@
justify-content: space-around; justify-content: space-around;
} }
.controlsLeft button { .controlsLeft button,
.controlsLeft a {
width: 32px; width: 32px;
height: 32px; height: 32px;
display: flex; display: flex;
@@ -199,10 +210,12 @@
justify-content: center; justify-content: center;
border-radius: 8px; border-radius: 8px;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none;
transition: background 0.2s, color 0.2s; transition: background 0.2s, color 0.2s;
} }
.controlsLeft button:hover { .controlsLeft button:hover,
.controlsLeft a:hover {
background: rgba(var(--primary-rgb) / 0.1); background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary); color: var(--primary);
} }
@@ -6,6 +6,8 @@ import {
formatVariantCount, formatVariantCount,
type StoreProductListing, type StoreProductListing,
} from '../utils/storeProductGroups' } from '../utils/storeProductGroups'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { StoreItemPrice } from './StoreItemPrice' import { StoreItemPrice } from './StoreItemPrice'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './StoreItemCard.module.css' import styles from './StoreItemCard.module.css'
@@ -83,6 +85,14 @@ export function StoreItemCard({
const imageSrc = listing.productImage?.trim() || '' const imageSrc = listing.productImage?.trim() || ''
const [imageFailed, setImageFailed] = useState(false) const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed const showImage = Boolean(imageSrc) && !imageFailed
const websiteUrl =
listing.variantCount > 0
? buildWebsiteContentUrl('product', {
id: listing.productId,
nameFa: listing.productNameFa,
nameEn: listing.productTitle,
})
: ''
useEffect(() => { useEffect(() => {
setImageFailed(false) setImageFailed(false)
@@ -115,6 +125,9 @@ export function StoreItemCard({
{listing.showFestival && ( {listing.showFestival && (
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span> <span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
)} )}
{listing.isUsed && (
<span className={styles.usedBadge}>{t('storeItems.card.used')}</span>
)}
{listing.productTotalStock > 0 && ( {listing.productTotalStock > 0 && (
<span className={styles.stockBadge}> <span className={styles.stockBadge}>
{t('storeItems.card.inStock', { count: listing.productTotalStock })} {t('storeItems.card.inStock', { count: listing.productTotalStock })}
@@ -164,6 +177,7 @@ export function StoreItemCard({
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
</Tooltip> </Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
<Tooltip label={t('storeItems.card.discount')}> <Tooltip label={t('storeItems.card.discount')}>
<button <button
type="button" type="button"
@@ -1,28 +1,30 @@
.priceChip { .price,
display: inline-block; .contactPrice,
padding: 3px 10px; .discountWrap {
font-size: 11px; display: block;
font-weight: 600; width: 100%;
color: var(--primary); text-align: left;
background: rgba(var(--primary-rgb) / 0.12); direction: ltr;
border-radius: 50px; }
.price {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
} }
.contactPrice { .contactPrice {
display: inline-block; font-size: 12px;
padding: 3px 10px;
font-size: 11px;
font-weight: 600; font-weight: 600;
font-family: var(--font-ui); font-family: var(--font-ui);
color: var(--text-muted); color: var(--text-muted);
background: rgba(148, 163, 184, 0.14);
border-radius: 50px;
} }
.discountWrap { .discountWrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
align-items: flex-start;
} }
.originalPrice { .originalPrice {
@@ -36,12 +38,13 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-start;
} }
.salePrice { .salePrice {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
color: #dc2626; color: var(--text-primary);
} }
.percent { .percent {
@@ -31,5 +31,5 @@ export function StoreItemPrice({ price, discountedPrice = null }: StoreItemPrice
) )
} }
return <span className={styles.priceChip}>{formatIrtPrice(price)}</span> return <span className={styles.price}>{formatIrtPrice(price)}</span>
} }
@@ -0,0 +1,211 @@
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
transition: background 0.2s;
}
.row:last-child {
border-bottom: none;
}
.row:hover {
background: rgba(var(--primary-rgb) / 0.04);
}
.logo {
height: 44px;
width: auto;
max-width: 88px;
flex-shrink: 0;
object-fit: contain;
object-position: center;
border-radius: 8px;
background:
linear-gradient(45deg, rgba(148, 163, 184, 0.12) 25%, transparent 25%),
linear-gradient(-45deg, rgba(148, 163, 184, 0.12) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(148, 163, 184, 0.12) 75%),
linear-gradient(-45deg, transparent 75%, rgba(148, 163, 184, 0.12) 75%);
background-size: 12px 12px;
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
border: 1px solid rgba(148, 163, 184, 0.2);
}
.titleBlock {
flex: 0 1 180px;
min-width: 120px;
display: flex;
flex-direction: column;
gap: 2px;
}
.names {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.namePrimary {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.nameSecondary {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.nameEn {
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
}
.description {
margin: 0;
font-size: 12px;
line-height: 1.35;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metrics {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
min-height: 32px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.16);
}
.chipLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.chipValue {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 3px 5px;
min-width: 0;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
}
.chipNumber {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
line-height: 1.2;
}
.chipSep {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.chipUnit {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
}
.chipHint {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-inline-start: auto;
}
.controlBtn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.controlBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.useBtn {
color: var(--primary);
}
.useBtn:hover {
background: rgba(var(--primary-rgb) / 0.14);
color: var(--primary);
}
.danger:hover {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
}
@media (max-width: 900px) {
.row {
flex-wrap: wrap;
}
.titleBlock {
flex: 1 1 160px;
}
.metrics {
flex: 1 1 100%;
order: 3;
}
.controls {
margin-inline-start: 0;
}
}
@@ -0,0 +1,150 @@
import { Calculator, Pencil, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { StoreLoanMethod } from '../types/storeLoanMethod'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import styles from './StoreLoanMethodRow.module.css'
interface StoreLoanMethodRowProps {
method: StoreLoanMethod
onUse: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
}
function formatMoney(value: number) {
return Math.round(value).toLocaleString('en-US')
}
function formatInterest(value: number) {
return Number.isInteger(value) ? String(value) : value.toLocaleString('en-US')
}
export function StoreLoanMethodRow({
method,
onUse,
onEdit,
onRemove,
}: StoreLoanMethodRowProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const primaryName = isFa ? method.nameFa || method.nameEn : method.nameEn || method.nameFa
const secondaryName = isFa
? method.nameFa
? method.nameEn
: ''
: method.nameEn
? method.nameFa
: ''
return (
<div className={styles.row}>
{method.imageUrl ? (
<img src={method.imageUrl} alt="" className={styles.logo} />
) : null}
<div className={styles.titleBlock}>
<div className={styles.names}>
<span className={`${styles.namePrimary} ${!isFa ? styles.nameEn : ''}`}>
{primaryName}
</span>
{secondaryName ? (
<span className={`${styles.nameSecondary} ${isFa ? styles.nameEn : ''}`}>
{secondaryName}
</span>
) : null}
</div>
{method.description ? (
<p className={styles.description}>{method.description}</p>
) : null}
</div>
<div className={styles.metrics}>
<div className={styles.chip}>
<span className={styles.chipLabel}>
{t('store.installments.row.minShopping')}
</span>
<span className={styles.chipValue}>
<span className={styles.chipNumber}>
{formatMoney(method.minShoppingAmount)}
</span>
</span>
</div>
<div className={styles.chip}>
<span className={styles.chipLabel}>
{t('store.installments.row.creditAmount')}
</span>
<span className={styles.chipValue}>
<span className={styles.chipNumber}>{formatMoney(method.minAmount)}</span>
<span className={styles.chipSep}>–</span>
<span className={styles.chipNumber}>{formatMoney(method.maxAmount)}</span>
</span>
</div>
<div className={styles.chip}>
<span className={styles.chipLabel}>
{t('store.installments.row.interest')}
</span>
<span className={styles.chipValue}>
<span className={styles.chipNumber}>{formatInterest(method.interest)}</span>
<span className={styles.chipUnit}>%</span>
</span>
</div>
<div className={styles.chip}>
<span className={styles.chipLabel}>
{t('store.installments.row.months')}
</span>
<span className={styles.chipValue}>
<span className={styles.chipNumber}>{method.returnMonthsMin}</span>
<span className={styles.chipSep}>–</span>
<span className={styles.chipNumber}>{method.returnMonthsMax}</span>
<span className={styles.chipUnit}>
{t('store.installments.row.monthsUnit')}
</span>
</span>
<span className={styles.chipHint}>
{t('store.installments.row.monthsStep', {
step: method.returnMonthsStep,
})}
</span>
</div>
</div>
<div className={styles.controls}>
<Tooltip label={t('store.installments.tooltip.use')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.useBtn}`}
onClick={() => onUse(method.id)}
aria-label={t('store.installments.tooltip.use')}
>
<Calculator size={17} />
</button>
</Tooltip>
<Tooltip label={t('store.installments.tooltip.edit')}>
<button
type="button"
className={styles.controlBtn}
onClick={() => onEdit(method.id)}
aria-label={t('store.installments.tooltip.edit')}
>
<Pencil size={17} />
</button>
</Tooltip>
<Tooltip label={t('store.installments.tooltip.remove')}>
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(method.id)}
aria-label={t('store.installments.tooltip.remove')}
>
<Trash2 size={17} />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -0,0 +1,402 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
padding: 12px;
}
.overlayIn {
animation: overlayIn 0.22s ease forwards;
}
.overlayOut {
animation: overlayOut 0.22s ease forwards;
}
.modal {
width: 100%;
max-width: 720px;
max-height: calc(100vh - 24px);
display: flex;
flex-direction: column;
background: var(--elevated-surface, var(--surface));
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16);
overflow: hidden;
}
.modalIn {
animation: modalIn 0.22s ease forwards;
}
.modalOut {
animation: modalOut 0.22s ease forwards;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
flex-shrink: 0;
}
.headerText {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.headerBadge {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
}
.title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.subtitle {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.closeBtn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.closeBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.body {
padding: 12px 16px 16px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(148, 163, 184, 0.06);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.sectionTitle {
margin: 0;
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.planGrid {
display: grid;
grid-template-columns: 0.9fr 1.5fr;
gap: 10px;
align-items: start;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.field label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
}
.field input:not([type='range']),
.field select {
width: 100%;
height: 34px;
padding: 0 10px;
font-size: 13px;
color: var(--text);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: var(--radius-sm);
outline: none;
}
.field select {
cursor: pointer;
}
.field input:not([type='range']):focus,
.field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.field input:disabled,
.field select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sliderHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sliderValue {
font-size: 12px;
font-weight: 700;
color: var(--primary);
font-variant-numeric: tabular-nums;
direction: ltr;
unicode-bidi: isolate;
}
.slider {
width: 100%;
height: 28px;
margin: 0;
padding: 0;
accent-color: var(--primary);
cursor: pointer;
background: transparent;
border: none;
direction: ltr;
}
.slider:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.sliderMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 10px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
direction: ltr;
}
.sliderMeta > span:nth-child(2) {
direction: inherit;
text-align: center;
flex: 1;
}
.hint {
margin: 0;
font-size: 10px;
line-height: 1.35;
color: var(--text-muted);
}
.warning {
padding: 8px 10px;
border-radius: 8px;
font-size: 12px;
color: #b45309;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.28);
}
.emptyResult {
margin: 0;
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.summaryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.summaryCard {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 10px;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.16);
}
.summaryCardWide {
grid-column: 1 / -1;
}
.summaryLabel {
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
}
.summaryValue {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
letter-spacing: -0.02em;
}
.schedule {
display: flex;
flex-direction: column;
gap: 6px;
}
.scheduleHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
}
.scheduleList {
display: flex;
flex-direction: column;
max-height: 180px;
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: var(--surface);
}
.scheduleRow {
display: grid;
grid-template-columns: 72px 1fr auto;
gap: 8px;
align-items: center;
padding: 7px 10px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.scheduleRow:last-child {
border-bottom: none;
}
.scheduleIndex {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.scheduleDate {
font-size: 13px;
color: var(--text-secondary);
}
.scheduleAmount {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
direction: ltr;
unicode-bidi: isolate;
font-family: var(--font-en), var(--font-ui);
}
@keyframes overlayIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modalOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
@media (max-width: 700px) {
.planGrid,
.summaryGrid {
grid-template-columns: 1fr;
}
.scheduleRow {
grid-template-columns: 1fr auto;
}
.scheduleIndex {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,367 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Calculator, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { StoreLoanMethod } from '../types/storeLoanMethod'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import {
calculateStoreLoanPlan,
clampCreditAmount,
listReturnMonthOptions,
} from '../utils/storeLoanCalculator'
import styles from './StoreLoanUseModal.module.css'
interface StoreLoanUseModalProps {
open: boolean
method: StoreLoanMethod | null
onClose: () => void
}
const ANIMATION_MS = 220
function formatMoney(value: number) {
return Math.round(value).toLocaleString('en-US')
}
function formatDate(value: Date, locale: 'en' | 'fa') {
return value.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
})
}
export function StoreLoanUseModal({ open, method, onClose }: StoreLoanUseModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [totalValueInput, setTotalValueInput] = useState('')
const [creditInput, setCreditInput] = useState('')
const [months, setMonths] = useState(0)
const monthOptions = useMemo(() => {
if (!method) return []
return listReturnMonthOptions(
method.returnMonthsMin,
method.returnMonthsMax,
method.returnMonthsStep,
)
}, [method])
useEffect(() => {
if (open && method) {
setMounted(true)
setClosing(false)
setTotalValueInput('')
setCreditInput('')
const options = listReturnMonthOptions(
method.returnMonthsMin,
method.returnMonthsMax,
method.returnMonthsStep,
)
setMonths(options[0] ?? method.returnMonthsMin)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, method, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
const totalValue = parseIrtInput(totalValueInput)
const creditRaw = parseIrtInput(creditInput)
const plan = useMemo(() => {
if (!method || totalValue === null || creditRaw === null) return null
const creditAmount = clampCreditAmount(
creditRaw,
totalValue,
method.minAmount,
method.maxAmount,
)
return calculateStoreLoanPlan({
totalValue,
creditAmount,
interestPercent: method.interest,
months,
})
}, [method, totalValue, creditRaw, months])
const validationMessage = useMemo(() => {
if (!method) return ''
if (totalValue === null) return ''
if (totalValue < method.minShoppingAmount) {
return t('store.installments.use.errorMinShopping', {
amount: formatMoney(method.minShoppingAmount),
})
}
if (creditRaw === null) return ''
if (creditRaw > totalValue) {
return t('store.installments.use.errorCreditOverTotal')
}
if (creditRaw < method.minAmount || creditRaw > method.maxAmount) {
return t('store.installments.use.errorCreditRange', {
min: formatMoney(method.minAmount),
max: formatMoney(method.maxAmount),
})
}
return ''
}, [method, totalValue, creditRaw, t])
if (!mounted || !method) return null
const primaryName = isFa ? method.nameFa || method.nameEn : method.nameEn || method.nameFa
function handleTotalChange(raw: string) {
const formatted = formatIrtInput(raw)
setTotalValueInput(formatted)
const nextTotal = parseIrtInput(formatted)
if (nextTotal === null || !method) return
const suggested = clampCreditAmount(
Math.min(nextTotal, method.maxAmount),
nextTotal,
method.minAmount,
method.maxAmount,
)
if (!creditInput || creditRaw === null) {
setCreditInput(formatIrtInput(String(suggested)))
} else {
const clamped = clampCreditAmount(
creditRaw,
nextTotal,
method.minAmount,
method.maxAmount,
)
setCreditInput(formatIrtInput(String(clamped)))
}
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="store-loan-use-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div className={styles.headerText}>
<div className={styles.headerBadge}>
<Calculator size={16} />
<span>{t('store.installments.use.badge')}</span>
</div>
<h3 id="store-loan-use-title" className={styles.title}>
{primaryName}
</h3>
<p className={styles.subtitle}>{t('store.installments.use.subtitle')}</p>
</div>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<div className={styles.body}>
<section className={styles.section}>
<h4 className={styles.sectionTitle}>
{t('store.installments.use.section.value')}
</h4>
<div className={styles.field}>
<label htmlFor="loan-use-total">
{t('store.installments.use.totalValue')}
</label>
<input
id="loan-use-total"
type="text"
inputMode="numeric"
value={totalValueInput}
onChange={(e) => handleTotalChange(e.target.value)}
placeholder={t('store.installments.use.totalValuePlaceholder')}
/>
<p className={styles.hint}>
{t('store.installments.use.totalValueHint', {
amount: formatMoney(method.minShoppingAmount),
})}
</p>
</div>
</section>
<section className={styles.section}>
<h4 className={styles.sectionTitle}>
{t('store.installments.use.section.plan')}
</h4>
<div className={styles.planGrid}>
<div className={styles.field}>
<label htmlFor="loan-use-credit">
{t('store.installments.use.creditAmount')}
</label>
<input
id="loan-use-credit"
type="text"
inputMode="numeric"
value={creditInput}
onChange={(e) => setCreditInput(formatIrtInput(e.target.value))}
placeholder={t('store.installments.use.creditAmountPlaceholder')}
disabled={totalValue === null}
/>
<p className={styles.hint}>
{t('store.installments.use.creditAmountHint', {
min: formatMoney(method.minAmount),
max: formatMoney(method.maxAmount),
})}
</p>
</div>
<div className={styles.field}>
<div className={styles.sliderHeader}>
<label htmlFor="loan-use-months">
{t('store.installments.use.months')}
</label>
<span className={styles.sliderValue}>
{t('store.installments.use.monthsOption', { months })}
</span>
</div>
<input
id="loan-use-months"
className={styles.slider}
type="range"
min={method.returnMonthsMin}
max={method.returnMonthsMax}
step={method.returnMonthsStep}
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
disabled={monthOptions.length === 0}
/>
<div className={styles.sliderMeta}>
<span>{method.returnMonthsMin}</span>
<span>
{t('store.installments.use.monthsHint', {
step: method.returnMonthsStep,
})}
</span>
<span>{method.returnMonthsMax}</span>
</div>
</div>
</div>
</section>
<section className={styles.section}>
<h4 className={styles.sectionTitle}>
{t('store.installments.use.section.result')}
</h4>
{validationMessage ? (
<div className={styles.warning} role="status">
{validationMessage}
</div>
) : null}
{!plan || validationMessage ? (
<p className={styles.emptyResult}>
{t('store.installments.use.resultEmpty')}
</p>
) : (
<>
<div className={styles.summaryGrid}>
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>
{t('store.installments.use.result.totalValue')}
</span>
<span className={styles.summaryValue}>
{formatMoney(plan.totalValue)}
</span>
</div>
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>
{t('store.installments.use.result.credit')}
</span>
<span className={styles.summaryValue}>
{formatMoney(plan.creditAmount)}
</span>
</div>
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>
{t('store.installments.use.result.cash')}
</span>
<span className={styles.summaryValue}>
{formatMoney(plan.cashPayment)}
</span>
</div>
<div className={styles.summaryCard}>
<span className={styles.summaryLabel}>
{t('store.installments.use.result.fees')}
</span>
<span className={styles.summaryValue}>
{formatMoney(plan.interestFee)}
</span>
</div>
<div className={`${styles.summaryCard} ${styles.summaryCardWide}`}>
<span className={styles.summaryLabel}>
{t('store.installments.use.result.totalRepay')}
</span>
<span className={styles.summaryValue}>
{formatMoney(plan.totalRepay)}
</span>
</div>
</div>
<div className={styles.schedule}>
<div className={styles.scheduleHead}>
<span>{t('store.installments.use.schedule.title')}</span>
<span>
{t('store.installments.use.schedule.count', {
count: plan.installments.length,
})}
</span>
</div>
<div className={styles.scheduleList}>
{plan.installments.map((item) => (
<div key={item.index} className={styles.scheduleRow}>
<span className={styles.scheduleIndex}>
{t('store.installments.use.schedule.item', {
index: item.index,
})}
</span>
<span className={styles.scheduleDate}>
{formatDate(item.dueDate, locale)}
</span>
<span className={styles.scheduleAmount}>
{formatMoney(item.amount)}
</span>
</div>
))}
</div>
</div>
</>
)}
</section>
</div>
</div>
</div>,
document.body,
)
}
@@ -1,37 +1,32 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react' import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import modalStyles from './CategoryModal.module.css' import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css' import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css' import styles from './CategoryAiPromptModal.module.css'
export function buildTechnicalFormAiPrompt( export function buildTechnicalFormAiPrompt(categoryName: string) {
categoryName: string, return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
locale: 'fa' | 'en' = 'en',
) {
if (locale === 'fa') {
return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
برای هر فیلد این موارد را مشخص کن: برای هر فیلد این موارد را مشخص کن:
- برچسب کوتاه انگلیسی (مثلاً Screen Size، Material، Color) - برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، ظرفیت)
- نوع ورودی: text، textarea، select یا multi_select - نوع ورودی: ترجیحاً text (یا textarea / multi_value در صورت نیاز)
- الزامی بودن یا نبودن - الزامی بودن یا نبودن
- برای select / multi_select: فهرست گزینه‌ها - multi_value یعنی لیست تکراری از مقادیر متنی آزاد (بدون گزینه)، مناسب برای ویژگی‌های چندتایی
روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.` مهم — نوع ورودی:
} - ترجیحاً از ورودی text استفاده کن تا فروشنده بتواند مقدار دلخواه را آزادانه وارد کند.
- از select و multi_select حتی‌المقدور استفاده نکن؛ فقط وقتی گزینه‌ها واقعاً ثابت و استانداردند (مثل بله/خیر).
- اگر بین text و select مردد هستی، text را انتخاب کن.
return `Suggest technical data form fields for the "${categoryName}" product category. همیشه در انتهای فرم یک فیلد «سایر ویژگی‌ها» از نوع multi_value در نظر گرفته می‌شود؛ خودت آن را نساز.
For each field include: اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را هم به‌صورت text اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتی‌متر یا کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن.
- A short English label (e.g. "Screen Size", "Material", "Color")
- The input type: text, textarea, select, or multi_select
- Whether it is required
- For select / multi_select: the list of options
Focus on specs and attributes that shoppers typically compare when buying this type of product.` روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.
برچسب‌ها را به فارسی بنویس.
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز می‌کند و فرم را بر اساس مشخصات واقعی آن می‌سازد).`
} }
interface TechnicalFormAiPromptModalProps { interface TechnicalFormAiPromptModalProps {
@@ -52,20 +47,16 @@ export function TechnicalFormAiPromptModal({
isRunning, isRunning,
}: TechnicalFormAiPromptModalProps) { }: TechnicalFormAiPromptModalProps) {
const t = useT() const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState(() => const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'),
)
const [error, setError] = useState('') const [error, setError] = useState('')
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMounted(true) setMounted(true)
setClosing(false) setClosing(false)
setPrompt(buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en')) setPrompt(buildTechnicalFormAiPrompt(categoryName))
setError('') setError('')
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
@@ -75,7 +66,7 @@ export function TechnicalFormAiPromptModal({
}, ANIMATION_MS) }, ANIMATION_MS)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [open, mounted, categoryName, isFa]) }, [open, mounted, categoryName])
useEffect(() => { useEffect(() => {
if (!mounted || closing) return if (!mounted || closing) return
@@ -118,8 +109,8 @@ export function TechnicalFormAiPromptModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="tech-form-ai-prompt-title" aria-labelledby="tech-form-ai-prompt-title"
lang={isFa ? 'fa' : 'en'} lang="fa"
dir={isFa ? 'rtl' : 'ltr'} dir="rtl"
> >
<div className={`${modalStyles.header} ${styles.header}`}> <div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}> <div className={styles.headerBlock}>
@@ -147,11 +138,11 @@ export function TechnicalFormAiPromptModal({
className={aiStyles.aiPromptTextarea} className={aiStyles.aiPromptTextarea}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={8} rows={14}
disabled={isRunning} disabled={isRunning}
required required
lang={isFa ? 'fa' : 'en'} lang="fa"
dir={isFa ? 'rtl' : 'ltr'} dir="rtl"
/> />
</div> </div>
@@ -18,7 +18,7 @@
.fieldMainRow { .fieldMainRow {
display: grid; display: grid;
grid-template-columns: 6fr 3fr 2fr 32px; grid-template-columns: 6fr 3fr 2fr auto;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
} }
@@ -61,6 +61,18 @@
align-items: center; align-items: center;
} }
.rowActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.rowActions button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.requiredCell { .requiredCell {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -176,3 +188,15 @@
white-space: nowrap; white-space: nowrap;
margin-inline-start: auto; margin-inline-start: auto;
} }
.productTechnicalActions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
width: 100%;
padding-top: 16px;
margin-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
direction: ltr;
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Plus, Sparkles, Trash2, X } from 'lucide-react' import { ChevronDown, ChevronUp, Plus, Sparkles, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm' import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -34,6 +34,7 @@ const FIELD_TYPE_KEYS: Record<TechnicalFieldType, BusinessMessageKey> = {
textarea: 'categories.technical.type.textarea', textarea: 'categories.technical.type.textarea',
select: 'categories.technical.type.select', select: 'categories.technical.type.select',
multi_select: 'categories.technical.type.multi_select', multi_select: 'categories.technical.type.multi_select',
multi_value: 'categories.technical.type.multi_value',
} }
function createEmptyField(): TechnicalFormFieldDraft { function createEmptyField(): TechnicalFormFieldDraft {
@@ -111,6 +112,17 @@ export function TechnicalFormModal({
onChange(fields.filter((field) => field.id !== fieldId)) onChange(fields.filter((field) => field.id !== fieldId))
} }
function moveField(fieldId: string, delta: -1 | 1) {
const index = fields.findIndex((field) => field.id === fieldId)
if (index < 0) return
const nextIndex = index + delta
if (nextIndex < 0 || nextIndex >= fields.length) return
const next = [...fields]
const [item] = next.splice(index, 1)
next.splice(nextIndex, 0, item)
onChange(next)
}
function addField() { function addField() {
onChange([...fields, createEmptyField()]) onChange([...fields, createEmptyField()])
} }
@@ -215,7 +227,7 @@ export function TechnicalFormModal({
) : ( ) : (
<> <>
<div className={fieldStyles.fieldList}> <div className={fieldStyles.fieldList}>
{fields.map((field) => ( {fields.map((field, index) => (
<div key={field.id} className={fieldStyles.fieldCard}> <div key={field.id} className={fieldStyles.fieldCard}>
<div className={fieldStyles.fieldMainRow}> <div className={fieldStyles.fieldMainRow}>
<input <input
@@ -264,7 +276,29 @@ export function TechnicalFormModal({
/> />
<span>{t('categories.technical.required')}</span> <span>{t('categories.technical.required')}</span>
</label> </label>
<div className={fieldStyles.removeCell}> <div className={fieldStyles.rowActions}>
<Tooltip label={t('categories.technical.moveUp')}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => moveField(field.id, -1)}
disabled={index === 0}
aria-label={t('categories.technical.moveUp')}
>
<ChevronUp size={15} />
</button>
</Tooltip>
<Tooltip label={t('categories.technical.moveDown')}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => moveField(field.id, 1)}
disabled={index === fields.length - 1}
aria-label={t('categories.technical.moveDown')}
>
<ChevronDown size={15} />
</button>
</Tooltip>
<Tooltip label={t('categories.technical.removeField')}> <Tooltip label={t('categories.technical.removeField')}>
<button <button
type="button" type="button"
@@ -0,0 +1,68 @@
.root {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
}
.row input {
width: 100%;
min-height: var(--field-height, 40px);
padding: var(--field-padding-y, 8px) var(--field-padding-x, 12px);
font-size: var(--field-font-size, 14px);
font-family: inherit;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm, 8px);
outline: none;
box-sizing: border-box;
}
.row input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.removeBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: var(--text-secondary);
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm, 8px);
cursor: pointer;
}
.removeBtn:hover {
color: #dc2626;
border-color: rgba(220, 38, 38, 0.35);
}
.addBtn {
display: inline-flex;
align-items: center;
gap: 6px;
align-self: flex-start;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
color: var(--primary);
background: transparent;
border: 1px dashed rgba(var(--primary-rgb) / 0.45);
border-radius: var(--radius-sm, 8px);
cursor: pointer;
}
.addBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
@@ -0,0 +1,69 @@
import { Plus, Trash2 } from 'lucide-react'
import styles from './TechnicalMultiValueEditor.module.css'
interface TechnicalMultiValueEditorProps {
id?: string
values: string[]
onChange: (values: string[]) => void
addLabel: string
removeLabel: string
placeholder?: string
}
export function TechnicalMultiValueEditor({
id,
values,
onChange,
addLabel,
removeLabel,
placeholder,
}: TechnicalMultiValueEditorProps) {
const rows = values.length > 0 ? values : ['']
function updateRow(index: number, next: string) {
const copy = [...rows]
copy[index] = next
onChange(copy)
}
function removeRow(index: number) {
if (rows.length <= 1) {
onChange([''])
return
}
onChange(rows.filter((_, i) => i !== index))
}
function addRow() {
onChange([...rows, ''])
}
return (
<div className={styles.root}>
{rows.map((item, index) => (
<div key={`${id ?? 'mv'}-${index}`} className={styles.row}>
<input
id={index === 0 ? id : undefined}
type="text"
value={item}
placeholder={placeholder}
onChange={(e) => updateRow(index, e.target.value)}
/>
<button
type="button"
className={styles.removeBtn}
onClick={() => removeRow(index)}
aria-label={removeLabel}
title={removeLabel}
>
<Trash2 size={15} />
</button>
</div>
))}
<button type="button" className={styles.addBtn} onClick={addRow}>
<Plus size={15} />
{addLabel}
</button>
</div>
)
}
@@ -280,34 +280,43 @@
} }
.colorSearchWrap { .colorSearchWrap {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 10px; gap: 8px;
}
.colorSearchIcon {
position: absolute;
inset-inline-start: 12px;
color: var(--text-muted);
pointer-events: none;
}
.colorSearchInput {
width: 100%;
min-height: var(--field-height); min-height: var(--field-height);
padding: var(--field-padding-y) 36px; margin-bottom: 10px;
font-size: var(--field-font-size); padding: 0 var(--field-padding-x);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--surface); background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35); border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.colorSearchWrap:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.colorSearchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.colorSearchInput {
flex: 1;
min-width: 0;
min-height: calc(var(--field-height) - 2px);
padding: var(--field-padding-y) 0;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
box-sizing: border-box;
}
.colorSearchInput::placeholder { .colorSearchInput::placeholder {
font-family: var(--font-ui); font-family: var(--font-ui);
opacity: 1; opacity: 1;
@@ -315,13 +324,11 @@
} }
.colorSearchInput:focus { .colorSearchInput:focus {
border-color: var(--primary); outline: none;
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
} }
.colorSearchClear { .colorSearchClear {
position: absolute; flex-shrink: 0;
inset-inline-end: 8px;
width: 28px; width: 28px;
height: 28px; height: 28px;
display: flex; display: flex;
@@ -358,6 +365,89 @@
color: var(--text-muted); color: var(--text-muted);
} }
.customColorBlock {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed rgba(148, 163, 184, 0.35);
display: flex;
flex-direction: column;
gap: 8px;
}
.customColorTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.customColorRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.customColorNameInput {
flex: 1;
min-width: 140px;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.customColorNameInput:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.customColorPickerLabel {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.35);
overflow: hidden;
cursor: pointer;
background: var(--surface);
}
.customColorPicker {
width: 48px;
height: 48px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
.customColorError {
margin: 0;
font-size: 12px;
color: #dc2626;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.customValues { .customValues {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -500,6 +590,12 @@
color: #ef4444; color: #ef4444;
} }
.removeRowBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.preview { .preview {
font-size: 13px; font-size: 13px;
color: var(--text-primary); color: var(--text-primary);
@@ -3,10 +3,9 @@ import { createPortal } from 'react-dom'
import { Pencil, Plus, X } from 'lucide-react' import { Pencil, Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import type { Variation, VariationType } from '../types/variation' import type { Variation, VariationType } from '../types/variation'
import { COLOR_OPTIONS, normalizeColorName } from '../types/variation'
import { createId } from '../utils/id' import { createId } from '../utils/id'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { textLocaleAttrs } from '../utils/textLocale' import { textLocaleAttrs } from '../utils/textLocale'
import { AddVariationModal } from './AddVariationModal' import { AddVariationModal } from './AddVariationModal'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
@@ -122,11 +121,7 @@ export function VariationsModal({
} }
function localizeValue(value: string) { function localizeValue(value: string) {
const normalized = normalizeColorName(value) return localizeVariationOptionLabel(value, t)
if ((COLOR_OPTIONS as string[]).includes(normalized)) {
return t(`categories.variations.color.${normalized}` as BusinessMessageKey)
}
return value
} }
return createPortal( return createPortal(
@@ -0,0 +1,171 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur, 16px));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur, 16px));
display: flex;
align-items: center;
justify-content: center;
z-index: 260;
padding: 20px;
}
.modal {
width: 100%;
max-width: 640px;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-ui);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.error {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
font-family: var(--font-ui);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.field input,
.field textarea {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
line-height: 1.4;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.field textarea {
min-height: 120px;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.valueField {
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
.cancelBtn {
min-height: var(--field-height);
padding: 0 16px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover {
background: rgba(148, 163, 184, 0.15);
}
.submitBtn {
min-height: var(--field-height);
padding: 0 18px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from './WebsiteMetaTagModal.module.css'
const ANIMATION_MS = 220
const META_HTML_RE = /^<meta\b[^>]*>/i
interface WebsiteMetaTagModalProps {
open: boolean
onClose: () => void
onSubmit: (data: { name: string; content: string }) => void
initial?: { name: string; content: string } | null
}
export function WebsiteMetaTagModal({
open,
onClose,
onSubmit,
initial = null,
}: WebsiteMetaTagModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const isEditing = Boolean(initial)
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [name, setName] = useState('')
const [content, setContent] = useState('')
const [formError, setFormError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setName(initial?.name ?? '')
setContent(initial?.content ?? '')
setFormError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
// Reset fields only when opening; ignore `initial` identity churn while open.
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmedName = name.trim()
const trimmedContent = content.trim()
if (!trimmedName) {
setFormError(t('website.badges.meta.nameInvalid'))
return
}
if (!trimmedContent || !META_HTML_RE.test(trimmedContent)) {
setFormError(t('website.badges.meta.contentRequired'))
return
}
onSubmit({ name: trimmedName, content: trimmedContent })
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="website-meta-tag-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="website-meta-tag-modal-title" className={styles.title}>
{isEditing ? t('website.badges.meta.editTitle') : t('website.badges.meta.modalTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="website-meta-tag-name">{t('website.badges.meta.nameLabel')}</label>
<input
id="website-meta-tag-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('website.badges.meta.namePlaceholder')}
dir="ltr"
autoFocus
/>
</div>
<div className={`${styles.field} ${styles.valueField}`}>
<label htmlFor="website-meta-tag-value">{t('website.badges.meta.valueLabel')}</label>
<textarea
id="website-meta-tag-value"
rows={4}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('website.badges.meta.contentPlaceholder')}
dir="ltr"
/>
</div>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
{t('common.cancel')}
</button>
<button type="submit" className={styles.submitBtn}>
{isEditing ? t('website.badges.meta.saveEdit') : t('website.badges.meta.add')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
@@ -65,6 +65,18 @@
cursor: pointer; cursor: pointer;
} }
/* Checkerboard so white/transparent PNG icons stay visible in the CMS. */
.imageButtonTransparent {
background-color: #64748b;
background-image:
linear-gradient(45deg, #475569 25%, transparent 25%),
linear-gradient(-45deg, #475569 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #475569 75%),
linear-gradient(-45deg, transparent 75%, #475569 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
.image { .image {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -3,6 +3,7 @@ import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import type { WebsiteStaticImageItem } from '../types/websiteStaticImage' import type { WebsiteStaticImageItem } from '../types/websiteStaticImage'
import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio' import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio'
import { isPngImageUrl } from '../utils/imageUpload'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import controlStyles from './CategoryRow.module.css' import controlStyles from './CategoryRow.module.css'
import styles from './WebsiteStaticImageGallery.module.css' import styles from './WebsiteStaticImageGallery.module.css'
@@ -106,7 +107,7 @@ export function WebsiteStaticImageGallery({
<article className={styles.card}> <article className={styles.card}>
<button <button
type="button" type="button"
className={styles.imageButton} className={`${styles.imageButton} ${isPngImageUrl(image.url) ? styles.imageButtonTransparent : ''}`}
onClick={() => onAddAt(index)} onClick={() => onAddAt(index)}
aria-label={replaceLabel} aria-label={replaceLabel}
style={{ aspectRatio: cssAspect }} style={{ aspectRatio: cssAspect }}
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ import {
createUserProduct, createUserProduct,
getUserProduct, getUserProduct,
getUserProductCategoryTechnicalForm, getUserProductCategoryTechnicalForm,
hydrateTechnicalFormValues,
listUserProductCategories, listUserProductCategories,
updateUserProduct, updateUserProduct,
type TechnicalFormField, type TechnicalFormField,
@@ -36,6 +37,7 @@ import {
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService' import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { seoMetaPayload } from '../utils/seoMeta' import { seoMetaPayload } from '../utils/seoMeta'
import { TechnicalMultiValueEditor } from '../components/TechnicalMultiValueEditor'
import styles from './AddCustomerProductPage.module.css' import styles from './AddCustomerProductPage.module.css'
type StepId = 1 | 2 | 3 type StepId = 1 | 2 | 3
@@ -266,7 +268,12 @@ export function AddCustomerProductPage() {
if (controller.signal.aborted) return if (controller.signal.aborted) return
setTechnicalFields(response.form?.fields ?? []) setTechnicalFields(response.form?.fields ?? [])
if (pendingTechnicalValuesRef.current) { if (pendingTechnicalValuesRef.current) {
setTechnicalValues(pendingTechnicalValuesRef.current) setTechnicalValues(
hydrateTechnicalFormValues(
response.form?.fields ?? [],
pendingTechnicalValuesRef.current,
),
)
pendingTechnicalValuesRef.current = null pendingTechnicalValuesRef.current = null
} }
}) })
@@ -321,8 +328,9 @@ export function AddCustomerProductPage() {
if (!field.isRequired) continue if (!field.isRequired) continue
const value = technicalValues[field.id] const value = technicalValues[field.id]
const ok = const ok =
field.type === 'multi_select' field.type === 'multi_select' || field.type === 'multi_value'
? Array.isArray(value) && value.length > 0 ? Array.isArray(value) &&
value.some((item) => String(item).trim().length > 0)
: typeof value === 'string' && value.trim().length > 0 : typeof value === 'string' && value.trim().length > 0
if (!ok) { if (!ok) {
return t('customerProducts.form.error.technicalRequired') return t('customerProducts.form.error.technicalRequired')
@@ -483,6 +491,19 @@ export function AddCustomerProductPage() {
) )
} }
if (field.type === 'multi_value') {
const items = Array.isArray(value) ? value.map(String) : []
return (
<TechnicalMultiValueEditor
id={id}
values={items}
onChange={(next) => setTechnicalField(field.id, next)}
addLabel={t('customerProducts.form.technical.addValue')}
removeLabel={t('customerProducts.form.technical.removeValue')}
/>
)
}
return ( return (
<input <input
id={id} id={id}
@@ -751,6 +772,8 @@ export function AddCustomerProductPage() {
} }
}} }}
aspect={1} aspect={1}
minZoom={0.4}
outputFormat="jpeg"
hint={t('customerProducts.form.images.thumbnailHint')} hint={t('customerProducts.form.images.thumbnailHint')}
/> />
</div> </div>
+6 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom' import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect' import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category' import type { Category } from '../types/category'
import type { BlogPostType } from '../types/blog' import type { BlogPostType } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog' import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta' import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -61,8 +60,7 @@ export function AddNewBlogPage() {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories) const selectedAspect =
const selectedAspect =
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2 TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
useEffect(() => { useEffect(() => {
@@ -273,11 +271,13 @@ export function AddNewBlogPage() {
<div className={`${styles.field} ${styles.col6Span}`}> <div className={`${styles.field} ${styles.col6Span}`}>
<label>{t('blog.form.category')}</label> <label>{t('blog.form.category')}</label>
<SearchableSelect <CategoryTreeSelect
options={categoryOptions} categories={categories}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('blog.form.categoryPlaceholder')} placeholder={t('blog.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom' import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect' import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category' import type { Category } from '../types/category'
import type { VideoProvider } from '../types/instruction' import type { VideoProvider } from '../types/instruction'
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction' import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta' import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -63,8 +62,7 @@ export function AddNewInstructionPage() {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories) const selectedAspect =
const selectedAspect =
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2 COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
useEffect(() => { useEffect(() => {
@@ -257,11 +255,13 @@ export function AddNewInstructionPage() {
<div className={styles.formColumn}> <div className={styles.formColumn}>
<div className={styles.field}> <div className={styles.field}>
<label>{t('instruction.form.category')}</label> <label>{t('instruction.form.category')}</label>
<SearchableSelect <CategoryTreeSelect
options={categoryOptions} categories={categories}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('instruction.form.categoryPlaceholder')} placeholder={t('instruction.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom' import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect' import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader' import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
@@ -27,7 +27,6 @@ import {
updatePortfolio, updatePortfolio,
} from '../services/portfolioService' } from '../services/portfolioService'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta' import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -70,8 +69,7 @@ export function AddNewPortfolioPage() {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories) const selectedAspect =
const selectedAspect =
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2 TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
useEffect(() => { useEffect(() => {
@@ -277,11 +275,13 @@ export function AddNewPortfolioPage() {
<div className={styles.formColumn}> <div className={styles.formColumn}>
<div className={styles.field}> <div className={styles.field}>
<label>{t('portfolio.form.category')}</label> <label>{t('portfolio.form.category')}</label>
<SearchableSelect <CategoryTreeSelect
options={categoryOptions} categories={categories}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('portfolio.form.categoryPlaceholder')} placeholder={t('portfolio.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/> />
</div> </div>
@@ -17,6 +17,7 @@
.col2 { grid-column: span 2; } .col2 { grid-column: span 2; }
.col3 { grid-column: span 3; } .col3 { grid-column: span 3; }
.col4 { grid-column: span 4; } .col4 { grid-column: span 4; }
.col5 { grid-column: span 5; }
.col6 { grid-column: 1 / span 6; } .col6 { grid-column: 1 / span 6; }
.col8 { grid-column: span 8; } .col8 { grid-column: span 8; }
.col7 { grid-column: span 7; } .col7 { grid-column: span 7; }
@@ -86,6 +87,64 @@
color: var(--text-muted); color: var(--text-muted);
} }
.catalogHint {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-muted);
}
.catalogRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.catalogInput {
max-width: 100%;
font-size: 13px;
}
.catalogSelected {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.12);
font-size: 13px;
color: var(--text-secondary);
min-width: 0;
}
.catalogSelected span,
.catalogLink {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
.catalogLink {
color: var(--primary);
text-decoration: underline;
}
.catalogClear {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
color: var(--text-muted);
flex-shrink: 0;
}
.catalogClear:hover {
color: var(--text);
background: rgba(148, 163, 184, 0.2);
}
.summaryField textarea { .summaryField textarea {
min-height: calc((var(--field-height) + 8px) * 2); min-height: calc((var(--field-height) + 8px) * 2);
padding-inline-end: 12px; padding-inline-end: 12px;
@@ -437,6 +496,7 @@
.col2, .col2,
.col3, .col3,
.col4, .col4,
.col5,
.col6, .col6,
.col6Span, .col6Span,
.col7, .col7,
+112 -29
View File
@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom' import { Link, useParams, useNavigate } from 'react-router-dom'
import { Sparkles } from 'lucide-react' import { FileText, Sparkles, X } from 'lucide-react'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { SearchableSelect } from '../components/SearchableSelect' import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader' import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal' import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
@@ -18,6 +18,7 @@ import {
galleryMediaIdsAfterChange, galleryMediaIdsAfterChange,
resolveDataUrlToMediaId, resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds, resolveDataUrlsToMediaIds,
uploadCatalogPdf,
} from '../services/mediaService' } from '../services/mediaService'
import { import {
createProduct, createProduct,
@@ -29,19 +30,20 @@ import {
fillProductFieldByAi, fillProductFieldByAi,
} from '../services/productAiService' } from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category' import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css' import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
export function AddNewProductPage() { export function AddNewProductPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const t = useT() const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const catalogInputRef = useRef<HTMLInputElement>(null)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([]) const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
@@ -54,22 +56,24 @@ export function AddNewProductPage() {
const [thumbnail, setThumbnail] = useState<string | null>(null) const [thumbnail, setThumbnail] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([]) const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null) const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([]) const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [catalogMediaId, setCatalogMediaId] = useState<string | null>(null)
const [catalogUrl, setCatalogUrl] = useState<string | null>(null)
const [catalogFileName, setCatalogFileName] = useState<string | null>(null)
const [catalogFile, setCatalogFile] = useState<File | null>(null)
const [catalogRemoved, setCatalogRemoved] = useState(false)
const [loaded, setLoaded] = useState(!isEdit) const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null) const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
const [isAiRunning, setIsAiRunning] = useState(false) const [isAiRunning, setIsAiRunning] = useState(false)
const categoryOptions = flattenCategories(categories)
const selectedCategoryName = useMemo(() => { const selectedCategoryName = useMemo(() => {
const match = categoryOptions.find((item) => item.id === categoryId) const match = categories.find((item) => item.id === categoryId)
if (!match) return '' if (!match) return ''
return match.nameFa?.trim() || match.nameEn || '' return match.nameFa?.trim() || match.nameEn || ''
}, [categoryOptions, categoryId]) }, [categories, categoryId])
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
@@ -175,10 +179,13 @@ export function AddNewProductPage() {
setThumbnail(form.thumbnail) setThumbnail(form.thumbnail)
setImages(form.images) setImages(form.images)
setTags(form.tags) setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setThumbnailMediaId(form.thumbnailMediaId) setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds) setGalleryMediaIds(form.galleryMediaIds)
setCatalogMediaId(form.catalogMediaId)
setCatalogUrl(form.catalogUrl)
setCatalogFileName(form.catalogFileName)
setCatalogFile(null)
setCatalogRemoved(false)
setLoaded(true) setLoaded(true)
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
@@ -192,6 +199,39 @@ export function AddNewProductPage() {
if (!loaded) return null if (!loaded) return null
function clearCatalogFile() {
setCatalogFile(null)
setCatalogMediaId(null)
setCatalogUrl(null)
setCatalogFileName(null)
setCatalogRemoved(true)
if (catalogInputRef.current) {
catalogInputRef.current.value = ''
}
}
function handleCatalogFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null
if (!file) return
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setError(t('products.form.catalog.errorType'))
e.target.value = ''
return
}
if (file.size > CATALOG_PDF_MAX_BYTES) {
setError(t('products.form.catalog.errorSize'))
e.target.value = ''
return
}
setError('')
setCatalogFile(file)
setCatalogFileName(file.name)
setCatalogUrl(null)
setCatalogRemoved(false)
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
setIsSubmitting(true) setIsSubmitting(true)
@@ -215,6 +255,14 @@ export function AddNewProductPage() {
galleryMediaIds, galleryMediaIds,
) )
let nextCatalogMediaId: string | null = catalogMediaId
if (catalogFile) {
const uploaded = await uploadCatalogPdf(catalogFile)
nextCatalogMediaId = uploaded.id
} else if (catalogRemoved) {
nextCatalogMediaId = null
}
const payload = { const payload = {
title: nameEn.trim(), title: nameEn.trim(),
nameFa: nameFa.trim(), nameFa: nameFa.trim(),
@@ -224,8 +272,8 @@ export function AddNewProductPage() {
brandId: brandId || undefined, brandId: brandId || undefined,
featuredMediaId: featuredMediaId ?? undefined, featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds, galleryMediaIds: nextGalleryMediaIds,
catalogMediaId: nextCatalogMediaId ?? undefined,
tags, tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const, status: 'published' as const,
} }
@@ -235,6 +283,7 @@ export function AddNewProductPage() {
categoryId: categoryId || null, categoryId: categoryId || null,
brandId: brandId || null, brandId: brandId || null,
featuredMediaId, featuredMediaId,
catalogMediaId: nextCatalogMediaId,
}) })
} else { } else {
await createProduct(payload) await createProduct(payload)
@@ -293,15 +342,20 @@ export function AddNewProductPage() {
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}> <div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}>
<label>{t('products.form.thumbnail')}</label> <label>{t('products.form.thumbnail')}</label>
<ImageCropper value={thumbnail} onChange={setThumbnail} /> <ImageCropper
value={thumbnail}
onChange={setThumbnail}
minZoom={0.4}
outputFormat="jpeg"
/>
</div> </div>
<div className={styles.col10}> <div className={styles.col10}>
<div className={styles.topFields}> <div className={styles.topFields}>
<div className={`${styles.field} ${styles.fieldCategory}`}> <div className={`${styles.field} ${styles.fieldCategory}`}>
<label>{t('products.form.category')}</label> <label>{t('products.form.category')}</label>
<SearchableSelect <CategoryTreeSelect
options={categoryOptions} categories={categories}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('products.form.categoryPlaceholder')} placeholder={t('products.form.categoryPlaceholder')}
@@ -437,6 +491,48 @@ export function AddNewProductPage() {
/> />
</div> </div>
<div className={`${styles.field} ${styles.col12}`}>
<label htmlFor="product-catalog">{t('products.form.catalog')}</label>
<p className={styles.catalogHint}>{t('products.form.catalogHint')}</p>
<div className={styles.catalogRow}>
<input
ref={catalogInputRef}
id="product-catalog"
type="file"
accept="application/pdf,.pdf"
onChange={handleCatalogFileChange}
disabled={isSubmitting}
className={styles.catalogInput}
/>
{(catalogFileName || catalogUrl) && (
<div className={styles.catalogSelected}>
<FileText size={16} aria-hidden />
{catalogUrl && !catalogFile ? (
<a
href={catalogUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.catalogLink}
>
{catalogFileName || t('products.form.catalogView')}
</a>
) : (
<span>{catalogFileName}</span>
)}
<button
type="button"
className={styles.catalogClear}
onClick={clearCatalogFile}
disabled={isSubmitting}
aria-label={t('products.form.catalogRemove')}
>
<X size={16} />
</button>
</div>
)}
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.tags')}</label> <label>{t('products.form.tags')}</label>
<TagInput <TagInput
@@ -445,19 +541,6 @@ export function AddNewProductPage() {
placeholder={t('products.form.tagsPlaceholder')} placeholder={t('products.form.tagsPlaceholder')}
/> />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
+6 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom' import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect' import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category' import type { Category } from '../types/category'
import type { VideoProvider } from '../types/video' import type { VideoProvider } from '../types/video'
import { VIDEO_PROVIDER_OPTIONS } from '../types/video' import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta' import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -55,8 +54,7 @@ export function AddNewVideoPage() {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -218,11 +216,13 @@ export function AddNewVideoPage() {
<div className={styles.formColumn}> <div className={styles.formColumn}>
<div className={styles.field}> <div className={styles.field}>
<label>{t('video.form.category')}</label> <label>{t('video.form.category')}</label>
<SearchableSelect <CategoryTreeSelect
options={categoryOptions} categories={categories}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('video.form.categoryPlaceholder')} placeholder={t('video.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom' import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect' import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader' import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
@@ -28,7 +28,6 @@ import {
updateWorkshop, updateWorkshop,
} from '../services/workshopService' } from '../services/workshopService'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta' import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -61,8 +60,7 @@ export function AddNewWorkshopPage() {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -256,11 +254,13 @@ export function AddNewWorkshopPage() {
<div className={styles.formColumn}> <div className={styles.formColumn}>
<div className={styles.field}> <div className={styles.field}>
<label>{t('workshop.form.category')}</label> <label>{t('workshop.form.category')}</label>
<SearchableSelect <CategoryTreeSelect
options={categoryOptions} categories={categories}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('workshop.form.categoryPlaceholder')} placeholder={t('workshop.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/> />
</div> </div>
@@ -0,0 +1,201 @@
.layout {
display: grid;
grid-template-columns: minmax(160px, 200px) minmax(0, 1fr);
gap: 16px 20px;
align-items: start;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.row {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px 12px;
align-items: start;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.field input,
.field select,
.field textarea {
width: 100%;
min-width: 0;
height: var(--field-height, 40px);
padding: 0 10px;
font-size: 13px;
color: var(--text);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.field select {
cursor: pointer;
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 16px) calc(50% - 2px),
calc(100% - 11px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-inline-end: 28px;
}
:global([dir='rtl']) .field select {
background-position:
11px calc(50% - 2px),
16px calc(50% - 2px);
}
.field textarea {
height: auto;
min-height: 84px;
padding-top: 10px;
padding-bottom: 10px;
resize: vertical;
line-height: 1.45;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.field input:disabled,
.field select:disabled,
.field textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hint {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: var(--text-muted);
}
.nameFa,
.nameEn {
grid-column: span 6;
}
.descriptionFull {
grid-column: 1 / -1;
}
.minShopping,
.interest {
grid-column: span 4;
}
.loanGroup,
.monthsGroup {
grid-column: span 8;
}
.group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 10px;
min-width: 0;
padding-inline: 10px;
border-inline-start: 1px solid rgba(148, 163, 184, 0.35);
border-inline-end: 1px solid rgba(148, 163, 184, 0.35);
}
.monthsGroup {
grid-template-columns: 1fr 1fr 1fr;
}
.imageCol {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.imageCol > label {
width: 100%;
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.imageCol {
max-width: 220px;
margin-inline: auto;
}
.row {
grid-template-columns: 1fr 1fr;
}
.nameFa,
.nameEn,
.minShopping,
.loanGroup,
.interest,
.monthsGroup,
.descriptionFull {
grid-column: span 1;
}
.loanGroup,
.monthsGroup,
.descriptionFull {
grid-column: 1 / -1;
}
}
@media (max-width: 560px) {
.row {
grid-template-columns: 1fr;
}
.nameFa,
.nameEn,
.minShopping,
.loanGroup,
.interest,
.monthsGroup,
.descriptionFull {
grid-column: 1 / -1;
}
.group {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,505 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ImageCropper } from '../components/ImageCropper'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { resolveDataUrlToMediaId } from '../services/mediaService'
import {
createStoreLoanMethod,
getStoreLoanMethod,
mapStoreLoanMethodApiToUi,
updateStoreLoanMethod,
} from '../services/storeLoanMethodService'
import type { StoreLoanImageAspect } from '../types/storeLoanMethod'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import formStyles from './AddNewProductPage.module.css'
import styles from './AddStoreInstallmentPage.module.css'
const IMAGE_ASPECTS: { id: StoreLoanImageAspect; aspect: number }[] = [
{ id: '1:1', aspect: 1 },
{ id: '3:2', aspect: 3 / 2 },
]
const MONTH_STEP_OPTIONS = [2, 3, 4, 6, 12] as const
const DEFAULT_MONTH_STEP = '3'
function normalizeMonthStep(value: number | string | null | undefined): string {
const parsed = Number(value)
return (MONTH_STEP_OPTIONS as readonly number[]).includes(parsed)
? String(parsed)
: DEFAULT_MONTH_STEP
}
function parseNumberInput(value: string): number | null {
const trimmed = value.trim().replace(/,/g, '')
if (!trimmed) return null
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : null
}
function mediaFilenameFromDataUrl(value: string | null): string {
if (value?.startsWith('data:image/png')) return 'store-loan-method.png'
return 'store-loan-method.jpg'
}
export function AddStoreInstallmentPage() {
const { id } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const t = useT()
const isEdit = Boolean(id)
const [nameFa, setNameFa] = useState('')
const [nameEn, setNameEn] = useState('')
const [description, setDescription] = useState('')
const [image, setImage] = useState<string | null>(null)
const [imageMediaId, setImageMediaId] = useState<string | null>(null)
const [imageAspect, setImageAspect] = useState<StoreLoanImageAspect>('1:1')
const [minShoppingAmount, setMinShoppingAmount] = useState('')
const [minAmount, setMinAmount] = useState('')
const [maxAmount, setMaxAmount] = useState('')
const [interest, setInterest] = useState('')
const [returnMonthsMin, setReturnMonthsMin] = useState('')
const [returnMonthsMax, setReturnMonthsMax] = useState('')
const [returnMonthsStep, setReturnMonthsStep] = useState(DEFAULT_MONTH_STEP)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const selectedAspect =
IMAGE_ASPECTS.find((option) => option.id === imageAspect)?.aspect ?? 1
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadMethod() {
setError('')
try {
const data = await getStoreLoanMethod(id!, controller.signal)
const method = mapStoreLoanMethodApiToUi(data.method)
setNameFa(method.nameFa)
setNameEn(method.nameEn)
setDescription(method.description)
setImage(method.imageUrl)
setImageMediaId(method.imageMediaId)
setImageAspect(method.imageAspectRatio)
setMinShoppingAmount(
method.minShoppingAmount != null
? formatIrtInput(String(Math.round(method.minShoppingAmount)))
: '',
)
setMinAmount(
method.minAmount != null
? formatIrtInput(String(Math.round(method.minAmount)))
: '',
)
setMaxAmount(
method.maxAmount != null
? formatIrtInput(String(Math.round(method.maxAmount)))
: '',
)
setInterest(String(method.interest))
setReturnMonthsMin(String(method.returnMonthsMin))
setReturnMonthsMax(String(method.returnMonthsMax))
setReturnMonthsStep(normalizeMonthStep(method.returnMonthsStep))
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
navigate('/store/installments', { replace: true })
}
}
void loadMethod()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) return null
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
const parsedMinShopping = parseIrtInput(minShoppingAmount)
const parsedMinAmount = parseIrtInput(minAmount)
const parsedMaxAmount = parseIrtInput(maxAmount)
const parsedInterest = parseNumberInput(interest)
const parsedMonthsMin = parseNumberInput(returnMonthsMin)
const parsedMonthsMax = parseNumberInput(returnMonthsMax)
const parsedMonthsStep = parseNumberInput(returnMonthsStep)
if (
!nameFa.trim() ||
!nameEn.trim() ||
parsedMinShopping === null ||
parsedMinAmount === null ||
parsedMaxAmount === null ||
parsedInterest === null ||
parsedMonthsMin === null ||
parsedMonthsMax === null ||
parsedMonthsStep === null
) {
setError(t('store.installments.form.errorRequired'))
setIsSubmitting(false)
return
}
if (parsedMinAmount > parsedMaxAmount) {
setError(t('store.installments.form.errorAmountRange'))
setIsSubmitting(false)
return
}
if (parsedMonthsMin > parsedMonthsMax) {
setError(t('store.installments.form.errorMonthsRange'))
setIsSubmitting(false)
return
}
if (!(MONTH_STEP_OPTIONS as readonly number[]).includes(parsedMonthsStep)) {
setError(t('store.installments.form.errorMonthsStep'))
setIsSubmitting(false)
return
}
if ((parsedMonthsMax - parsedMonthsMin) % parsedMonthsStep !== 0) {
setError(t('store.installments.form.errorMonthsStepReach'))
setIsSubmitting(false)
return
}
try {
const nextImageMediaId = await resolveDataUrlToMediaId(
image,
mediaFilenameFromDataUrl(image),
imageMediaId,
)
const payload = {
nameFa: nameFa.trim(),
nameEn: nameEn.trim(),
description: description.trim() || null,
imageAspectRatio: imageAspect,
minShoppingAmount: parsedMinShopping,
minAmount: parsedMinAmount,
maxAmount: parsedMaxAmount,
interest: parsedInterest,
returnMonthsMin: Math.round(parsedMonthsMin),
returnMonthsMax: Math.round(parsedMonthsMax),
returnMonthsStep: Math.round(parsedMonthsStep),
...(isEdit
? { imageMediaId: nextImageMediaId }
: nextImageMediaId
? { imageMediaId: nextImageMediaId }
: {}),
}
if (isEdit && id) {
await updateStoreLoanMethod(id, payload)
showToast(t('store.installments.toast.updated'), 'success')
} else {
await createStoreLoanMethod({
...payload,
description: description.trim() || undefined,
})
showToast(t('store.installments.toast.created'), 'success')
}
navigate('/store/installments')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError(t('store.installments.form.errorSave'))
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Store', href: '/store' },
{ label: 'Installments & Facilities', href: '/store/installments' },
{ label: 'Edit Installment Method' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Store', href: '/store' },
{ label: 'Installments & Facilities', href: '/store/installments' },
{ label: 'Add Installment Method' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit
? t('title.editInstallment')
: t('title.addInstallment')}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit
? t('store.installments.form.edit.subtitle')
: t('store.installments.form.add.subtitle')}
</p>
</div>
</div>
{error && (
<div className={formStyles.error} role="alert">
{error}
</div>
)}
<form className={formStyles.form} onSubmit={handleSubmit}>
<div className={styles.layout}>
<div className={styles.imageCol}>
<label>{t('store.installments.form.image')}</label>
<ImageCropper
value={image}
onChange={setImage}
aspect={selectedAspect}
compact
uploadLabel={t('store.installments.form.uploadImage')}
hint={t('store.installments.form.cropHint')}
changeLabel={t('store.installments.form.changeImage')}
/>
<div
className={formStyles.aspectSwitch}
role="radiogroup"
aria-label={t('store.installments.form.aspectAria')}
>
{IMAGE_ASPECTS.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={imageAspect === option.id}
className={`${formStyles.aspectSwitchBtn} ${
imageAspect === option.id ? formStyles.aspectSwitchBtnSelected : ''
}`}
onClick={() => {
if (option.id === imageAspect) return
setImageAspect(option.id)
if (image) {
setImage(null)
setImageMediaId(null)
}
}}
disabled={isSubmitting}
>
{option.id}
</button>
))}
</div>
</div>
<div className={styles.fields}>
<div className={styles.row}>
<div className={`${styles.field} ${styles.nameFa}`}>
<label htmlFor="loan-name-fa">{t('store.installments.form.nameFa')}</label>
<input
id="loan-name-fa"
type="text"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
placeholder={t('store.installments.form.nameFaPlaceholder')}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.nameEn}`}>
<label htmlFor="loan-name-en">{t('store.installments.form.nameEn')}</label>
<input
id="loan-name-en"
type="text"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t('store.installments.form.nameEnPlaceholder')}
required
disabled={isSubmitting}
dir="ltr"
/>
</div>
</div>
<div className={styles.row}>
<div className={`${styles.field} ${styles.descriptionFull}`}>
<label htmlFor="loan-description">
{t('store.installments.form.description')}
</label>
<textarea
id="loan-description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('store.installments.form.descriptionPlaceholder')}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.row}>
<div className={`${styles.field} ${styles.minShopping}`}>
<label htmlFor="loan-min-shopping">
{t('store.installments.form.minShoppingAmount')}
</label>
<input
id="loan-min-shopping"
type="text"
inputMode="numeric"
value={minShoppingAmount}
onChange={(e) => setMinShoppingAmount(formatIrtInput(e.target.value))}
required
disabled={isSubmitting}
/>
<p className={styles.hint}>
{t('store.installments.form.minShoppingAmount.hint')}
</p>
</div>
<div className={`${styles.group} ${styles.loanGroup}`}>
<div className={styles.field}>
<label htmlFor="loan-min-amount">
{t('store.installments.form.minAmount')}
</label>
<input
id="loan-min-amount"
type="text"
inputMode="numeric"
value={minAmount}
onChange={(e) => setMinAmount(formatIrtInput(e.target.value))}
required
disabled={isSubmitting}
/>
<p className={styles.hint}>{t('store.installments.form.minAmount.hint')}</p>
</div>
<div className={styles.field}>
<label htmlFor="loan-max-amount">
{t('store.installments.form.maxAmount')}
</label>
<input
id="loan-max-amount"
type="text"
inputMode="numeric"
value={maxAmount}
onChange={(e) => setMaxAmount(formatIrtInput(e.target.value))}
required
disabled={isSubmitting}
/>
<p className={styles.hint}>{t('store.installments.form.maxAmount.hint')}</p>
</div>
</div>
</div>
<div className={styles.row}>
<div className={`${styles.field} ${styles.interest}`}>
<label htmlFor="loan-interest">
{t('store.installments.form.interest')}
</label>
<input
id="loan-interest"
type="number"
min={0}
step="any"
value={interest}
onChange={(e) => setInterest(e.target.value)}
required
disabled={isSubmitting}
/>
<p className={styles.hint}>{t('store.installments.form.interest.hint')}</p>
</div>
<div className={`${styles.group} ${styles.monthsGroup}`}>
<div className={styles.field}>
<label htmlFor="loan-months-min">
{t('store.installments.form.returnMonthsMin')}
</label>
<input
id="loan-months-min"
type="number"
min={1}
step={1}
value={returnMonthsMin}
onChange={(e) => setReturnMonthsMin(e.target.value)}
required
disabled={isSubmitting}
/>
<p className={styles.hint}>
{t('store.installments.form.returnMonthsMin.hint')}
</p>
</div>
<div className={styles.field}>
<label htmlFor="loan-months-max">
{t('store.installments.form.returnMonthsMax')}
</label>
<input
id="loan-months-max"
type="number"
min={1}
step={1}
value={returnMonthsMax}
onChange={(e) => setReturnMonthsMax(e.target.value)}
required
disabled={isSubmitting}
/>
<p className={styles.hint}>
{t('store.installments.form.returnMonthsMax.hint')}
</p>
</div>
<div className={styles.field}>
<label htmlFor="loan-months-step">
{t('store.installments.form.returnMonthsStep')}
</label>
<select
id="loan-months-step"
value={returnMonthsStep}
onChange={(e) => setReturnMonthsStep(e.target.value)}
required
disabled={isSubmitting}
>
{MONTH_STEP_OPTIONS.map((step) => (
<option key={step} value={step}>
{step}
</option>
))}
</select>
<p className={styles.hint}>
{t('store.installments.form.returnMonthsStep.hint')}
</p>
</div>
</div>
</div>
</div>
</div>
<div className={formStyles.actions}>
<Link to="/store/installments" className={formStyles.cancelBtn}>
{t('store.installments.form.cancel')}
</Link>
<button type="submit" className={formStyles.submitBtn} disabled={isSubmitting}>
{isSubmitting
? t('store.installments.form.saving')
: isEdit
? t('store.installments.form.update')
: t('store.installments.form.save')}
</button>
</div>
</form>
</main>
)
}
@@ -51,6 +51,13 @@
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45); box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
} }
.addFab:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.fabDock { .fabDock {
inset-inline-end: 20px; inset-inline-end: 20px;
@@ -40,8 +40,30 @@
margin-bottom: 0; margin-bottom: 0;
} }
.logoLightCol, .logosBlock {
.logoDarkCol, grid-column: span 12;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.logosPair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
width: 100%;
min-width: 0;
}
.logoField {
min-width: 0;
margin-bottom: 0;
display: flex;
flex-direction: column;
}
.faviconCol { .faviconCol {
grid-column: span 12; grid-column: span 12;
min-width: 0; min-width: 0;
@@ -229,12 +251,8 @@
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
.logoLightCol { .logosBlock {
grid-column: span 2; grid-column: span 4;
}
.logoDarkCol {
grid-column: span 2;
} }
.faviconCol { .faviconCol {
+83 -29
View File
@@ -39,6 +39,14 @@ import styles from './BusinessProfilePage.module.css'
type AddressDraft = AddressListItem type AddressDraft = AddressListItem
type LogoAspectId = '1:1' | '3:2' | '16:9'
const LOGO_ASPECTS: { id: LogoAspectId; aspect: number }[] = [
{ id: '1:1', aspect: 1 },
{ id: '3:2', aspect: 3 / 2 },
{ id: '16:9', aspect: 16 / 9 },
]
const EMPTY_SOCIAL: BusinessSocialMedia = { const EMPTY_SOCIAL: BusinessSocialMedia = {
whatsapp: '', whatsapp: '',
telegram: '', telegram: '',
@@ -82,6 +90,7 @@ export function BusinessProfilePage() {
const [logoMediaId, setLogoMediaId] = useState<string | null>(null) const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
const [logoDark, setLogoDark] = useState<string | null>(null) const [logoDark, setLogoDark] = useState<string | null>(null)
const [logoDarkMediaId, setLogoDarkMediaId] = useState<string | null>(null) const [logoDarkMediaId, setLogoDarkMediaId] = useState<string | null>(null)
const [logoAspect, setLogoAspect] = useState<LogoAspectId>('1:1')
const [favicon, setFavicon] = useState<string | null>(null) const [favicon, setFavicon] = useState<string | null>(null)
const [faviconMediaId, setFaviconMediaId] = useState<string | null>(null) const [faviconMediaId, setFaviconMediaId] = useState<string | null>(null)
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()]) const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
@@ -91,9 +100,25 @@ export function BusinessProfilePage() {
const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL) const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL)
const categoryOptions = useMemo( const categoryOptions = useMemo(
() => flattenBusinessActivityCategories(activityCategories), () => flattenBusinessActivityCategories(activityCategories, locale),
[activityCategories], [activityCategories, locale],
) )
const selectedLogoAspect =
LOGO_ASPECTS.find((option) => option.id === logoAspect)?.aspect ?? 1
function handleLogoAspectChange(next: LogoAspectId) {
if (next === logoAspect) return
setLogoAspect(next)
// Applied crops are locked to the old ratio — drop them so the user recrops.
if (logo) {
setLogo(null)
setLogoMediaId(null)
}
if (logoDark) {
setLogoDark(null)
setLogoDarkMediaId(null)
}
}
const addressLabels = useMemo( const addressLabels = useMemo(
() => ({ () => ({
@@ -176,7 +201,7 @@ export function BusinessProfilePage() {
: item.province, : item.province,
city: item.city, city: item.city,
address: item.address, address: item.address,
postalCode: item.postalCode, postalCode: item.postalCode ?? '',
landline: item.landline ?? '', landline: item.landline ?? '',
} }
}) })
@@ -307,7 +332,7 @@ export function BusinessProfilePage() {
province: item.province.trim(), province: item.province.trim(),
city: item.city.trim(), city: item.city.trim(),
address: item.address.trim(), address: item.address.trim(),
postalCode: item.postalCode.trim(), postalCode: item.postalCode.trim() || undefined,
landline: item.landline?.trim() || null, landline: item.landline?.trim() || null,
})) }))
@@ -378,32 +403,60 @@ export function BusinessProfilePage() {
<section className={styles.section}> <section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3> <h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3>
<div className={formStyles.formGrid}> <div className={formStyles.formGrid}>
<div className={`${formStyles.field} ${styles.logoLightCol}`}> <div className={styles.logosBlock}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label> <div className={styles.logosPair}>
<ImageCropper <div className={`${formStyles.field} ${styles.logoField}`}>
compact <label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
value={logo} <ImageCropper
onChange={setLogo} compact
outputFormat="png" value={logo}
accept="image/png" onChange={setLogo}
uploadLabel={t('bizProfile.logo.upload')} aspect={selectedLogoAspect}
hint={t('bizProfile.logo.hint')} outputFormat="png"
changeLabel={t('bizProfile.logo.change')} accept="image/png"
/> uploadLabel={t('bizProfile.logo.upload')}
</div> hint={t('bizProfile.logo.hint')}
changeLabel={t('bizProfile.logo.change')}
/>
</div>
<div className={`${formStyles.field} ${styles.logoDarkCol}`}> <div className={`${formStyles.field} ${styles.logoField}`}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label> <label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
<ImageCropper <ImageCropper
compact compact
value={logoDark} value={logoDark}
onChange={setLogoDark} onChange={setLogoDark}
outputFormat="png" aspect={selectedLogoAspect}
accept="image/png" outputFormat="png"
uploadLabel={t('bizProfile.logo.upload')} accept="image/png"
hint={t('bizProfile.logo.hint')} uploadLabel={t('bizProfile.logo.upload')}
changeLabel={t('bizProfile.logo.change')} hint={t('bizProfile.logo.hint')}
/> changeLabel={t('bizProfile.logo.change')}
/>
</div>
</div>
<div
className={formStyles.aspectSwitch}
role="radiogroup"
aria-label={t('bizProfile.logo.aspectAria')}
>
{LOGO_ASPECTS.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={logoAspect === option.id}
className={`${formStyles.aspectSwitchBtn} ${
logoAspect === option.id ? formStyles.aspectSwitchBtnSelected : ''
}`}
onClick={() => handleLogoAspectChange(option.id)}
disabled={isSaving}
>
{option.id}
</button>
))}
</div>
</div> </div>
<div className={`${formStyles.field} ${styles.faviconCol}`}> <div className={`${formStyles.field} ${styles.faviconCol}`}>
@@ -412,6 +465,7 @@ export function BusinessProfilePage() {
compact compact
value={favicon} value={favicon}
onChange={setFavicon} onChange={setFavicon}
aspect={1}
outputFormat="png" outputFormat="png"
accept="image/png" accept="image/png"
uploadLabel={t('bizProfile.favicon.upload')} uploadLabel={t('bizProfile.favicon.upload')}
@@ -1,3 +1,67 @@
.listSection {
display: flex;
flex-direction: column;
gap: 14px;
}
.searchWrap {
position: relative;
display: flex;
align-items: center;
}
.searchIcon {
position: absolute;
inset-inline-start: 18px;
color: var(--text-muted);
pointer-events: none;
}
.searchInput {
width: 100%;
height: 52px;
padding-inline: 52px 48px;
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 16px;
box-shadow: var(--glass-shadow);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.searchInput::placeholder {
color: var(--text-muted);
font-weight: 400;
}
.searchInput:focus {
border-color: rgba(var(--primary-rgb) / 0.45);
box-shadow:
var(--glass-shadow),
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.searchClear {
position: absolute;
inset-inline-end: 12px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.searchClear:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.list { .list {
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
+84 -26
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { FolderTree, Globe, Plus, Sparkles } from 'lucide-react' import { FolderTree, Globe, Plus, Search, Sparkles, X } from 'lucide-react'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { CategoryModal } from '../components/CategoryModal' import { CategoryModal } from '../components/CategoryModal'
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal' import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
@@ -38,6 +38,7 @@ import {
toCategoryFormPayload, toCategoryFormPayload,
updateProductCategory, updateProductCategory,
} from '../services/productCategoryService' } from '../services/productCategoryService'
import { filterCategoriesByQuery, getAncestorIds } from '../utils/categories'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { translateAllProductCategories } from '../services/translationService' import { translateAllProductCategories } from '../services/translationService'
import type { TranslationLocale } from '../types/translation' import type { TranslationLocale } from '../types/translation'
@@ -52,6 +53,7 @@ export function CategoriesPage() {
const { enabledModules } = useTenantBranding() const { enabledModules } = useTenantBranding()
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data') const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('') const [defaultParentId, setDefaultParentId] = useState('')
@@ -119,6 +121,32 @@ export function CategoriesPage() {
} }
}, []) }, [])
const filteredCategories = useMemo(
() => filterCategoriesByQuery(categories, searchQuery),
[categories, searchQuery],
)
useEffect(() => {
const query = searchQuery.trim()
if (!query) return
const normalized = query.toLowerCase()
const nextExpanded = new Set<string>()
for (const category of categories) {
const haystack = `${category.nameFa} ${category.nameEn}`.toLowerCase()
if (!haystack.includes(normalized)) continue
for (const ancestorId of getAncestorIds(categories, category.id)) {
nextExpanded.add(ancestorId)
}
}
if (nextExpanded.size === 0) return
setExpandedIds((prev) => {
const merged = new Set(prev)
for (const id of nextExpanded) merged.add(id)
return merged
})
}, [categories, searchQuery])
function openAiFabMenu() { function openAiFabMenu() {
if (aiMenuCloseTimerRef.current !== null) { if (aiMenuCloseTimerRef.current !== null) {
window.clearTimeout(aiMenuCloseTimerRef.current) window.clearTimeout(aiMenuCloseTimerRef.current)
@@ -549,30 +577,60 @@ export function CategoriesPage() {
onAction={() => openCreateModal()} onAction={() => openCreateModal()}
/> />
) : ( ) : (
<div className={styles.list}> <div className={styles.listSection}>
<CategoryTree <div className={styles.searchWrap}>
categories={categories} <Search size={20} className={styles.searchIcon} aria-hidden="true" />
expandedIds={expandedIds} <input
variationCounts={variationCountsMap()} type="search"
technicalFieldCounts={technicalFieldCountsMap()} className={styles.searchInput}
onToggle={toggleExpanded} value={searchQuery}
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))} onChange={(e) => setSearchQuery(e.target.value)}
onVariations={openVariations} placeholder={t('categories.searchPlaceholder')}
onTechnicalForm={openTechnicalForm} aria-label={t('categories.search')}
onEdit={(id) => { />
const category = categories.find((item) => item.id === id) {searchQuery ? (
if (category) openEditModal(category) <button
}} type="button"
onTranslate={(id) => { className={styles.searchClear}
if (!isMultiLanguageEnabled) return onClick={() => setSearchQuery('')}
const category = categories.find((item) => item.id === id) aria-label={t('categories.searchClear')}
if (category) setTranslateTarget(category) >
}} <X size={18} />
onRemove={(id) => { </button>
const category = categories.find((item) => item.id === id) ) : null}
if (category) setDeleteTarget(category) </div>
}}
/> {filteredCategories.length === 0 ? (
<div className={styles.list}>
<p className={styles.empty}>{t('categories.searchEmpty')}</p>
</div>
) : (
<div className={styles.list}>
<CategoryTree
categories={filteredCategories}
expandedIds={expandedIds}
variationCounts={variationCountsMap()}
technicalFieldCounts={technicalFieldCountsMap()}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onVariations={openVariations}
onTechnicalForm={openTechnicalForm}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onTranslate={(id) => {
if (!isMultiLanguageEnabled) return
const category = categories.find((item) => item.id === id)
if (category) setTranslateTarget(category)
}}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
}}
/>
</div>
)}
</div> </div>
)} )}

Some files were not shown because too many files have changed in this diff Show More