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
238 changed files with 20945 additions and 1970 deletions
+9
View File
@@ -24,6 +24,7 @@ import { StorePage } from './pages/StorePage'
import { StoreItemsPage } from './pages/StoreItemsPage'
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
import { CustomersPage } from './pages/CustomersPage'
import { RollCallPage } from './pages/RollCallPage'
import { InvoicesPage } from './pages/InvoicesPage'
import { IssueInvoicePage } from './pages/IssueInvoicePage'
import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage'
@@ -37,6 +38,9 @@ import { OrdersPage } from './pages/OrdersPage'
import { LoginPage } from './pages/LoginPage'
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
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 { BlogListPage } from './pages/BlogListPage'
import { AddNewBlogPage } from './pages/AddNewBlogPage'
@@ -109,9 +113,14 @@ function App() {
<Route path="store" element={<StorePage />} />
<Route path="store/items" element={<StoreItemsPage />} />
<Route path="store/orders" element={<OrdersPage />} />
<Route path="store/shipping" element={<StoreShippingPage />} />
<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="customers" element={<CustomersPage />} />
<Route path="roll-call" element={<RollCallPage />} />
<Route path="finance" element={<FinancePage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="invoices/new" element={<IssueInvoicePage />} />
@@ -1,3 +1,8 @@
.modal {
width: min(820px, calc(100vw - 32px));
max-width: 820px;
}
.headerBlock {
flex: 1;
min-width: 0;
@@ -10,6 +15,17 @@
line-height: 1.45;
}
.prompt {
width: 100%;
}
.promptHint {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.error {
margin: 0;
padding: 10px 12px;
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, 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 { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import { CategoryTreeSelect } from './CategoryTreeSelect'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddProductByAiModal.module.css'
@@ -26,26 +27,28 @@ export function AddProductByAiModal({
onClose,
onCreated,
}: AddProductByAiModalProps) {
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('')
const [name, setName] = useState('')
const [language, setLanguage] = useState<ProductAiLanguage>('en')
const [prompt, setPrompt] = useState('')
const [language, setLanguage] = useState<ProductAiLanguage>(locale === 'fa' ? 'fa' : 'en')
const [isLoadingCategories, setIsLoadingCategories] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = useMemo(() => flattenCategories(categories), [categories])
const promptDir = language === 'fa' ? 'rtl' : 'ltr'
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setCategoryId('')
setName('')
setLanguage('en')
setPrompt('')
setLanguage(locale === 'fa' ? 'fa' : 'en')
setError('')
} else if (mounted) {
setClosing(true)
@@ -55,7 +58,7 @@ export function AddProductByAiModal({
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
}, [open, mounted, locale])
useEffect(() => {
if (!open) return
@@ -86,7 +89,7 @@ export function AddProductByAiModal({
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load categories.')
setError(t('products.ai.loadCategoriesError'))
}
} finally {
setIsLoadingCategories(false)
@@ -95,7 +98,7 @@ export function AddProductByAiModal({
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!categoryId || !name.trim()) return
if (!categoryId || !prompt.trim()) return
setIsSubmitting(true)
setError('')
@@ -103,7 +106,7 @@ export function AddProductByAiModal({
try {
const result = await createProductByAi({
categoryId,
name: name.trim(),
name: prompt.trim(),
language,
})
showToast(result.message, 'success')
@@ -115,7 +118,7 @@ export function AddProductByAiModal({
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to create product with AI.')
setError(t('products.ai.createError'))
}
} finally {
setIsSubmitting(false)
@@ -131,7 +134,7 @@ export function AddProductByAiModal({
role="presentation"
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="add-product-ai-title"
@@ -139,19 +142,16 @@ export function AddProductByAiModal({
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h2 id="add-product-ai-title" className={modalStyles.title}>
Add Product by AI
{t('products.ai.title')}
</h2>
<p className={styles.subtitle}>
AI will generate product content and technical data. You can add images and
variations afterward.
</p>
<p className={styles.subtitle}>{t('products.ai.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label="Close"
aria-label={t('common.close')}
>
<X size={20} />
</button>
@@ -159,43 +159,45 @@ export function AddProductByAiModal({
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={modalStyles.field}>
<label>Category</label>
<SearchableSelect
options={categoryOptions}
<label htmlFor="ai-product-category">{t('products.ai.category')}</label>
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder="Search and select category..."
placeholder={t('products.ai.categoryPlaceholder')}
allowEmpty={false}
dropdownVariant="inFlow"
disabled={isSubmitting || isLoadingCategories}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="ai-product-name">Product name</label>
<input
id="ai-product-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
language === 'fa' ? 'نام محصول را وارد کنید' : 'Enter product name'
}
dir={language === 'fa' ? 'rtl' : 'ltr'}
className={language === 'fa' ? 'faText' : undefined}
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
<label htmlFor="ai-product-prompt">{t('products.ai.prompt')}</label>
<textarea
id="ai-product-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t('products.ai.promptPlaceholder')}
dir={promptDir}
className={`${aiStyles.aiPromptTextarea} ${styles.prompt}${language === 'fa' ? ' faText' : ''}`}
disabled={isSubmitting}
rows={14}
required
minLength={2}
lang={language === 'fa' ? 'fa' : 'en'}
/>
<p className={styles.promptHint}>{t('products.ai.promptHint')}</p>
</div>
<div className={`${modalStyles.field} ${fieldStyles.field}`}>
<label htmlFor="ai-product-language">Language</label>
<label htmlFor="ai-product-language">{t('products.ai.language')}</label>
<select
id="ai-product-language"
value={language}
onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)}
disabled={isSubmitting}
>
<option value="en">English</option>
<option value="fa">Persian (Farsi)</option>
<option value="en">{t('products.ai.language.en')}</option>
<option value="fa">{t('products.ai.language.fa')}</option>
</select>
</div>
@@ -208,24 +210,24 @@ export function AddProductByAiModal({
onClick={onClose}
disabled={isSubmitting}
>
Cancel
{t('common.cancel')}
</button>
<button
type="submit"
className={aiStyles.aiBtn}
disabled={
isSubmitting || isLoadingCategories || !categoryId || !name.trim()
isSubmitting || isLoadingCategories || !categoryId || !prompt.trim()
}
>
{isSubmitting ? (
<>
<Sparkles size={16} />
Creating...
{t('products.ai.creating')}
</>
) : (
<>
<Sparkles size={16} />
Create product
{t('products.ai.create')}
</>
)}
</button>
@@ -3,10 +3,10 @@ import { createPortal } from 'react-dom'
import { Plus, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import { COLOR_PRESETS } from '../data/colorPresets'
import { COLOR_PRESETS, getColorHex, normalizeColorName } from '../data/colorPresets'
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 styles from './VariationsModal.module.css'
@@ -18,15 +18,42 @@ interface AddVariationModalProps {
initial?: Variation | null
}
interface SelectedColor {
label: string
hex: string
}
const ANIMATION_MS = 220
const DEFAULT_CUSTOM_HEX = '#64748B'
function seedCustomValues(values: string[]) {
const trimmed = values.map((value) => value.trim()).filter(Boolean)
return trimmed.length ? trimmed : ['']
}
function colorLabelKey(name: string): BusinessMessageKey {
return `categories.variations.color.${name}` as BusinessMessageKey
function normalizeHex(value: string): string {
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) {
@@ -38,10 +65,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
const [closing, setClosing] = useState(false)
const [type, setType] = useState<VariationType>('color')
const [customName, setCustomName] = useState('')
const [selectedColors, setSelectedColors] = useState<string[]>([])
const [selectedColors, setSelectedColors] = useState<SelectedColor[]>([])
const [selectedSizes, setSelectedSizes] = useState<string[]>([])
const [customValues, setCustomValues] = useState<string[]>([''])
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 pendingFocusIndex = useRef<number | null>(null)
@@ -56,12 +86,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
setMounted(true)
setClosing(false)
setColorQuery('')
setCustomColorName('')
setCustomColorHex(DEFAULT_CUSTOM_HEX)
setCustomColorError('')
if (initial) {
setType(initial.type)
setCustomName(initial.type === 'custom' ? initial.name : '')
setSelectedColors(
initial.type === 'color' ? normalizeColorNames(initial.values) : [],
)
setSelectedColors(initial.type === 'color' ? colorsFromInitial(initial) : [])
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
setCustomValues(
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
@@ -92,26 +123,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
const selectedColorLabels = useMemo(
() => new Set(selectedColors.map((color) => color.label.toLowerCase())),
[selectedColors],
)
const filteredColors = useMemo(() => {
const q = colorQuery.trim().toLowerCase()
if (!q) return COLOR_PRESETS
return COLOR_PRESETS.filter((color) => {
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)
})
}, [colorQuery, t])
if (!mounted) return null
function toggleOption(value: string, selected: string[], setSelected: (v: string[]) => void) {
setSelected(
selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value],
function togglePresetColor(name: string, hex: string) {
setSelectedColors((prev) => {
const exists = prev.some((item) => item.label === name)
if (exists) return prev.filter((item) => item.label !== name)
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) {
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()
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') {
onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes })
} else {
@@ -224,27 +301,22 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
</div>
{selectedColors.length > 0 ? (
<div className={styles.selectedColors}>
{selectedColors.map((color) => {
const preset = COLOR_PRESETS.find((item) => item.name === color)
return (
<button
key={color}
type="button"
className={`${styles.chip} ${styles.chipSelected}`}
onClick={() =>
toggleOption(color, selectedColors, setSelectedColors)
}
>
<span
className={styles.colorSwatch}
style={{ backgroundColor: preset?.hex ?? '#94a3b8' }}
aria-hidden="true"
/>
{t(colorLabelKey(color))}
<X size={12} aria-hidden="true" />
</button>
)
})}
{selectedColors.map((color) => (
<button
key={color.label}
type="button"
className={`${styles.chip} ${styles.chipSelected}`}
onClick={() => removeSelectedColor(color.label)}
>
<span
className={styles.colorSwatch}
style={{ backgroundColor: color.hex }}
aria-hidden="true"
/>
{localizeVariationOptionLabel(color.label, t)}
<X size={12} aria-hidden="true" />
</button>
))}
</div>
) : null}
<div className={styles.chipGrid}>
@@ -256,22 +328,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
key={color.name}
type="button"
className={`${styles.chip} ${
selectedColors.includes(color.name) ? styles.chipSelected : ''
selectedColorLabels.has(color.name.toLowerCase())
? styles.chipSelected
: ''
}`}
onClick={() =>
toggleOption(color.name, selectedColors, setSelectedColors)
}
onClick={() => togglePresetColor(color.name, color.hex)}
>
<span
className={styles.colorSwatch}
style={{ backgroundColor: color.hex }}
aria-hidden="true"
/>
{t(colorLabelKey(color.name))}
{localizeVariationOptionLabel(color.name, t)}
</button>
))
)}
</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>
)}
@@ -284,7 +401,7 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
key={size}
type="button"
className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`}
onClick={() => toggleOption(size, selectedSizes, setSelectedSizes)}
onClick={() => toggleSize(size)}
>
{size}
</button>
+3 -1
View File
@@ -115,6 +115,7 @@ export function BrandModal({
onChange={setImage}
outputFormat="png"
accept="image/png"
minZoom={0.4}
uploadLabel={t('brands.modal.uploadLogo')}
hint={t('brands.modal.logoHint')}
changeLabel={t('brands.modal.changeLogo')}
@@ -142,7 +143,8 @@ export function BrandModal({
id="nameEn"
name="nameEn"
type="text"
dir={isFa ? 'rtl' : 'ltr'}
dir="ltr"
lang="en"
placeholder={t('brands.modal.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
@@ -1,5 +1,6 @@
.modal {
max-width: 560px;
width: min(820px, calc(100vw - 32px));
max-width: 820px;
}
.headerBlock {
@@ -12,7 +12,11 @@ export function buildCategoryAiPrompt(locale: 'fa' | 'en') {
if (locale === 'fa') {
return `یک درخت دسته‌بندی محصول کاربردی برای فروشگاه من بساز.
نام انگلیسی، نام فارسی با خط پارسی، و توضیح کوتاه انگلیسی را برای هر دسته اضافه کن.
برای هر دسته این موارد را مشخص کن:
- نام انگلیسی
- نام فارسی با خط پارسی
- توضیح کوتاه فارسی
۳ تا ۵ دسته اصلی با زیردسته‌های مرتبط بساز تا مرور برای خریداران راحت‌تر شود.`
}
@@ -136,7 +140,7 @@ export function CategoryAiPromptModal({
className={aiStyles.aiPromptTextarea}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={8}
rows={14}
disabled={isRunning}
required
lang={isFa ? 'fa' : 'en'}
@@ -93,6 +93,7 @@
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.field label {
+14 -9
View File
@@ -4,8 +4,8 @@ import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import { getDescendantIds } from '../utils/categories'
import { CategoryTreeSelect } from './CategoryTreeSelect'
import { SeoMetaFields } from './SeoMetaFields'
import styles from './CategoryModal.module.css'
@@ -88,9 +88,12 @@ export function CategoryModal({
})
}
const parentOptions = flattenCategories(
categories.filter((category) => category.id !== editingCategory?.id),
)
const parentCategories = editingCategory
? categories.filter(
(category) =>
!getDescendantIds(categories, editingCategory.id).includes(category.id),
)
: categories
return createPortal(
<div
@@ -123,12 +126,13 @@ export function CategoryModal({
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label>{t('categories.modal.parent')}</label>
<SearchableSelect
options={parentOptions}
<CategoryTreeSelect
categories={parentCategories}
value={parentId}
onChange={setParentId}
placeholder={t('categories.select.search')}
dropdownVariant="inFlow"
allowEmpty
disabled={isSubmitting}
/>
</div>
@@ -154,7 +158,8 @@ export function CategoryModal({
id="nameEn"
name="nameEn"
type="text"
dir={isFa ? 'rtl' : 'ltr'}
dir="ltr"
lang="en"
placeholder={t('categories.modal.nameEnPlaceholder')}
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
@@ -75,6 +75,8 @@
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
direction: ltr;
unicode-bidi: isolate;
}
.separator {
@@ -1,9 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { useMemo } from 'react'
import type { UserProductCategoryOption } from '../services/userProductsService'
import styles from './CategorySearchSelect.module.css'
import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
export interface FlatCategoryOption extends UserProductCategoryOption {
depth: number
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
dropdownVariant?: 'overlay' | 'inFlow'
}
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
if (isFa) {
return category.nameFa?.trim() || category.name
}
return category.name || category.nameFa?.trim() || ''
}
/** @deprecated Prefer CategoryTreeSelect; kept for user-product category API shape. */
export function flattenCategoryTree(
categories: UserProductCategoryOption[],
): FlatCategoryOption[] {
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
}
for (const list of byParent.values()) {
list.sort((a, b) => {
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
return aLabel
})
list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
}
const result: FlatCategoryOption[] = []
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
function walk(parentId: string | null, depth: number) {
const children = byParent.get(parentId) ?? []
for (const child of children) {
if (!ids.has(child.id)) continue
result.push({ ...child, depth })
walk(child.id, depth + 1)
}
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
walk(null, 0)
const rooted = new Set(result.map((item) => item.id))
for (const category of categories) {
if (category.parentId && !ids.has(category.parentId)) {
if (!result.some((item) => item.id === category.id)) {
result.push({ ...category, depth: 0 })
walk(category.id, 1)
}
if (!rooted.has(category.id)) {
result.push({ ...category, depth: 0 })
}
}
return result
}
function filterCategoryTree(
flat: FlatCategoryOption[],
query: string,
): FlatCategoryOption[] {
const q = query.trim().toLowerCase()
if (!q) return flat
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>
)
function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
return options.map((option) => ({
id: option.id,
parentId: option.parentId,
nameEn: option.name,
nameFa: option.nameFa?.trim() || '',
}))
}
/** User-product marketplace categories — same tree UX as CategoryTreeSelect. */
export function CategorySearchSelect({
options,
value,
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
allowEmpty = false,
dropdownVariant = 'overlay',
}: CategorySearchSelectProps) {
const t = useT()
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(' ')
const categories = useMemo(() => toTreeItems(options), [options])
return (
<div
className={styles.wrapper}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div
className={[
styles.inputWrap,
open ? styles.inputWrapOpen : '',
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>
<CategoryTreeSelect
id={id}
categories={categories}
value={value}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
allowEmpty={allowEmpty}
dropdownVariant={dropdownVariant}
/>
)
}
@@ -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;
}
.wrapperOpen {
z-index: 130;
}
.inputWrap {
display: flex;
align-items: center;
@@ -75,14 +79,8 @@
}
.panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 120;
max-height: min(320px, 50vh);
overflow-y: auto;
margin-top: 0;
padding: 4px;
background: var(--elevated-surface);
border: 1px solid var(--glass-border);
@@ -90,6 +88,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 {
position: static;
margin-top: 6px;
@@ -98,6 +104,29 @@
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 {
display: flex;
align-items: center;
@@ -135,7 +164,7 @@
background: transparent;
cursor: pointer;
outline: none;
transition: background 0.15s, color 0.15s, transform 0.2s;
transition: background 0.15s, color 0.15s;
}
.toggleBtn:hover {
@@ -149,23 +178,6 @@
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 {
flex: 1;
min-width: 0;
@@ -1,25 +1,54 @@
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 { useT } from '../i18n/useT'
import type { Category } from '../types/category'
import { getAncestorIds, getChildren } from '../utils/categories'
import { Tooltip } from './Tooltip'
import treeStyles from './CategoryTree.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 {
categories: Category[]
categories: CategoryTreeItem[]
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
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'
}
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) {
return category.nameFa
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
@@ -30,7 +59,7 @@ function categoryLabel(category: Category, isFa: boolean) {
: category.nameFa
}
function categoryMatches(category: Category, query: string) {
function categoryMatches(category: CategoryTreeItem, query: string) {
const q = query.trim().toLowerCase()
if (!q) return true
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()
if (!q) return null
@@ -67,7 +99,7 @@ function TreeNodes({
onToggle,
onSelect,
}: {
categories: Category[]
categories: CategoryTreeItem[]
parentId: string | null
value: string
expandedIds: Set<string>
@@ -114,9 +146,7 @@ function TreeNodes({
aria-expanded={expanded}
onClick={() => onToggle(category.id)}
>
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
<ChevronRight size={16} />
</span>
{expanded ? <Minus size={14} /> : <Plus size={14} />}
</button>
</Tooltip>
) : (
@@ -127,9 +157,19 @@ function TreeNodes({
className={styles.selectBtn}
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 ? (
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
<span
className={styles.nameSecondary}
dir={isFa ? 'ltr' : 'rtl'}
lang={isFa ? 'en' : 'fa'}
>
· {secondary}
</span>
) : null}
@@ -165,6 +205,8 @@ export function CategoryTreeSelect({
placeholder,
disabled = false,
id,
allowEmpty = false,
emptyLabel,
dropdownVariant = 'overlay',
}: CategoryTreeSelectProps) {
const t = useT()
@@ -178,6 +220,7 @@ export function CategoryTreeSelect({
const selected = categories.find((category) => category.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('categories.select.search')
const emptyOptionLabel = emptyLabel ?? t('categories.select.none')
const visibleIds = useMemo(
() => visibleIdsForQuery(categories, query),
@@ -213,17 +256,17 @@ export function CategoryTreeSelect({
})
}, [categories, value])
function handleToggle(id: string) {
function handleToggle(toggleId: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
if (next.has(toggleId)) next.delete(toggleId)
else next.add(toggleId)
return next
})
}
function selectOption(id: string) {
onChange(id)
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
@@ -232,7 +275,11 @@ export function CategoryTreeSelect({
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
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 : ''}`}>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
@@ -254,7 +301,7 @@ export function CategoryTreeSelect({
aria-expanded={open}
aria-autocomplete="list"
/>
{value && !open && !disabled ? (
{allowEmpty && value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
@@ -275,11 +322,20 @@ export function CategoryTreeSelect({
<div
className={[
styles.panel,
dropdownVariant === 'inFlow' ? styles.panelInFlow : '',
dropdownVariant === 'inFlow' ? styles.panelInFlow : styles.panelOverlay,
]
.filter(Boolean)
.join(' ')}
>
{allowEmpty ? (
<button
type="button"
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
onClick={() => selectOption('')}
>
{emptyOptionLabel}
</button>
) : null}
{!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p>
) : (
@@ -1,16 +1,16 @@
.modal {
width: min(560px, calc(100vw - 32px));
width: min(720px, calc(100vw - 32px));
}
.body {
padding: 0 20px 20px;
padding: 4px 24px 24px;
}
.metaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin: 0 0 18px;
gap: 16px 24px;
margin: 0 0 20px;
}
.metaItem {
@@ -18,44 +18,55 @@
}
.metaItem dt {
margin: 0 0 4px;
font-size: 11px;
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted);
}
.metaItem dd {
margin: 0;
font-size: 14px;
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
}
.ltrValue {
direction: ltr;
unicode-bidi: isolate;
text-align: start;
}
.sentAt {
display: inline-block;
direction: ltr;
unicode-bidi: isolate;
}
.messageBlock {
padding-top: 16px;
padding: 18px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.messageLabel {
.messageTitle {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
font-size: 16px;
font-weight: 700;
line-height: 1.4;
color: var(--text-primary);
}
.messageText {
margin: 0;
font-size: 14px;
line-height: 1.65;
color: var(--text-primary);
line-height: 1.75;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 520px) {
@media (max-width: 560px) {
.metaGrid {
grid-template-columns: 1fr;
}
@@ -21,8 +21,15 @@ function formatDateTime(value: string, locale: 'en' | 'fa') {
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return {
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
date: d.toLocaleDateString(intlLocale, {
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"
>
<div className={modalStyles.header}>
<div>
<h2 id="contact-submission-title" className={modalStyles.title}>
{t('website.contact.detailTitle')}
</h2>
<p className={modalStyles.subtitle}>{submission.title}</p>
</div>
<h2 id="contact-submission-title" className={modalStyles.title}>
{t('website.contact.detailTitle')}
</h2>
<button
type="button"
className={modalStyles.closeBtn}
@@ -100,26 +104,26 @@ export function ContactSubmissionDetailModal({
</div>
<div className={styles.metaItem}>
<dt>{t('website.contact.col.email')}</dt>
<dd>{submission.email ?? '—'}</dd>
<dd className={styles.ltrValue}>{submission.email ?? '—'}</dd>
</div>
<div className={styles.metaItem}>
<dt>{t('website.contact.col.cell')}</dt>
<dd>
<dd className={styles.ltrValue}>
{submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'}
</dd>
</div>
<div className={styles.metaItem}>
<dt>{t('website.contact.col.date')}</dt>
<dd>{date}</dd>
</div>
<div className={styles.metaItem}>
<dt>{t('website.contact.col.time')}</dt>
<dd>{time || '—'}</dd>
<dt>{t('website.contact.detailSentAt')}</dt>
<dd>
<span className={styles.sentAt} dir="ltr">
{time ? `${date} · ${time}` : date}
</span>
</dd>
</div>
</dl>
<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>
</div>
</div>
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { ProductVariant } from '../types/productVariant'
import { buildVariantLabel } from '../types/productVariant'
import type { ProductVariantAttribute } from '../services/productVariantService'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import styles from './VariationsModal.module.css'
interface CreateProductVariantModalProps {
@@ -27,6 +29,7 @@ export function CreateProductVariantModal({
onClose,
onSubmit,
}: CreateProductVariantModalProps) {
const t = useT()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [variationId, setVariationId] = useState('')
@@ -150,7 +153,7 @@ export function CreateProductVariantModal({
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
{localizeVariationOptionLabel(option.label, t)}
</option>
))}
</select>
@@ -155,6 +155,31 @@
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 {
opacity: 0.55;
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 { PackagePlus, Plus, 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 { listAllProducts } from '../services/productService'
import { listAllProducts, listProducts } from '../services/productService'
import {
getProductVariationValues,
type ProductVariationSelection,
@@ -22,11 +22,15 @@ import {
getVariationOptions,
type StoreItemDraftRow,
} from '../utils/storeItemRows'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import {
ConfirmBulkAddStoreItemsModal,
type BulkAddResult,
} from './ConfirmBulkAddStoreItemsModal'
import { ProductSearchSelect } from './ProductSearchSelect'
import {
ProductSearchSelect,
type ProductSearchOption,
} from './ProductSearchSelect'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
import rowStyles from './CreateStoreItemsModal.module.css'
@@ -40,6 +44,7 @@ interface CreateStoreItemsModalProps {
}
const ANIMATION_MS = 220
const PRODUCT_SEARCH_PAGE_SIZE = 20
export function CreateStoreItemsModal({
open,
@@ -53,20 +58,29 @@ export function CreateStoreItemsModal({
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
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 [isUsed, setIsUsed] = useState(false)
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
const [isSearchingProducts, setIsSearchingProducts] = useState(false)
const [isLoadingVariations, setIsLoadingVariations] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isBulkCreating, setIsBulkCreating] = useState(false)
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkResult, setBulkResult] = useState<BulkAddResult | null>(null)
const [error, setError] = useState('')
const searchControllerRef = useRef<AbortController | null>(null)
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(
() => variations.filter((variation) => getVariationOptions(variation).length > 0),
[variations],
@@ -118,6 +132,9 @@ export function CreateStoreItemsModal({
setMounted(false)
setClosing(false)
setProductId('')
setIsUsed(false)
setSelectedProduct(null)
setSearchResults([])
setVariations([])
setRows([])
setError('')
@@ -129,13 +146,54 @@ export function CreateStoreItemsModal({
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadProducts(controller.signal)
return () => controller.abort()
if (!open) {
searchControllerRef.current?.abort()
return
}
}, [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(() => {
if (!productId) {
setVariations([])
@@ -148,31 +206,6 @@ export function CreateStoreItemsModal({
return () => controller.abort()
}, [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) {
setIsLoadingVariations(true)
setError('')
@@ -213,6 +246,15 @@ export function CreateStoreItemsModal({
return
}
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>) {
@@ -281,6 +323,7 @@ export function CreateStoreItemsModal({
try {
await batchCreateStoreItems({
productId,
isUsed,
items: buildPayload(),
})
onCreated?.()
@@ -297,7 +340,7 @@ export function CreateStoreItemsModal({
}
function openBulkConfirm() {
if (busy || isLoadingProducts || products.length === 0) return
if (busy) return
setBulkResult(null)
setBulkConfirmOpen(true)
}
@@ -315,7 +358,7 @@ export function CreateStoreItemsModal({
}
async function handleAddAllProducts() {
if (isBulkCreating || isLoadingProducts) return
if (isBulkCreating) return
setIsBulkCreating(true)
setError('')
@@ -327,14 +370,11 @@ export function CreateStoreItemsModal({
const skip = new Set(existingProductIds)
try {
const catalog =
products.length > 0
? products
: (await listAllProducts()).map((item) => ({
id: item.id,
title: item.title,
nameFa: item.nameFa,
}))
const catalog = (await listAllProducts()).map((item) => ({
id: item.id,
title: item.title,
nameFa: item.nameFa,
}))
for (const product of catalog) {
if (skip.has(product.id)) {
@@ -412,15 +452,28 @@ export function CreateStoreItemsModal({
<div className={modalStyles.field}>
<ProductSearchSelect
id="store-item-product"
options={products}
options={productOptions}
value={productId}
onChange={handleProductChange}
onQueryChange={(query) => void searchProducts(query)}
isSearching={isSearchingProducts}
placeholder={t('storeItems.create.productPlaceholder')}
disabled={isLoadingProducts || busy}
disabled={busy}
aria-label={t('storeItems.create.productPlaceholder')}
/>
</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 && (
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
)}
@@ -462,7 +515,7 @@ export function CreateStoreItemsModal({
<option value="">—</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
{localizeVariationOptionLabel(option.label, t)}
</option>
))}
</select>
@@ -537,7 +590,7 @@ export function CreateStoreItemsModal({
type="button"
className={rowStyles.footerBulkBtn}
onClick={openBulkConfirm}
disabled={busy || isLoadingProducts || products.length === 0}
disabled={busy}
>
<PackagePlus size={16} />
{t('storeItems.create.addAllProducts')}
@@ -140,7 +140,7 @@
}
.chartDay {
gap: 1px;
gap: 2px;
}
.chartMonth {
@@ -172,19 +172,26 @@
justify-content: center;
width: 100%;
height: 180px;
gap: 2px;
gap: 3px;
}
.barWrap {
position: relative;
display: flex;
flex: 0 0 auto;
flex: 1 1 0;
align-items: flex-end;
justify-content: center;
width: 100%;
min-width: 0;
height: 100%;
z-index: 1;
}
.chartMonth .barWrap {
flex: 0 0 auto;
width: auto;
}
.barWrap:hover,
.barWrap:focus-within {
z-index: 1000;
@@ -219,24 +226,24 @@
border-radius: 6px;
box-shadow: 0 6px 18px rgba(31, 38, 135, 0.18);
z-index: 10000;
unicode-bidi: isolate;
}
.bar {
position: relative;
flex: 1 1 0;
width: 100%;
max-width: 8px;
min-width: 2px;
flex: none;
width: 40%;
max-width: 7px;
min-width: 4px;
border-radius: 3px 3px 1px 1px;
transition: height 0.3s ease;
cursor: default;
}
.chartMonth .bar {
flex: none;
width: 10px;
width: 12px;
max-width: none;
min-width: 10px;
min-width: 12px;
border-radius: 4px 4px 2px 2px;
}
@@ -246,8 +253,8 @@
}
.barMonth {
width: 10px;
max-width: 10px;
width: 12px;
max-width: 12px;
}
.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 {
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
day: 'numeric',
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). */
function formatRangeDate(dateKey: string, locale: string): string {
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
@@ -236,12 +240,12 @@ export function DailyActivityChart({
const primaryTip = t(primaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.primary,
count: formatTooltipCount(item.primary, locale),
})
const secondaryTip = t(secondaryBarTitleKey, {
day: item.label,
month: item.label,
count: item.secondary,
count: formatTooltipCount(item.secondary, locale),
})
return (
@@ -254,7 +258,13 @@ export function DailyActivityChart({
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>
{showSecondary ? (
@@ -265,7 +275,13 @@ export function DailyActivityChart({
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>
) : null}
@@ -289,11 +305,11 @@ export function DailyActivityChart({
const tooltipDay = formatDayTooltip(item.date, locale)
const primaryTip = t(primaryBarTitleKey, {
day: tooltipDay,
count: item.count,
count: formatTooltipCount(item.count, locale),
})
const secondaryTip = t(secondaryBarTitleKey, {
day: tooltipDay,
count: secondaryCount,
count: formatTooltipCount(secondaryCount, locale),
})
return (
@@ -306,7 +322,13 @@ export function DailyActivityChart({
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>
{showSecondary ? (
@@ -317,7 +339,13 @@ export function DailyActivityChart({
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>
) : null}
@@ -19,6 +19,7 @@ import {
getVariationOptions,
type StoreItemDraftRow,
} from '../utils/storeItemRows'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
import rowStyles from './CreateStoreItemsModal.module.css'
@@ -72,6 +73,7 @@ export function EditStoreItemsModal({
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
const [removedIds, setRemovedIds] = useState<string[]>([])
const [isUsed, setIsUsed] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
@@ -123,6 +125,7 @@ export function EditStoreItemsModal({
setClosing(false)
setRemovedIds([])
setRows(itemsToRows(items))
setIsUsed(items.some((item) => item.isUsed))
setError('')
const controller = new AbortController()
void loadVariations(productId, controller.signal)
@@ -223,6 +226,7 @@ export function EditStoreItemsModal({
try {
const data = await syncProductStoreItems({
productId,
isUsed,
items: rows.map((row) => ({
id: row.storeItemId,
selections: formVariations.flatMap((variation) => {
@@ -294,6 +298,17 @@ export function EditStoreItemsModal({
<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
className={rowStyles.itemRowHeader}
style={{ gridTemplateColumns: gridTemplate }}
@@ -329,7 +344,7 @@ export function EditStoreItemsModal({
<option value="">—</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
{localizeVariationOptionLabel(option.label, t)}
</option>
))}
</select>
@@ -274,7 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
value={categoryId}
onChange={setCategoryId}
placeholder={t('website.linkGenerator.pickCategory')}
dropdownVariant="inFlow"
allowEmpty={false}
/>
)}
</div>
@@ -98,6 +98,16 @@
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 {
padding: 14px 16px;
display: flex;
@@ -186,11 +196,6 @@
align-self: stretch;
}
.compact .changeBtn {
align-self: stretch;
justify-content: center;
}
.compact .uploadZone {
gap: 4px;
font-size: 11px;
@@ -202,8 +207,36 @@
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 {
display: flex;
align-self: stretch;
justify-content: center;
padding: 6px 10px;
font-size: 11px;
}
+55 -5
View File
@@ -10,7 +10,16 @@ interface ImageCropperProps {
onChange: (value: string | null) => void
/** Crop aspect ratio. Defaults to square (1). */
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'
/** 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
uploadLabel?: string
hint?: string
@@ -19,11 +28,25 @@ interface ImageCropperProps {
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({
value,
onChange,
aspect = 1,
outputFormat = 'jpeg',
outputFormat,
fillColor,
minZoom = 0.4,
maxZoom = 3,
accept = 'image/*',
uploadLabel,
hint,
@@ -35,10 +58,17 @@ export function ImageCropper({
const resolvedHint = hint ?? t('products.form.thumbnailHint')
const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange')
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [sourceIsPng, setSourceIsPng] = useState(false)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
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(() => {
setCrop({ x: 0, y: 0 })
setZoom(1)
@@ -52,6 +82,8 @@ export function ImageCropper({
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const png = isPngSource(file)
setSourceIsPng(png)
const reader = new FileReader()
reader.onload = () => {
setImageSrc(reader.result as string)
@@ -65,7 +97,10 @@ export function ImageCropper({
async function applyCrop() {
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)
setImageSrc(null)
setZoom(1)
@@ -101,6 +136,10 @@ export function ImageCropper({
alignSelf: 'center',
}
const cropSurfaceStyle: React.CSSProperties = useTransparentPad
? { ...frameStyle }
: { ...frameStyle, background: resolvedFillColor ?? '#1e293b' }
return (
<div className={`${styles.wrapper} ${compact ? styles.compact : ''}`}>
{value && !imageSrc && (
@@ -128,16 +167,27 @@ export function ImageCropper({
{imageSrc && (
<div className={styles.cropPanel}>
<div className={styles.cropArea} style={frameStyle}>
<div
className={`${styles.cropArea} ${useTransparentPad ? styles.cropAreaTransparent : ''}`}
style={cropSurfaceStyle}
>
<Cropper
key={String(aspect)}
image={imageSrc}
crop={crop}
zoom={zoom}
minZoom={minZoom}
maxZoom={maxZoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
restrictPosition={minZoom >= 1}
style={{
containerStyle: useTransparentPad
? { background: 'transparent' }
: { background: resolvedFillColor ?? '#1e293b' },
}}
/>
</div>
<div className={styles.cropControls}>
@@ -145,8 +195,8 @@ export function ImageCropper({
{t('products.form.zoom')}
<input
type="range"
min={1}
max={3}
min={minZoom}
max={maxZoom}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
@@ -135,6 +135,15 @@
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.blockHeaderActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.blockTitle {
@@ -1,14 +1,16 @@
import { Pencil, Plus, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { useT } from '../i18n/useT'
import type { InvoiceItemTemplate } from '../types/invoice'
import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
import { formatIrtInput } from '../utils/irtPrice'
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
import {
draftAccountFromAccountTemplate,
draftItemFromItemTemplate,
emptyDraftAccount,
emptyDraftItem,
emptyDraftKeyPoint,
emptyDraftPaymentMethod,
} from '../utils/invoiceDraft'
import formStyles from './InvoiceForm.module.css'
import styles from './InvoiceDraftFields.module.css'
@@ -19,32 +21,50 @@ function focusKeyPointInput(index: number) {
el?.select()
}
function focusPaymentMethodInput(index: number) {
const el = document.querySelector<HTMLInputElement>(`input[data-payment-method-index="${index}"]`)
el?.focus()
el?.select()
}
type Props = {
itemTemplates: InvoiceItemTemplate[]
accountTemplates: InvoiceAccountTemplate[]
items: DraftLineItem[]
keyPoints: DraftKeyPoint[]
paymentMethods: DraftPaymentMethod[]
accounts: DraftAccount[]
selectedItemTemplateId: string
onSelectedItemTemplateId: (id: string) => void
selectedAccountTemplateId: string
onSelectedAccountTemplateId: (id: string) => void
onItemsChange: (items: DraftLineItem[]) => void
onKeyPointsChange: (points: DraftKeyPoint[]) => void
onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
onAccountsChange: (accounts: DraftAccount[]) => void
showItemTemplatePicker?: boolean
showAccountTemplatePicker?: boolean
/** Rendered after line items (before key points / accounts), e.g. totals row. */
afterItems?: ReactNode
}
export function InvoiceDraftFields({
itemTemplates,
accountTemplates,
items,
keyPoints,
paymentMethods,
accounts,
selectedItemTemplateId,
onSelectedItemTemplateId,
selectedAccountTemplateId,
onSelectedAccountTemplateId,
onItemsChange,
onKeyPointsChange,
onPaymentMethodsChange,
onAccountsChange,
showItemTemplatePicker = true,
showAccountTemplatePicker = true,
afterItems,
}: Props) {
const t = useT()
@@ -70,6 +90,13 @@ export function InvoiceDraftFields({
onSelectedItemTemplateId('')
}
function addFromAccountTemplate() {
const template = accountTemplates.find((entry) => entry.id === selectedAccountTemplateId)
if (!template) return
onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)])
onSelectedAccountTemplateId('')
}
return (
<>
<div className={styles.itemsStack}>
@@ -250,16 +277,107 @@ export function InvoiceDraftFields({
<div className={styles.blockSection}>
<div className={styles.blockHeader}>
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
<h4 className={styles.blockTitle}>{t('invoiceDraft.paymentMethods')}</h4>
<button
type="button"
className={`${formStyles.btn} ${formStyles.btnGhost}`}
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
>
<Plus size={16} />
{t('invoiceDraft.addAccount')}
{t('invoiceDraft.addPaymentMethod')}
</button>
</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 ? (
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p>
) : (
@@ -13,7 +13,7 @@
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
text-align: left;
text-align: start;
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
@@ -64,19 +64,37 @@
}
.dropdown {
position: static;
margin-top: 4px;
z-index: auto;
position: fixed;
z-index: 1200;
display: flex;
flex-direction: column;
max-height: none;
overflow: visible;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
overflow: hidden;
isolation: isolate;
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
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 {
@@ -110,6 +128,8 @@
}
.optionsList {
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0;
padding: 6px;
@@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, Search } from 'lucide-react'
import styles from './MultiSelectDropdown.module.css'
@@ -6,6 +7,8 @@ export interface MultiSelectOption<T extends string | number = number> {
value: T
label: string
depth?: number
/** Extra text matched by search (e.g. EN+FA names, tags). */
keywords?: string
}
interface MultiSelectDropdownProps<T extends string | number = number> {
@@ -19,6 +22,13 @@ interface MultiSelectDropdownProps<T extends string | number = number> {
id?: string
}
type MenuPosition = {
top: number
left: number
width: number
maxHeight: number
}
export function MultiSelectDropdown<T extends string | number = number>({
options,
value,
@@ -32,8 +42,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [triggerLabel, setTriggerLabel] = useState(placeholder)
const [menuPos, setMenuPos] = useState<MenuPosition | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLSpanElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -45,6 +57,29 @@ export function MultiSelectDropdown<T extends string | number = number>({
[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(() => {
const trigger = triggerRef.current
const measure = measureRef.current
@@ -74,9 +109,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
const style = getComputedStyle(triggerEl)
measureEl.style.font = style.font
const padding =
parseFloat(style.paddingLeft) +
parseFloat(style.paddingRight)
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
const gap = parseFloat(style.columnGap || style.gap || '10')
const reservedWidth = padding + gap + 16 + (searchable ? gap + 16 : 0)
const availableWidth = triggerEl.clientWidth - reservedWidth
@@ -103,9 +136,32 @@ export function MultiSelectDropdown<T extends string | number = number>({
const filteredOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
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])
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(() => {
if (!open) {
setSearchQuery('')
@@ -113,9 +169,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
const target = e.target as Node
if (containerRef.current?.contains(target)) return
if (menuRef.current?.contains(target)) return
setOpen(false)
}
function handleEscape(e: KeyboardEvent) {
@@ -134,7 +191,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
if (open && searchable) {
searchInputRef.current?.focus()
}
}, [open, searchable])
}, [open, searchable, menuPos])
function toggleOption(optionValue: T) {
if (value.includes(optionValue)) {
@@ -144,6 +201,68 @@ export function MultiSelectDropdown<T extends string | number = number>({
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 (
<div className={styles.wrapper} ref={containerRef}>
<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>
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
</button>
{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>
)}
{menu}
</div>
)
}
@@ -1,5 +1,6 @@
.modalWide {
max-width: 720px;
max-width: 960px;
width: min(960px, calc(100vw - 32px));
}
.body {
@@ -42,7 +43,7 @@
.itemRow {
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;
gap: 10px;
padding: 10px 12px;
@@ -105,7 +106,7 @@
.tableHead {
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;
padding: 0 12px 2px;
font-size: 11px;
@@ -140,6 +141,126 @@
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 {
font-size: 13px;
color: var(--text-muted);
+144 -12
View File
@@ -5,6 +5,7 @@ import { useT } from '../i18n/useT'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
import { formatIrtPrice } from '../utils/irtPrice'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import modalStyles from './VariationsModal.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)
}
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) {
const t = useT()
const [mounted, setMounted] = useState(open)
@@ -61,6 +112,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
if (!mounted || !order) return null
const itemCount = totalQuantity(order)
const shippingInfo = parseOrderShipping(order.shippingAddress)
return createPortal(
<div
@@ -105,6 +157,44 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</span>
</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 ? (
<p className={styles.empty}>{t('orderItems.empty')}</p>
) : (
@@ -117,7 +207,15 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
<span>{t('orderItems.lineTotal')}</span>
</div>
<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}>
<div className={styles.itemThumb}>
{item.productImage ? (
@@ -127,7 +225,18 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
)}
</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>
{item.variantSku && (
<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.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
</li>
))}
)
})}
</ul>
</div>
)}
<div className={styles.summary}>
<span className={styles.summaryLabel}>
{t('orderItems.summary', {
count: itemCount,
itemsLabel:
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
})}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
<div className={styles.totalsBlock}>
<div className={styles.totalsRow}>
<span>{t('orderItems.subtotal')}</span>
<span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
</div>
<div className={styles.totalsRow}>
<span>{t('orderItems.shipping')}</span>
<span className={styles.totalsValue}>
{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 className={modalStyles.actions}>
@@ -5,6 +5,17 @@
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 {
display: flex;
align-items: center;
@@ -6,7 +6,10 @@ import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
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 modalStyles from './VariationsModal.module.css'
import styles from './OrderStepModal.module.css'
@@ -71,6 +74,7 @@ export function OrderStepModal({
async function handleSelectStep(stepId: string) {
if (!order || savingStepId) return
if (isSystemPaymentProcessStep(stepId)) return
if (stepId === order.processStepId) {
onClose()
@@ -104,6 +108,8 @@ export function OrderStepModal({
order.processStepLabel?.trim() ||
currentStep?.label ||
order.processStepId
const selectableSteps = steps.filter((step) => !isSystemPaymentProcessStep(step.id))
const paymentIsCurrent = isSystemPaymentProcessStep(order.processStepId)
return createPortal(
<div
@@ -151,8 +157,12 @@ export function OrderStepModal({
</div>
)}
{paymentIsCurrent && (
<p className={styles.paymentHint}>{t('orderStep.paymentHint')}</p>
)}
<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 saving = savingStepId === step.id
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
@@ -178,7 +188,7 @@ export function OrderStepModal({
})}
</div>
{!steps.length && (
{!selectableSteps.length && (
<p className={styles.emptyText}>{t('orderStep.empty')}</p>
)}
</div>
@@ -49,14 +49,34 @@
.txMeta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 16px;
gap: 8px 16px;
font-size: 12px;
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 {
color: var(--text-primary);
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 {
@@ -96,7 +96,12 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
if (!mounted || !order) return null
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'),
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) {
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 })
}
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) {
rows.push({ label: t('orderTransactions.gateway'), value: transaction.gatewayType })
if (transaction.type === 'e_payment_gate') {
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) {
rows.push({ label: t('orderTransactions.notes'), value: transaction.notes })
@@ -176,13 +215,15 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
<div className={styles.txMeta}>
{details.map((detail) => (
<div key={detail.label} className={styles.txMetaItem}>
{detail.label}:{' '}
<span className={styles.txMetaLabel}>{detail.label}</span>
{detail.isStatus ? (
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
{detail.value}
</span>
) : (
<strong>{detail.value}</strong>
<strong className={detail.mono ? styles.txMono : undefined}>
{detail.value}
</strong>
)}
</div>
))}
@@ -4,6 +4,42 @@
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 {
display: flex;
align-items: flex-start;
@@ -76,7 +76,7 @@ export function PortfolioCard({
</h3>
) : null}
{titleEn ? (
<p className={cardStyles.titleEn} lang="en" dir={isFa ? 'rtl' : 'ltr'}>
<p className={cardStyles.titleEn} lang="en" dir="ltr">
{titleEn}
</p>
) : null}
@@ -155,6 +155,10 @@
border-radius: 50px;
direction: rtl;
unicode-bidi: plaintext;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.bodyLtr {
@@ -202,7 +206,8 @@
.variantBadge,
.commentBadge,
.translateBadge {
.translateBadge,
.actionBadge {
position: absolute;
top: -4px;
right: -4px;
@@ -241,3 +246,12 @@
background: rgba(239, 68, 68, 0.1);
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 {
Pencil,
Globe,
Info,
Trash2,
Layers,
MessageSquare,
ClipboardList,
ImageOff,
Info,
MessageSquare,
Search,
type LucideIcon,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Product } from '../types/product'
@@ -17,8 +19,7 @@ import { Tooltip } from './Tooltip'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { hasBusinessModule } from '../utils/businessModules'
import { formatProductCategoryChip } from '../services/productService'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { ProductCardOverflowMenu } from './ProductCardOverflowMenu'
import styles from './ProductCard.module.css'
interface ProductCardProps {
@@ -29,11 +30,21 @@ interface ProductCardProps {
onTranslate: (id: string) => void
onQuickInfo: (id: string) => void
onComments: (id: string) => void
onSeo: (id: string) => void
onTechnicalInfo: (id: string) => void
onRemove: (id: string) => void
onVariations: (id: string) => void
}
type CardAction = {
key: string
label: string
icon: LucideIcon
onClick: () => void
danger?: boolean
badge?: number
}
export function ProductCard({
product,
commentCount,
@@ -42,6 +53,7 @@ export function ProductCard({
onTranslate,
onQuickInfo,
onComments,
onSeo,
onTechnicalInfo,
onRemove,
onVariations,
@@ -59,7 +71,6 @@ export function ProductCard({
: ''
: product.nameFa
const categoryLabel = formatProductCategoryChip(product, isFa)
const websiteUrl = buildWebsiteContentUrl('product', product)
const imageSrc = product.image?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
@@ -68,6 +79,70 @@ export function ProductCard({
setImageFailed(false)
}, [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 (
<article className={styles.card}>
<Link to={`/products/detail/${product.id}`} className={styles.clickable}>
@@ -103,89 +178,35 @@ export function ProductCard({
</>
)}
{categoryLabel ? (
<span className={styles.categoryChip}>{categoryLabel}</span>
<span className={styles.categoryChip} title={categoryLabel}>
{categoryLabel}
</span>
) : null}
</div>
</Link>
<div className={styles.controls}>
<Tooltip label={t('products.card.edit')}>
<button type="button" onClick={() => onEdit(product.id)} aria-label={t('products.card.edit')}>
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
{showTranslationControls ? (
<Tooltip label={t('products.card.translate')}>
<button
type="button"
className={styles.iconBtn}
onClick={() => onTranslate(product.id)}
aria-label={
translationCount > 0
? `${t('products.card.translate')} (${translationCount})`
: t('products.card.translate')
}
>
<Globe size={16} />
{translationCount > 0 && (
<span className={styles.translateBadge}>{translationCount}</span>
)}
</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>
{primaryActions.map((action) => {
const Icon = action.icon
return (
<Tooltip key={action.key} label={action.label}>
<button
type="button"
className={styles.iconBtn}
onClick={action.onClick}
aria-label={
action.badge != null ? `${action.label} (${action.badge})` : action.label
}
>
<Icon size={16} />
{action.badge != null ? (
<span className={styles.actionBadge}>{action.badge}</span>
) : null}
</button>
</Tooltip>
)
})}
<ProductCardOverflowMenu productName={primaryName} items={menuActions} />
</div>
</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 : [])
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') {
return item.optionLabel ?? (item.value ? String(item.value) : '—')
}
@@ -239,7 +239,7 @@ export function ProductFieldAiPromptModal({
className={aiStyles.aiPromptTextarea}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={10}
rows={14}
disabled={isRunning}
required
lang={isFa ? 'fa' : 'en'}
@@ -1,9 +1,11 @@
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 { useT } from '../i18n/useT'
import styles from './SearchableSelect.module.css'
export const PRODUCT_SEARCH_DEBOUNCE_MS = 350
export interface ProductSearchOption {
id: string
title: string
@@ -18,6 +20,13 @@ interface ProductSearchSelectProps {
disabled?: boolean
id?: string
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
}
@@ -36,6 +45,9 @@ export function ProductSearchSelect({
disabled = false,
id,
minSearchLength = 3,
isSearching = false,
onQueryChange,
debounceMs = PRODUCT_SEARCH_DEBOUNCE_MS,
'aria-label': ariaLabel,
}: ProductSearchSelectProps) {
const t = useT()
@@ -45,20 +57,35 @@ export function ProductSearchSelect({
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const onQueryChangeRef = useRef(onQueryChange)
onQueryChangeRef.current = onQueryChange
const selected = options.find((option) => option.id === value)
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 trimmed = query.trim()
if (trimmed.length < minSearchLength) return []
const q = trimmed.toLowerCase()
if (!canSearch) return []
if (onQueryChange) return options
const q = trimmedQuery.toLowerCase()
return options.filter(
(option) =>
option.title.toLowerCase().includes(q) ||
option.nameFa.includes(trimmed),
option.nameFa.includes(trimmedQuery),
)
}, [options, query, minSearchLength])
}, [canSearch, onQueryChange, options, trimmedQuery])
useEffect(() => {
if (!open) return
@@ -76,13 +103,14 @@ export function ProductSearchSelect({
onChange(id)
setOpen(false)
setQuery('')
onQueryChangeRef.current?.('')
}
function handleFocus() {
if (!disabled) setOpen(true)
}
const showDropdown = open && query.trim().length >= minSearchLength
const showDropdown = open && (canSearch || trimmedQuery.length > 0)
return (
<div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}>
@@ -115,17 +143,19 @@ export function ProductSearchSelect({
<X size={14} />
</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>
{showDropdown && (
{showDropdown && canSearch && (
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>
{options.length === 0
? t('storeItems.search.noProducts')
: t('storeItems.search.noMatch')}
</li>
{isSearching && filtered.length === 0 ? (
<li className={styles.noResults}>{t('storeItems.search.searching')}</li>
) : filtered.length === 0 ? (
<li className={styles.noResults}>{t('storeItems.search.noMatch')}</li>
) : (
filtered.map((option) => (
<li key={option.id}>
@@ -172,7 +202,7 @@ export function ProductSearchSelect({
</ul>
)}
{open && query.trim().length > 0 && query.trim().length < minSearchLength && (
{open && trimmedQuery.length > 0 && trimmedQuery.length < minSearchLength && (
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}>
<li className={styles.noResults}>
{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 { 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 { useT } from '../i18n/useT'
import {
fillProductTechnicalInfoByAi,
getProductTechnicalInfo,
saveProductTechnicalInfo,
valuesToFormState,
@@ -12,8 +15,11 @@ import type {
ProductTechnicalFormValues,
TechnicalFieldType,
} from '../types/technicalForm'
import { ProductTechnicalValuesAiPromptModal } from './ProductTechnicalValuesAiPromptModal'
import { TechnicalMultiValueEditor } from './TechnicalMultiValueEditor'
import styles from './VariationsModal.module.css'
import fieldStyles from './TechnicalFormModal.module.css'
import aiStyles from '../styles/ai.module.css'
interface ProductTechnicalInfoModalProps {
open: boolean
@@ -32,12 +38,17 @@ export function ProductTechnicalInfoModal({
categoryId,
onClose,
}: ProductTechnicalInfoModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [form, setForm] = useState<CategoryTechnicalForm | null>(null)
const [values, setValues] = useState<ProductTechnicalFormValues>({})
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isAiOpen, setIsAiOpen] = useState(false)
const [isAiRunning, setIsAiRunning] = useState(false)
const [error, setError] = useState('')
const [infoMessage, setInfoMessage] = useState('')
@@ -45,8 +56,10 @@ export function ProductTechnicalInfoModal({
if (open) {
setMounted(true)
setClosing(false)
setIsAiOpen(false)
} else if (mounted) {
setClosing(true)
setIsAiOpen(false)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
@@ -75,16 +88,16 @@ export function ProductTechnicalInfoModal({
if (data.message) {
setInfoMessage(data.message)
} else if (!categoryId) {
setInfoMessage('Assign a category to this product before adding technical data.')
setInfoMessage(t('products.technical.needCategory'))
} else if (!data.form) {
setInfoMessage('No technical form is defined for this product category.')
setInfoMessage(t('products.technical.noForm'))
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load technical data.')
setError(t('products.technical.loadError'))
}
} finally {
setIsLoading(false)
@@ -92,13 +105,13 @@ export function ProductTechnicalInfoModal({
}
useEffect(() => {
if (!mounted || closing) return
if (!mounted || closing || isAiOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
}, [mounted, closing, isAiOpen, onClose])
if (!mounted) return null
@@ -123,8 +136,11 @@ export function ProductTechnicalInfoModal({
return form.fields.every((field) => {
if (!field.isRequired) return true
const value = values[field.key]
if (field.type === 'multi_select') {
return Array.isArray(value) && value.length > 0
if (field.type === 'multi_select' || field.type === 'multi_value') {
return (
Array.isArray(value) &&
value.some((item) => String(item).trim().length > 0)
)
}
return typeof value === 'string' && value.trim().length > 0
})
@@ -145,13 +161,26 @@ export function ProductTechnicalInfoModal({
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save technical data.')
setError(t('products.technical.saveError'))
}
} finally {
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]) {
const value = values[field.key]
const type = field.type as TechnicalFieldType
@@ -173,7 +202,7 @@ export function ProductTechnicalInfoModal({
value={typeof value === 'string' ? value : ''}
onChange={(e) => setFieldValue(field.key, e.target.value)}
>
<option value="">Select...</option>
<option value="">{t('products.technical.selectPlaceholder')}</option>
{field.options.map((option) => (
<option key={option.id} value={option.value}>
{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 (
<input
id={`tech-${field.key}`}
@@ -211,68 +253,106 @@ export function ProductTechnicalInfoModal({
)
}
const canUseAi = Boolean(form && form.fields.length > 0)
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<>
<div
className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="product-technical-title"
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div className={styles.header}>
<div>
<h3 id="product-technical-title" className={styles.title}>
Technical Data
</h3>
{productName && <p className={styles.subtitle}>{productName}</p>}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{isLoading ? (
<p className={styles.emptyText}>Loading technical data...</p>
) : infoMessage && !form ? (
<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>
<div
className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="product-technical-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div>
<h3 id="product-technical-title" className={styles.title}>
{t('products.technical.title')}
</h3>
{productName && <p className={styles.subtitle}>{productName}</p>}
</div>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSave()}
disabled={!form || !isFormValid() || isSaving || isLoading}
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
{isSaving ? 'Saving…' : 'Save'}
<X size={20} />
</button>
</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>
,
document.body,
<ProductTechnicalValuesAiPromptModal
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 { 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 {
countSelectedOptions,
@@ -8,6 +10,7 @@ import {
saveProductVariationValues,
type ProductVariationSelection,
} from '../services/productVariationService'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { MultiSelectDropdown } from './MultiSelectDropdown'
import styles from './VariationsModal.module.css'
@@ -30,6 +33,9 @@ export function ProductVariantsModal({
onClose,
onVariantsChange,
}: ProductVariantsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
@@ -72,7 +78,7 @@ export function ProductVariantsModal({
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load product variations.')
setError(t('products.variants.loadError'))
}
} finally {
setIsLoading(false)
@@ -90,6 +96,12 @@ export function ProductVariantsModal({
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[]) {
setVariations((prev) =>
prev.map((variation) =>
@@ -113,7 +125,7 @@ export function ProductVariantsModal({
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save product variations.')
setError(t('products.variants.saveError'))
}
} finally {
setIsSaving(false)
@@ -131,54 +143,63 @@ export function ProductVariantsModal({
role="dialog"
aria-modal="true"
aria-labelledby="product-variants-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div>
<h3 id="product-variants-title" className={styles.title}>
Product Variations
{t('products.variants.title')}
</h3>
{productName && <p className={styles.subtitle}>{productName}</p>}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<button
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<div className={styles.body}>
{!categoryId && (
<p className={styles.errorText}>
Assign a category to this product before managing variations.
</p>
<p className={styles.errorText}>{t('products.variants.needCategory')}</p>
)}
{error && <p className={styles.errorText}>{error}</p>}
{isLoading ? (
<p className={styles.emptyText}>Loading variations...</p>
<p className={styles.emptyText}>{t('products.variants.loading')}</p>
) : variations.length === 0 ? (
<p className={styles.emptyText}>
{categoryId
? 'This category has no variations. Add variations on the category first.'
: 'No variations available.'}
? t('products.variants.emptyCategory')
: t('products.variants.empty')}
</p>
) : (
<div className={styles.variationFields}>
{variations.map((variation) => (
<div key={variation.id} className={styles.field}>
<label htmlFor={`variation-${variation.id}`}>{variation.name}</label>
<MultiSelectDropdown
id={`variation-${variation.id}`}
options={variation.options.map((option) => ({
value: option.id,
label: option.label,
}))}
value={variation.selectedOptionIds}
onChange={(optionIds) => handleSelectionChange(variation.id, optionIds)}
placeholder={`Select ${variation.name.toLowerCase()} values`}
disabled={isSaving}
/>
</div>
))}
{variations.map((variation) => {
const name = variationLabel(variation)
return (
<div key={variation.id} className={styles.field}>
<label htmlFor={`variation-${variation.id}`}>{name}</label>
<MultiSelectDropdown
id={`variation-${variation.id}`}
options={variation.options.map((option) => ({
value: option.id,
label: localizeVariationOptionLabel(option.label, t),
}))}
value={variation.selectedOptionIds}
onChange={(optionIds) =>
handleSelectionChange(variation.id, optionIds)
}
placeholder={t('products.variants.selectValues', { name })}
disabled={isSaving}
/>
</div>
)
})}
</div>
)}
@@ -189,7 +210,7 @@ export function ProductVariantsModal({
onClick={onClose}
disabled={isSaving}
>
Cancel
{t('common.cancel')}
</button>
<button
type="button"
@@ -197,13 +218,12 @@ export function ProductVariantsModal({
onClick={() => void handleSave()}
disabled={!categoryId || isLoading || isSaving || variations.length === 0}
>
{isSaving ? 'Saving…' : 'Save variations'}
{isSaving ? t('products.variants.saving') : t('products.variants.save')}
</button>
</div>
</div>
</div>
</div>
,
document.body,
</div>,
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 {
position: static;
margin-top: 6px;
max-height: none;
overflow: visible;
max-height: min(280px, 40vh);
overflow-y: auto;
z-index: auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes dropdownIn {
from {
opacity: 0;
@@ -217,6 +227,8 @@
.optionEn {
font-weight: 500;
text-align: start;
direction: ltr;
unicode-bidi: isolate;
}
.optionEn[dir='rtl'] {
@@ -195,13 +195,17 @@ export function SearchableSelect({
{cat.nameEn ? (
<>
<span className={styles.optionSep}>·</span>
<span className={styles.optionEn}>{cat.nameEn}</span>
<span className={styles.optionEn} dir="ltr" lang="en">
{cat.nameEn}
</span>
</>
) : null}
</>
) : (
<>
<span className={styles.optionEn}>{cat.nameEn}</span>
<span className={styles.optionEn} dir="ltr" lang="en">
{cat.nameEn}
</span>
{cat.nameFa ? (
<>
<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 {
display: block;
width: 40px;
height: 40px;
/* Same height as brandName + domain stack; width follows image aspect ratio */
height: calc(13px * 1.2 + 2px + 11px * 1.2);
width: auto;
max-width: 7.5rem;
object-fit: contain;
flex-shrink: 0;
background: transparent;
}
.brandLogoUploaded {
border-radius: 8px;
object-fit: cover;
background: var(--card-media-bg);
border-radius: 0;
object-fit: contain;
background: transparent;
}
.brandEditBtn {
width: 40px;
height: 40px;
width: calc(13px * 1.2 + 2px + 11px * 1.2);
height: calc(13px * 1.2 + 2px + 11px * 1.2);
display: flex;
align-items: center;
justify-content: center;
@@ -69,6 +71,7 @@
font-weight: 500;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+46 -11
View File
@@ -26,7 +26,9 @@ import type { BusinessMessageKey } from '../i18n/messages'
import {
BUSINESS_PROFILE_UPDATED_EVENT,
getActiveBusinessDomain,
getActiveBusinessId,
} from '../lib/businessContext'
import { canAccessRollCall } from '../utils/rollCallAccess'
import { isAbortError } from '../lib/api'
import { getBusinessProfile } from '../services/businessProfileService'
import { useTenantBranding } from '../context/TenantBrandingContext'
@@ -68,16 +70,19 @@ function isGroupActive(group: NavGroup, pathname: string) {
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
return true
}
if (group.id === 'content') {
if (
pathname.startsWith('/blog') ||
pathname.startsWith('/videos') ||
pathname.startsWith('/instructions') ||
pathname.startsWith('/workshops')
) {
return true
}
if (group.id === 'content') {
if (
pathname.startsWith('/blog') ||
pathname.startsWith('/videos') ||
pathname.startsWith('/instructions') ||
pathname.startsWith('/workshops')
) {
return true
}
}
if (group.id === 'customers' && pathname.startsWith('/roll-call')) {
return true
}
return group.children.some(
(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.shipping', to: '/store/shipping' },
{ 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' },
],
},
{ 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',
id: 'finance',
@@ -241,7 +276,7 @@ export function Sidebar({ open = false }: SidebarProps) {
}
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
})
}, [enabledModules])
}, [enabledModules, user])
useEffect(() => {
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-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
@@ -45,8 +46,8 @@
.image {
width: 100%;
height: 100%;
object-fit: contain;
padding: 10px;
object-fit: cover;
display: block;
}
.imagePlaceholder {
@@ -64,7 +65,8 @@
}
.festivalBadge,
.stockBadge {
.stockBadge,
.usedBadge {
position: absolute;
top: 8px;
padding: 4px 10px;
@@ -86,6 +88,14 @@
color: #7c3aed;
}
.usedBadge {
inset-inline-start: 8px;
top: auto;
bottom: 8px;
color: #b45309;
background: rgba(255, 251, 235, 0.95);
}
.stockBadge {
inset-inline-end: 8px;
color: var(--primary);
@@ -191,7 +201,8 @@
justify-content: space-around;
}
.controlsLeft button {
.controlsLeft button,
.controlsLeft a {
width: 32px;
height: 32px;
display: flex;
@@ -199,10 +210,12 @@
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.controlsLeft button:hover {
.controlsLeft button:hover,
.controlsLeft a:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
@@ -6,6 +6,8 @@ import {
formatVariantCount,
type StoreProductListing,
} from '../utils/storeProductGroups'
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
import { StoreItemPrice } from './StoreItemPrice'
import { Tooltip } from './Tooltip'
import styles from './StoreItemCard.module.css'
@@ -83,6 +85,14 @@ export function StoreItemCard({
const imageSrc = listing.productImage?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
const websiteUrl =
listing.variantCount > 0
? buildWebsiteContentUrl('product', {
id: listing.productId,
nameFa: listing.productNameFa,
nameEn: listing.productTitle,
})
: ''
useEffect(() => {
setImageFailed(false)
@@ -115,6 +125,9 @@ export function StoreItemCard({
{listing.showFestival && (
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
)}
{listing.isUsed && (
<span className={styles.usedBadge}>{t('storeItems.card.used')}</span>
)}
{listing.productTotalStock > 0 && (
<span className={styles.stockBadge}>
{t('storeItems.card.inStock', { count: listing.productTotalStock })}
@@ -164,6 +177,7 @@ export function StoreItemCard({
<Pencil size={16} />
</button>
</Tooltip>
<OpenOnWebsiteControl href={websiteUrl} />
<Tooltip label={t('storeItems.card.discount')}>
<button
type="button"
@@ -1,28 +1,30 @@
.priceChip {
display: inline-block;
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
border-radius: 50px;
.price,
.contactPrice,
.discountWrap {
display: block;
width: 100%;
text-align: left;
direction: ltr;
}
.price {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.contactPrice {
display: inline-block;
padding: 3px 10px;
font-size: 11px;
font-size: 12px;
font-weight: 600;
font-family: var(--font-ui);
color: var(--text-muted);
background: rgba(148, 163, 184, 0.14);
border-radius: 50px;
}
.discountWrap {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
}
.originalPrice {
@@ -36,12 +38,13 @@
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-start;
}
.salePrice {
font-size: 12px;
font-weight: 700;
color: #dc2626;
color: var(--text-primary);
}
.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 { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css'
export function buildTechnicalFormAiPrompt(
categoryName: string,
locale: 'fa' | 'en' = 'en',
) {
if (locale === 'fa') {
return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
export function buildTechnicalFormAiPrompt(categoryName: string) {
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:
- 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
اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را هم به‌صورت text اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتی‌متر یا کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن.
Focus on specs and attributes that shoppers typically compare when buying this type of product.`
روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.
برچسب‌ها را به فارسی بنویس.
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز می‌کند و فرم را بر اساس مشخصات واقعی آن می‌سازد).`
}
interface TechnicalFormAiPromptModalProps {
@@ -52,20 +47,16 @@ export function TechnicalFormAiPromptModal({
isRunning,
}: TechnicalFormAiPromptModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState(() =>
buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'),
)
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setPrompt(buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'))
setPrompt(buildTechnicalFormAiPrompt(categoryName))
setError('')
} else if (mounted) {
setClosing(true)
@@ -75,7 +66,7 @@ export function TechnicalFormAiPromptModal({
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, categoryName, isFa])
}, [open, mounted, categoryName])
useEffect(() => {
if (!mounted || closing) return
@@ -118,8 +109,8 @@ export function TechnicalFormAiPromptModal({
role="dialog"
aria-modal="true"
aria-labelledby="tech-form-ai-prompt-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
lang="fa"
dir="rtl"
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
@@ -147,11 +138,11 @@ export function TechnicalFormAiPromptModal({
className={aiStyles.aiPromptTextarea}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={8}
rows={14}
disabled={isRunning}
required
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
lang="fa"
dir="rtl"
/>
</div>
@@ -18,7 +18,7 @@
.fieldMainRow {
display: grid;
grid-template-columns: 6fr 3fr 2fr 32px;
grid-template-columns: 6fr 3fr 2fr auto;
gap: 8px;
align-items: center;
}
@@ -61,6 +61,18 @@
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 {
display: flex;
align-items: center;
@@ -176,3 +188,15 @@
white-space: nowrap;
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 { 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 type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
import { useT } from '../i18n/useT'
@@ -34,6 +34,7 @@ const FIELD_TYPE_KEYS: Record<TechnicalFieldType, BusinessMessageKey> = {
textarea: 'categories.technical.type.textarea',
select: 'categories.technical.type.select',
multi_select: 'categories.technical.type.multi_select',
multi_value: 'categories.technical.type.multi_value',
}
function createEmptyField(): TechnicalFormFieldDraft {
@@ -111,6 +112,17 @@ export function TechnicalFormModal({
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() {
onChange([...fields, createEmptyField()])
}
@@ -215,7 +227,7 @@ export function TechnicalFormModal({
) : (
<>
<div className={fieldStyles.fieldList}>
{fields.map((field) => (
{fields.map((field, index) => (
<div key={field.id} className={fieldStyles.fieldCard}>
<div className={fieldStyles.fieldMainRow}>
<input
@@ -264,7 +276,29 @@ export function TechnicalFormModal({
/>
<span>{t('categories.technical.required')}</span>
</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')}>
<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 {
position: relative;
display: flex;
align-items: center;
margin-bottom: 10px;
}
.colorSearchIcon {
position: absolute;
inset-inline-start: 12px;
color: var(--text-muted);
pointer-events: none;
}
.colorSearchInput {
width: 100%;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) 36px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
margin-bottom: 10px;
padding: 0 var(--field-padding-x);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
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 {
font-family: var(--font-ui);
opacity: 1;
@@ -315,13 +324,11 @@
}
.colorSearchInput:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
outline: none;
}
.colorSearchClear {
position: absolute;
inset-inline-end: 8px;
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
@@ -358,6 +365,89 @@
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 {
display: flex;
flex-direction: column;
@@ -500,6 +590,12 @@
color: #ef4444;
}
.removeRowBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.preview {
font-size: 13px;
color: var(--text-primary);
@@ -3,10 +3,9 @@ import { createPortal } from 'react-dom'
import { Pencil, Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import type { Variation, VariationType } from '../types/variation'
import { COLOR_OPTIONS, normalizeColorName } from '../types/variation'
import { createId } from '../utils/id'
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
import { textLocaleAttrs } from '../utils/textLocale'
import { AddVariationModal } from './AddVariationModal'
import { Tooltip } from './Tooltip'
@@ -122,11 +121,7 @@ export function VariationsModal({
}
function localizeValue(value: string) {
const normalized = normalizeColorName(value)
if ((COLOR_OPTIONS as string[]).includes(normalized)) {
return t(`categories.variations.color.${normalized}` as BusinessMessageKey)
}
return value
return localizeVariationOptionLabel(value, t)
}
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;
}
/* 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 {
width: 100%;
height: 100%;
@@ -3,6 +3,7 @@ import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { WebsiteStaticImageItem } from '../types/websiteStaticImage'
import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio'
import { isPngImageUrl } from '../utils/imageUpload'
import { Tooltip } from './Tooltip'
import controlStyles from './CategoryRow.module.css'
import styles from './WebsiteStaticImageGallery.module.css'
@@ -106,7 +107,7 @@ export function WebsiteStaticImageGallery({
<article className={styles.card}>
<button
type="button"
className={styles.imageButton}
className={`${styles.imageButton} ${isPngImageUrl(image.url) ? styles.imageButtonTransparent : ''}`}
onClick={() => onAddAt(index)}
aria-label={replaceLabel}
style={{ aspectRatio: cssAspect }}
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ import {
createUserProduct,
getUserProduct,
getUserProductCategoryTechnicalForm,
hydrateTechnicalFormValues,
listUserProductCategories,
updateUserProduct,
type TechnicalFormField,
@@ -36,6 +37,7 @@ import {
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { seoMetaPayload } from '../utils/seoMeta'
import { TechnicalMultiValueEditor } from '../components/TechnicalMultiValueEditor'
import styles from './AddCustomerProductPage.module.css'
type StepId = 1 | 2 | 3
@@ -266,7 +268,12 @@ export function AddCustomerProductPage() {
if (controller.signal.aborted) return
setTechnicalFields(response.form?.fields ?? [])
if (pendingTechnicalValuesRef.current) {
setTechnicalValues(pendingTechnicalValuesRef.current)
setTechnicalValues(
hydrateTechnicalFormValues(
response.form?.fields ?? [],
pendingTechnicalValuesRef.current,
),
)
pendingTechnicalValuesRef.current = null
}
})
@@ -321,8 +328,9 @@ export function AddCustomerProductPage() {
if (!field.isRequired) continue
const value = technicalValues[field.id]
const ok =
field.type === 'multi_select'
? Array.isArray(value) && value.length > 0
field.type === 'multi_select' || field.type === 'multi_value'
? Array.isArray(value) &&
value.some((item) => String(item).trim().length > 0)
: typeof value === 'string' && value.trim().length > 0
if (!ok) {
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 (
<input
id={id}
@@ -751,6 +772,8 @@ export function AddCustomerProductPage() {
}
}}
aspect={1}
minZoom={0.4}
outputFormat="jpeg"
hint={t('customerProducts.form.images.thumbnailHint')}
/>
</div>
+6 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category'
import type { BlogPostType } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -61,8 +60,7 @@ export function AddNewBlogPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
const selectedAspect =
const selectedAspect =
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
useEffect(() => {
@@ -273,11 +271,13 @@ export function AddNewBlogPage() {
<div className={`${styles.field} ${styles.col6Span}`}>
<label>{t('blog.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('blog.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category'
import type { VideoProvider } from '../types/instruction'
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -63,8 +62,7 @@ export function AddNewInstructionPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
const selectedAspect =
const selectedAspect =
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
useEffect(() => {
@@ -257,11 +255,13 @@ export function AddNewInstructionPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('instruction.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('instruction.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
@@ -27,7 +27,6 @@ import {
updatePortfolio,
} from '../services/portfolioService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -70,8 +69,7 @@ export function AddNewPortfolioPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
const selectedAspect =
const selectedAspect =
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
useEffect(() => {
@@ -277,11 +275,13 @@ export function AddNewPortfolioPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('portfolio.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('portfolio.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -17,6 +17,7 @@
.col2 { grid-column: span 2; }
.col3 { grid-column: span 3; }
.col4 { grid-column: span 4; }
.col5 { grid-column: span 5; }
.col6 { grid-column: 1 / span 6; }
.col8 { grid-column: span 8; }
.col7 { grid-column: span 7; }
@@ -86,6 +87,64 @@
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 {
min-height: calc((var(--field-height) + 8px) * 2);
padding-inline-end: 12px;
@@ -437,6 +496,7 @@
.col2,
.col3,
.col4,
.col5,
.col6,
.col6Span,
.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 { Sparkles } from 'lucide-react'
import { FileText, Sparkles, X } from 'lucide-react'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
import { Tooltip } from '../components/Tooltip'
@@ -18,6 +18,7 @@ import {
galleryMediaIdsAfterChange,
resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds,
uploadCatalogPdf,
} from '../services/mediaService'
import {
createProduct,
@@ -29,19 +30,20 @@ import {
fillProductFieldByAi,
} from '../services/productAiService'
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 aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
import { useT } from '../i18n/useT'
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
export function AddNewProductPage() {
const { id } = useParams()
const navigate = useNavigate()
const t = useT()
const { showToast } = useToast()
const isEdit = Boolean(id)
const catalogInputRef = useRef<HTMLInputElement>(null)
const [categories, setCategories] = useState<Category[]>([])
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
@@ -54,22 +56,24 @@ export function AddNewProductPage() {
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
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 [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
const [isAiRunning, setIsAiRunning] = useState(false)
const categoryOptions = flattenCategories(categories)
const selectedCategoryName = useMemo(() => {
const match = categoryOptions.find((item) => item.id === categoryId)
const match = categories.find((item) => item.id === categoryId)
if (!match) return ''
return match.nameFa?.trim() || match.nameEn || ''
}, [categoryOptions, categoryId])
}, [categories, categoryId])
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
@@ -175,10 +179,13 @@ export function AddNewProductPage() {
setThumbnail(form.thumbnail)
setImages(form.images)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setCatalogMediaId(form.catalogMediaId)
setCatalogUrl(form.catalogUrl)
setCatalogFileName(form.catalogFileName)
setCatalogFile(null)
setCatalogRemoved(false)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
@@ -192,6 +199,39 @@ export function AddNewProductPage() {
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>) {
e.preventDefault()
setIsSubmitting(true)
@@ -215,6 +255,14 @@ export function AddNewProductPage() {
galleryMediaIds,
)
let nextCatalogMediaId: string | null = catalogMediaId
if (catalogFile) {
const uploaded = await uploadCatalogPdf(catalogFile)
nextCatalogMediaId = uploaded.id
} else if (catalogRemoved) {
nextCatalogMediaId = null
}
const payload = {
title: nameEn.trim(),
nameFa: nameFa.trim(),
@@ -224,8 +272,8 @@ export function AddNewProductPage() {
brandId: brandId || undefined,
featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
catalogMediaId: nextCatalogMediaId ?? undefined,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -235,6 +283,7 @@ export function AddNewProductPage() {
categoryId: categoryId || null,
brandId: brandId || null,
featuredMediaId,
catalogMediaId: nextCatalogMediaId,
})
} else {
await createProduct(payload)
@@ -293,15 +342,20 @@ export function AddNewProductPage() {
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}>
<label>{t('products.form.thumbnail')}</label>
<ImageCropper value={thumbnail} onChange={setThumbnail} />
<ImageCropper
value={thumbnail}
onChange={setThumbnail}
minZoom={0.4}
outputFormat="jpeg"
/>
</div>
<div className={styles.col10}>
<div className={styles.topFields}>
<div className={`${styles.field} ${styles.fieldCategory}`}>
<label>{t('products.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('products.form.categoryPlaceholder')}
@@ -437,6 +491,48 @@ export function AddNewProductPage() {
/>
</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}`}>
<label>{t('products.form.tags')}</label>
<TagInput
@@ -445,19 +541,6 @@ export function AddNewProductPage() {
placeholder={t('products.form.tagsPlaceholder')}
/>
</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 className={styles.actions}>
+6 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
@@ -26,7 +26,6 @@ import {
import type { Category } from '../types/category'
import type { VideoProvider } from '../types/video'
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -55,8 +54,7 @@ export function AddNewVideoPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
@@ -218,11 +216,13 @@ export function AddNewVideoPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('video.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('video.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</div>
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
@@ -28,7 +28,6 @@ import {
updateWorkshop,
} from '../services/workshopService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -61,8 +60,7 @@ export function AddNewWorkshopPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
@@ -256,11 +254,13 @@ export function AddNewWorkshopPage() {
<div className={styles.formColumn}>
<div className={styles.field}>
<label>{t('workshop.form.category')}</label>
<SearchableSelect
options={categoryOptions}
<CategoryTreeSelect
categories={categories}
value={categoryId}
onChange={setCategoryId}
placeholder={t('workshop.form.categoryPlaceholder')}
allowEmpty
emptyLabel={t('categories.select.noneCategory')}
/>
</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);
}
.addFab:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
@media (max-width: 768px) {
.fabDock {
inset-inline-end: 20px;
@@ -40,8 +40,30 @@
margin-bottom: 0;
}
.logoLightCol,
.logoDarkCol,
.logosBlock {
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 {
grid-column: span 12;
min-width: 0;
@@ -229,12 +251,8 @@
}
@media (min-width: 1280px) {
.logoLightCol {
grid-column: span 2;
}
.logoDarkCol {
grid-column: span 2;
.logosBlock {
grid-column: span 4;
}
.faviconCol {
+83 -29
View File
@@ -39,6 +39,14 @@ import styles from './BusinessProfilePage.module.css'
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 = {
whatsapp: '',
telegram: '',
@@ -82,6 +90,7 @@ export function BusinessProfilePage() {
const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
const [logoDark, setLogoDark] = 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 [faviconMediaId, setFaviconMediaId] = useState<string | null>(null)
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
@@ -91,9 +100,25 @@ export function BusinessProfilePage() {
const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL)
const categoryOptions = useMemo(
() => flattenBusinessActivityCategories(activityCategories),
[activityCategories],
() => flattenBusinessActivityCategories(activityCategories, locale),
[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(
() => ({
@@ -176,7 +201,7 @@ export function BusinessProfilePage() {
: item.province,
city: item.city,
address: item.address,
postalCode: item.postalCode,
postalCode: item.postalCode ?? '',
landline: item.landline ?? '',
}
})
@@ -307,7 +332,7 @@ export function BusinessProfilePage() {
province: item.province.trim(),
city: item.city.trim(),
address: item.address.trim(),
postalCode: item.postalCode.trim(),
postalCode: item.postalCode.trim() || undefined,
landline: item.landline?.trim() || null,
}))
@@ -378,32 +403,60 @@ export function BusinessProfilePage() {
<section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3>
<div className={formStyles.formGrid}>
<div className={`${formStyles.field} ${styles.logoLightCol}`}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
<ImageCropper
compact
value={logo}
onChange={setLogo}
outputFormat="png"
accept="image/png"
uploadLabel={t('bizProfile.logo.upload')}
hint={t('bizProfile.logo.hint')}
changeLabel={t('bizProfile.logo.change')}
/>
</div>
<div className={styles.logosBlock}>
<div className={styles.logosPair}>
<div className={`${formStyles.field} ${styles.logoField}`}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
<ImageCropper
compact
value={logo}
onChange={setLogo}
aspect={selectedLogoAspect}
outputFormat="png"
accept="image/png"
uploadLabel={t('bizProfile.logo.upload')}
hint={t('bizProfile.logo.hint')}
changeLabel={t('bizProfile.logo.change')}
/>
</div>
<div className={`${formStyles.field} ${styles.logoDarkCol}`}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
<ImageCropper
compact
value={logoDark}
onChange={setLogoDark}
outputFormat="png"
accept="image/png"
uploadLabel={t('bizProfile.logo.upload')}
hint={t('bizProfile.logo.hint')}
changeLabel={t('bizProfile.logo.change')}
/>
<div className={`${formStyles.field} ${styles.logoField}`}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
<ImageCropper
compact
value={logoDark}
onChange={setLogoDark}
aspect={selectedLogoAspect}
outputFormat="png"
accept="image/png"
uploadLabel={t('bizProfile.logo.upload')}
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 className={`${formStyles.field} ${styles.faviconCol}`}>
@@ -412,6 +465,7 @@ export function BusinessProfilePage() {
compact
value={favicon}
onChange={setFavicon}
aspect={1}
outputFormat="png"
accept="image/png"
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 {
background: var(--glass-bg);
backdrop-filter: blur(20px);
+84 -26
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { FolderTree, Globe, Plus, Sparkles } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FolderTree, Globe, Plus, Search, Sparkles, X } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { CategoryModal } from '../components/CategoryModal'
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
@@ -38,6 +38,7 @@ import {
toCategoryFormPayload,
updateProductCategory,
} from '../services/productCategoryService'
import { filterCategoriesByQuery, getAncestorIds } from '../utils/categories'
import { useT } from '../i18n/useT'
import { translateAllProductCategories } from '../services/translationService'
import type { TranslationLocale } from '../types/translation'
@@ -52,6 +53,7 @@ export function CategoriesPage() {
const { enabledModules } = useTenantBranding()
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
const [categories, setCategories] = useState<Category[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
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() {
if (aiMenuCloseTimerRef.current !== null) {
window.clearTimeout(aiMenuCloseTimerRef.current)
@@ -549,30 +577,60 @@ export function CategoriesPage() {
onAction={() => openCreateModal()}
/>
) : (
<div className={styles.list}>
<CategoryTree
categories={categories}
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 className={styles.listSection}>
<div className={styles.searchWrap}>
<Search size={20} className={styles.searchIcon} aria-hidden="true" />
<input
type="search"
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('categories.searchPlaceholder')}
aria-label={t('categories.search')}
/>
{searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
aria-label={t('categories.searchClear')}
>
<X size={18} />
</button>
) : null}
</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>
)}

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