Compare commits
32
Commits
d35c2f92b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018f3142ec | ||
|
|
b25f3cbea0 | ||
|
|
c4e846564e | ||
|
|
ac287e02cc | ||
|
|
eb9b8b9fa3 | ||
|
|
68b985dee3 | ||
|
|
cb20fe3c40 | ||
|
|
06589600bd | ||
|
|
55071af98c | ||
|
|
41f21999d2 | ||
|
|
bc1c5f0747 | ||
|
|
016dd0b081 | ||
|
|
c59ca4444f | ||
|
|
95390933a7 | ||
|
|
6d85a217e1 | ||
|
|
e2950fa20e | ||
|
|
ff419603bf | ||
|
|
8465a29ce1 | ||
|
|
838e84056e | ||
|
|
3eb6495f55 | ||
|
|
00f2e0f729 | ||
|
|
592540cc7e | ||
|
|
0bbc5bbc12 | ||
|
|
61d145d92e | ||
|
|
32f0dbab63 | ||
|
|
06e2442aaf | ||
|
|
b743c09e4d | ||
|
|
d787a69195 | ||
|
|
c24f6c06f7 | ||
|
|
e020c74fb1 | ||
|
|
8630465d34 | ||
|
|
c344ddefdc |
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,7 +54,6 @@ 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,7 +60,6 @@ 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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user