Compare commits
32
Commits
| 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 { StoreItemsPage } from './pages/StoreItemsPage'
|
||||||
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
|
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
|
||||||
import { CustomersPage } from './pages/CustomersPage'
|
import { CustomersPage } from './pages/CustomersPage'
|
||||||
|
import { RollCallPage } from './pages/RollCallPage'
|
||||||
import { InvoicesPage } from './pages/InvoicesPage'
|
import { InvoicesPage } from './pages/InvoicesPage'
|
||||||
import { IssueInvoicePage } from './pages/IssueInvoicePage'
|
import { IssueInvoicePage } from './pages/IssueInvoicePage'
|
||||||
import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage'
|
import { InvoiceTemplatesPage } from './pages/InvoiceTemplatesPage'
|
||||||
@@ -37,6 +38,9 @@ import { OrdersPage } from './pages/OrdersPage'
|
|||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
|
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
|
||||||
import { StoreSettingsPage } from './pages/StoreSettingsPage'
|
import { StoreSettingsPage } from './pages/StoreSettingsPage'
|
||||||
|
import { StoreShippingPage } from './pages/StoreShippingPage'
|
||||||
|
import { StoreInstallmentsPage } from './pages/StoreInstallmentsPage'
|
||||||
|
import { AddStoreInstallmentPage } from './pages/AddStoreInstallmentPage'
|
||||||
import { BlogPage } from './pages/BlogPage'
|
import { BlogPage } from './pages/BlogPage'
|
||||||
import { BlogListPage } from './pages/BlogListPage'
|
import { BlogListPage } from './pages/BlogListPage'
|
||||||
import { AddNewBlogPage } from './pages/AddNewBlogPage'
|
import { AddNewBlogPage } from './pages/AddNewBlogPage'
|
||||||
@@ -109,9 +113,14 @@ function App() {
|
|||||||
<Route path="store" element={<StorePage />} />
|
<Route path="store" element={<StorePage />} />
|
||||||
<Route path="store/items" element={<StoreItemsPage />} />
|
<Route path="store/items" element={<StoreItemsPage />} />
|
||||||
<Route path="store/orders" element={<OrdersPage />} />
|
<Route path="store/orders" element={<OrdersPage />} />
|
||||||
|
<Route path="store/shipping" element={<StoreShippingPage />} />
|
||||||
<Route path="store/cards" element={<ShoppingCardsPage />} />
|
<Route path="store/cards" element={<ShoppingCardsPage />} />
|
||||||
|
<Route path="store/installments" element={<StoreInstallmentsPage />} />
|
||||||
|
<Route path="store/installments/new" element={<AddStoreInstallmentPage />} />
|
||||||
|
<Route path="store/installments/edit/:id" element={<AddStoreInstallmentPage />} />
|
||||||
<Route path="store/settings" element={<StoreSettingsPage />} />
|
<Route path="store/settings" element={<StoreSettingsPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
|
<Route path="roll-call" element={<RollCallPage />} />
|
||||||
<Route path="finance" element={<FinancePage />} />
|
<Route path="finance" element={<FinancePage />} />
|
||||||
<Route path="invoices" element={<InvoicesPage />} />
|
<Route path="invoices" element={<InvoicesPage />} />
|
||||||
<Route path="invoices/new" element={<IssueInvoicePage />} />
|
<Route path="invoices/new" element={<IssueInvoicePage />} />
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
.modal {
|
||||||
|
width: min(820px, calc(100vw - 32px));
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
|
|
||||||
.headerBlock {
|
.headerBlock {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -10,6 +15,17 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptHint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Sparkles, X } from 'lucide-react'
|
import { Sparkles, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||||
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
|
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
import { CategoryTreeSelect } from './CategoryTreeSelect'
|
||||||
import { SearchableSelect } from './SearchableSelect'
|
|
||||||
import modalStyles from './CategoryModal.module.css'
|
import modalStyles from './CategoryModal.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './AddProductByAiModal.module.css'
|
import styles from './AddProductByAiModal.module.css'
|
||||||
@@ -26,26 +27,28 @@ export function AddProductByAiModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: AddProductByAiModalProps) {
|
}: AddProductByAiModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [categoryId, setCategoryId] = useState('')
|
const [categoryId, setCategoryId] = useState('')
|
||||||
const [name, setName] = useState('')
|
const [prompt, setPrompt] = useState('')
|
||||||
const [language, setLanguage] = useState<ProductAiLanguage>('en')
|
const [language, setLanguage] = useState<ProductAiLanguage>(locale === 'fa' ? 'fa' : 'en')
|
||||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false)
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => flattenCategories(categories), [categories])
|
const promptDir = language === 'fa' ? 'rtl' : 'ltr'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setCategoryId('')
|
setCategoryId('')
|
||||||
setName('')
|
setPrompt('')
|
||||||
setLanguage('en')
|
setLanguage(locale === 'fa' ? 'fa' : 'en')
|
||||||
setError('')
|
setError('')
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
setClosing(true)
|
setClosing(true)
|
||||||
@@ -55,7 +58,7 @@ export function AddProductByAiModal({
|
|||||||
}, ANIMATION_MS)
|
}, ANIMATION_MS)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [open, mounted])
|
}, [open, mounted, locale])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -86,7 +89,7 @@ export function AddProductByAiModal({
|
|||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to load categories.')
|
setError(t('products.ai.loadCategoriesError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingCategories(false)
|
setIsLoadingCategories(false)
|
||||||
@@ -95,7 +98,7 @@ export function AddProductByAiModal({
|
|||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!categoryId || !name.trim()) return
|
if (!categoryId || !prompt.trim()) return
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -103,7 +106,7 @@ export function AddProductByAiModal({
|
|||||||
try {
|
try {
|
||||||
const result = await createProductByAi({
|
const result = await createProductByAi({
|
||||||
categoryId,
|
categoryId,
|
||||||
name: name.trim(),
|
name: prompt.trim(),
|
||||||
language,
|
language,
|
||||||
})
|
})
|
||||||
showToast(result.message, 'success')
|
showToast(result.message, 'success')
|
||||||
@@ -115,7 +118,7 @@ export function AddProductByAiModal({
|
|||||||
} else if (err instanceof Error) {
|
} else if (err instanceof Error) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to create product with AI.')
|
setError(t('products.ai.createError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
@@ -131,7 +134,7 @@ export function AddProductByAiModal({
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="add-product-ai-title"
|
aria-labelledby="add-product-ai-title"
|
||||||
@@ -139,19 +142,16 @@ export function AddProductByAiModal({
|
|||||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||||
<div className={styles.headerBlock}>
|
<div className={styles.headerBlock}>
|
||||||
<h2 id="add-product-ai-title" className={modalStyles.title}>
|
<h2 id="add-product-ai-title" className={modalStyles.title}>
|
||||||
Add Product by AI
|
{t('products.ai.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>{t('products.ai.subtitle')}</p>
|
||||||
AI will generate product content and technical data. You can add images and
|
|
||||||
variations afterward.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={modalStyles.closeBtn}
|
className={modalStyles.closeBtn}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -159,43 +159,45 @@ export function AddProductByAiModal({
|
|||||||
|
|
||||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
<div className={modalStyles.field}>
|
<div className={modalStyles.field}>
|
||||||
<label>Category</label>
|
<label htmlFor="ai-product-category">{t('products.ai.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder="Search and select category..."
|
placeholder={t('products.ai.categoryPlaceholder')}
|
||||||
allowEmpty={false}
|
allowEmpty={false}
|
||||||
dropdownVariant="inFlow"
|
disabled={isSubmitting || isLoadingCategories}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={modalStyles.field}>
|
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
||||||
<label htmlFor="ai-product-name">Product name</label>
|
<label htmlFor="ai-product-prompt">{t('products.ai.prompt')}</label>
|
||||||
<input
|
<textarea
|
||||||
id="ai-product-name"
|
id="ai-product-prompt"
|
||||||
value={name}
|
value={prompt}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
placeholder={
|
placeholder={t('products.ai.promptPlaceholder')}
|
||||||
language === 'fa' ? 'نام محصول را وارد کنید' : 'Enter product name'
|
dir={promptDir}
|
||||||
}
|
className={`${aiStyles.aiPromptTextarea} ${styles.prompt}${language === 'fa' ? ' faText' : ''}`}
|
||||||
dir={language === 'fa' ? 'rtl' : 'ltr'}
|
|
||||||
className={language === 'fa' ? 'faText' : undefined}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
rows={14}
|
||||||
required
|
required
|
||||||
|
minLength={2}
|
||||||
|
lang={language === 'fa' ? 'fa' : 'en'}
|
||||||
/>
|
/>
|
||||||
|
<p className={styles.promptHint}>{t('products.ai.promptHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${modalStyles.field} ${fieldStyles.field}`}>
|
<div className={`${modalStyles.field} ${fieldStyles.field}`}>
|
||||||
<label htmlFor="ai-product-language">Language</label>
|
<label htmlFor="ai-product-language">{t('products.ai.language')}</label>
|
||||||
<select
|
<select
|
||||||
id="ai-product-language"
|
id="ai-product-language"
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)}
|
onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<option value="en">English</option>
|
<option value="en">{t('products.ai.language.en')}</option>
|
||||||
<option value="fa">Persian (Farsi)</option>
|
<option value="fa">{t('products.ai.language.fa')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,24 +210,24 @@ export function AddProductByAiModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={aiStyles.aiBtn}
|
className={aiStyles.aiBtn}
|
||||||
disabled={
|
disabled={
|
||||||
isSubmitting || isLoadingCategories || !categoryId || !name.trim()
|
isSubmitting || isLoadingCategories || !categoryId || !prompt.trim()
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
Creating...
|
{t('products.ai.creating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
Create product
|
{t('products.ai.create')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { createPortal } from 'react-dom'
|
|||||||
import { Plus, Search, X } from 'lucide-react'
|
import { Plus, Search, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { BusinessMessageKey } from '../i18n/messages'
|
import { COLOR_PRESETS, getColorHex, normalizeColorName } from '../data/colorPresets'
|
||||||
import { COLOR_PRESETS } from '../data/colorPresets'
|
|
||||||
import type { Variation, VariationType } from '../types/variation'
|
import type { Variation, VariationType } from '../types/variation'
|
||||||
import { SIZE_OPTIONS, normalizeColorNames } from '../types/variation'
|
import { SIZE_OPTIONS } from '../types/variation'
|
||||||
|
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import styles from './VariationsModal.module.css'
|
import styles from './VariationsModal.module.css'
|
||||||
|
|
||||||
@@ -18,15 +18,42 @@ interface AddVariationModalProps {
|
|||||||
initial?: Variation | null
|
initial?: Variation | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelectedColor {
|
||||||
|
label: string
|
||||||
|
hex: string
|
||||||
|
}
|
||||||
|
|
||||||
const ANIMATION_MS = 220
|
const ANIMATION_MS = 220
|
||||||
|
const DEFAULT_CUSTOM_HEX = '#64748B'
|
||||||
|
|
||||||
function seedCustomValues(values: string[]) {
|
function seedCustomValues(values: string[]) {
|
||||||
const trimmed = values.map((value) => value.trim()).filter(Boolean)
|
const trimmed = values.map((value) => value.trim()).filter(Boolean)
|
||||||
return trimmed.length ? trimmed : ['']
|
return trimmed.length ? trimmed : ['']
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorLabelKey(name: string): BusinessMessageKey {
|
function normalizeHex(value: string): string {
|
||||||
return `categories.variations.color.${name}` as BusinessMessageKey
|
const trimmed = value.trim()
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed.toUpperCase()
|
||||||
|
if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed.toUpperCase()}`
|
||||||
|
return DEFAULT_CUSTOM_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorsFromInitial(initial: Variation): SelectedColor[] {
|
||||||
|
return initial.values
|
||||||
|
.map((raw, index) => {
|
||||||
|
const label = raw.trim()
|
||||||
|
if (!label) return null
|
||||||
|
const canonical = normalizeColorName(label)
|
||||||
|
const provided = initial.colorHexes?.[index]
|
||||||
|
const hex =
|
||||||
|
(provided && /^#[0-9a-fA-F]{6}$/.test(provided.trim())
|
||||||
|
? provided.trim().toUpperCase()
|
||||||
|
: null) ??
|
||||||
|
getColorHex(canonical) ??
|
||||||
|
DEFAULT_CUSTOM_HEX
|
||||||
|
return { label: canonical, hex }
|
||||||
|
})
|
||||||
|
.filter((item): item is SelectedColor => item !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) {
|
export function AddVariationModal({ open, onClose, onSubmit, initial = null }: AddVariationModalProps) {
|
||||||
@@ -38,10 +65,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
|
|||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [type, setType] = useState<VariationType>('color')
|
const [type, setType] = useState<VariationType>('color')
|
||||||
const [customName, setCustomName] = useState('')
|
const [customName, setCustomName] = useState('')
|
||||||
const [selectedColors, setSelectedColors] = useState<string[]>([])
|
const [selectedColors, setSelectedColors] = useState<SelectedColor[]>([])
|
||||||
const [selectedSizes, setSelectedSizes] = useState<string[]>([])
|
const [selectedSizes, setSelectedSizes] = useState<string[]>([])
|
||||||
const [customValues, setCustomValues] = useState<string[]>([''])
|
const [customValues, setCustomValues] = useState<string[]>([''])
|
||||||
const [colorQuery, setColorQuery] = useState('')
|
const [colorQuery, setColorQuery] = useState('')
|
||||||
|
const [customColorName, setCustomColorName] = useState('')
|
||||||
|
const [customColorHex, setCustomColorHex] = useState(DEFAULT_CUSTOM_HEX)
|
||||||
|
const [customColorError, setCustomColorError] = useState('')
|
||||||
const valueInputRefs = useRef<(HTMLInputElement | null)[]>([])
|
const valueInputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||||
const pendingFocusIndex = useRef<number | null>(null)
|
const pendingFocusIndex = useRef<number | null>(null)
|
||||||
|
|
||||||
@@ -56,12 +86,13 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
|
|||||||
setMounted(true)
|
setMounted(true)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setColorQuery('')
|
setColorQuery('')
|
||||||
|
setCustomColorName('')
|
||||||
|
setCustomColorHex(DEFAULT_CUSTOM_HEX)
|
||||||
|
setCustomColorError('')
|
||||||
if (initial) {
|
if (initial) {
|
||||||
setType(initial.type)
|
setType(initial.type)
|
||||||
setCustomName(initial.type === 'custom' ? initial.name : '')
|
setCustomName(initial.type === 'custom' ? initial.name : '')
|
||||||
setSelectedColors(
|
setSelectedColors(initial.type === 'color' ? colorsFromInitial(initial) : [])
|
||||||
initial.type === 'color' ? normalizeColorNames(initial.values) : [],
|
|
||||||
)
|
|
||||||
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
|
setSelectedSizes(initial.type === 'size' ? [...initial.values] : [])
|
||||||
setCustomValues(
|
setCustomValues(
|
||||||
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
|
initial.type === 'custom' ? seedCustomValues(initial.values) : [''],
|
||||||
@@ -92,26 +123,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
|
|||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [mounted, closing, onClose])
|
}, [mounted, closing, onClose])
|
||||||
|
|
||||||
|
const selectedColorLabels = useMemo(
|
||||||
|
() => new Set(selectedColors.map((color) => color.label.toLowerCase())),
|
||||||
|
[selectedColors],
|
||||||
|
)
|
||||||
|
|
||||||
const filteredColors = useMemo(() => {
|
const filteredColors = useMemo(() => {
|
||||||
const q = colorQuery.trim().toLowerCase()
|
const q = colorQuery.trim().toLowerCase()
|
||||||
if (!q) return COLOR_PRESETS
|
if (!q) return COLOR_PRESETS
|
||||||
return COLOR_PRESETS.filter((color) => {
|
return COLOR_PRESETS.filter((color) => {
|
||||||
const en = color.name.toLowerCase()
|
const en = color.name.toLowerCase()
|
||||||
const fa = t(colorLabelKey(color.name)).toLowerCase()
|
const fa = localizeVariationOptionLabel(color.name, t).toLowerCase()
|
||||||
return en.includes(q) || fa.includes(q)
|
return en.includes(q) || fa.includes(q)
|
||||||
})
|
})
|
||||||
}, [colorQuery, t])
|
}, [colorQuery, t])
|
||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
function toggleOption(value: string, selected: string[], setSelected: (v: string[]) => void) {
|
function togglePresetColor(name: string, hex: string) {
|
||||||
setSelected(
|
setSelectedColors((prev) => {
|
||||||
selected.includes(value)
|
const exists = prev.some((item) => item.label === name)
|
||||||
? selected.filter((item) => item !== value)
|
if (exists) return prev.filter((item) => item.label !== name)
|
||||||
: [...selected, value],
|
return [...prev, { label: name, hex }]
|
||||||
|
})
|
||||||
|
setCustomColorError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedColor(label: string) {
|
||||||
|
setSelectedColors((prev) => prev.filter((item) => item.label !== label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSize(value: string) {
|
||||||
|
setSelectedSizes((prev) =>
|
||||||
|
prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addCustomColor() {
|
||||||
|
const label = customColorName.trim()
|
||||||
|
if (!label) {
|
||||||
|
setCustomColorError(t('categories.variations.customColorNameRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = normalizeHex(customColorHex)
|
||||||
|
const key = label.toLowerCase()
|
||||||
|
const canonical = normalizeColorName(label)
|
||||||
|
const alreadySelected =
|
||||||
|
selectedColorLabels.has(key) ||
|
||||||
|
selectedColorLabels.has(canonical.toLowerCase())
|
||||||
|
|
||||||
|
if (alreadySelected) {
|
||||||
|
setCustomColorError(t('categories.variations.customColorExists'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedColors((prev) => [...prev, { label, hex }])
|
||||||
|
setCustomColorName('')
|
||||||
|
setCustomColorHex(DEFAULT_CUSTOM_HEX)
|
||||||
|
setCustomColorError('')
|
||||||
|
}
|
||||||
|
|
||||||
function updateCustomValue(index: number, value: string) {
|
function updateCustomValue(index: number, value: string) {
|
||||||
setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v)))
|
setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v)))
|
||||||
}
|
}
|
||||||
@@ -142,7 +214,12 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (type === 'color') {
|
if (type === 'color') {
|
||||||
onSubmit({ name: t('categories.variations.type.color'), type: 'color', values: selectedColors })
|
onSubmit({
|
||||||
|
name: t('categories.variations.type.color'),
|
||||||
|
type: 'color',
|
||||||
|
values: selectedColors.map((color) => color.label),
|
||||||
|
colorHexes: selectedColors.map((color) => color.hex),
|
||||||
|
})
|
||||||
} else if (type === 'size') {
|
} else if (type === 'size') {
|
||||||
onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes })
|
onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes })
|
||||||
} else {
|
} else {
|
||||||
@@ -224,27 +301,22 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
|
|||||||
</div>
|
</div>
|
||||||
{selectedColors.length > 0 ? (
|
{selectedColors.length > 0 ? (
|
||||||
<div className={styles.selectedColors}>
|
<div className={styles.selectedColors}>
|
||||||
{selectedColors.map((color) => {
|
{selectedColors.map((color) => (
|
||||||
const preset = COLOR_PRESETS.find((item) => item.name === color)
|
<button
|
||||||
return (
|
key={color.label}
|
||||||
<button
|
type="button"
|
||||||
key={color}
|
className={`${styles.chip} ${styles.chipSelected}`}
|
||||||
type="button"
|
onClick={() => removeSelectedColor(color.label)}
|
||||||
className={`${styles.chip} ${styles.chipSelected}`}
|
>
|
||||||
onClick={() =>
|
<span
|
||||||
toggleOption(color, selectedColors, setSelectedColors)
|
className={styles.colorSwatch}
|
||||||
}
|
style={{ backgroundColor: color.hex }}
|
||||||
>
|
aria-hidden="true"
|
||||||
<span
|
/>
|
||||||
className={styles.colorSwatch}
|
{localizeVariationOptionLabel(color.label, t)}
|
||||||
style={{ backgroundColor: preset?.hex ?? '#94a3b8' }}
|
<X size={12} aria-hidden="true" />
|
||||||
aria-hidden="true"
|
</button>
|
||||||
/>
|
))}
|
||||||
{t(colorLabelKey(color))}
|
|
||||||
<X size={12} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={styles.chipGrid}>
|
<div className={styles.chipGrid}>
|
||||||
@@ -256,22 +328,67 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
|
|||||||
key={color.name}
|
key={color.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.chip} ${
|
className={`${styles.chip} ${
|
||||||
selectedColors.includes(color.name) ? styles.chipSelected : ''
|
selectedColorLabels.has(color.name.toLowerCase())
|
||||||
|
? styles.chipSelected
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() =>
|
onClick={() => togglePresetColor(color.name, color.hex)}
|
||||||
toggleOption(color.name, selectedColors, setSelectedColors)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={styles.colorSwatch}
|
className={styles.colorSwatch}
|
||||||
style={{ backgroundColor: color.hex }}
|
style={{ backgroundColor: color.hex }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{t(colorLabelKey(color.name))}
|
{localizeVariationOptionLabel(color.name, t)}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.customColorBlock}>
|
||||||
|
<span className={styles.customColorTitle}>
|
||||||
|
{t('categories.variations.addCustomColor')}
|
||||||
|
</span>
|
||||||
|
<div className={styles.customColorRow}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.customColorNameInput}
|
||||||
|
value={customColorName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomColorName(e.target.value)
|
||||||
|
if (customColorError) setCustomColorError('')
|
||||||
|
}}
|
||||||
|
placeholder={t('categories.variations.customColorNamePlaceholder')}
|
||||||
|
aria-label={t('categories.variations.customColorName')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addCustomColor()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label className={styles.customColorPickerLabel}>
|
||||||
|
<span className={styles.srOnly}>{t('categories.variations.customColorHex')}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className={styles.customColorPicker}
|
||||||
|
value={customColorHex}
|
||||||
|
onChange={(e) => setCustomColorHex(normalizeHex(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addValueBtn}
|
||||||
|
onClick={addCustomColor}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
{t('categories.variations.addCustomColorBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customColorError ? (
|
||||||
|
<p className={styles.customColorError}>{customColorError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -284,7 +401,7 @@ export function AddVariationModal({ open, onClose, onSubmit, initial = null }: A
|
|||||||
key={size}
|
key={size}
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`}
|
className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`}
|
||||||
onClick={() => toggleOption(size, selectedSizes, setSelectedSizes)}
|
onClick={() => toggleSize(size)}
|
||||||
>
|
>
|
||||||
{size}
|
{size}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function BrandModal({
|
|||||||
onChange={setImage}
|
onChange={setImage}
|
||||||
outputFormat="png"
|
outputFormat="png"
|
||||||
accept="image/png"
|
accept="image/png"
|
||||||
|
minZoom={0.4}
|
||||||
uploadLabel={t('brands.modal.uploadLogo')}
|
uploadLabel={t('brands.modal.uploadLogo')}
|
||||||
hint={t('brands.modal.logoHint')}
|
hint={t('brands.modal.logoHint')}
|
||||||
changeLabel={t('brands.modal.changeLogo')}
|
changeLabel={t('brands.modal.changeLogo')}
|
||||||
@@ -142,7 +143,8 @@ export function BrandModal({
|
|||||||
id="nameEn"
|
id="nameEn"
|
||||||
name="nameEn"
|
name="nameEn"
|
||||||
type="text"
|
type="text"
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
placeholder={t('brands.modal.nameEnPlaceholder')}
|
placeholder={t('brands.modal.nameEnPlaceholder')}
|
||||||
value={nameEn}
|
value={nameEn}
|
||||||
onChange={(e) => setNameEn(e.target.value)}
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.modal {
|
.modal {
|
||||||
max-width: 560px;
|
width: min(820px, calc(100vw - 32px));
|
||||||
|
max-width: 820px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerBlock {
|
.headerBlock {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ export function buildCategoryAiPrompt(locale: 'fa' | 'en') {
|
|||||||
if (locale === 'fa') {
|
if (locale === 'fa') {
|
||||||
return `یک درخت دستهبندی محصول کاربردی برای فروشگاه من بساز.
|
return `یک درخت دستهبندی محصول کاربردی برای فروشگاه من بساز.
|
||||||
|
|
||||||
نام انگلیسی، نام فارسی با خط پارسی، و توضیح کوتاه انگلیسی را برای هر دسته اضافه کن.
|
برای هر دسته این موارد را مشخص کن:
|
||||||
|
- نام انگلیسی
|
||||||
|
- نام فارسی با خط پارسی
|
||||||
|
- توضیح کوتاه فارسی
|
||||||
|
|
||||||
۳ تا ۵ دسته اصلی با زیردستههای مرتبط بساز تا مرور برای خریداران راحتتر شود.`
|
۳ تا ۵ دسته اصلی با زیردستههای مرتبط بساز تا مرور برای خریداران راحتتر شود.`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +140,7 @@ export function CategoryAiPromptModal({
|
|||||||
className={aiStyles.aiPromptTextarea}
|
className={aiStyles.aiPromptTextarea}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
rows={8}
|
rows={14}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
required
|
required
|
||||||
lang={isFa ? 'fa' : 'en'}
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field label {
|
.field label {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { X } from 'lucide-react'
|
|||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { Category, CategoryFormData } from '../types/category'
|
import type { Category, CategoryFormData } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
import { getDescendantIds } from '../utils/categories'
|
||||||
import { SearchableSelect } from './SearchableSelect'
|
import { CategoryTreeSelect } from './CategoryTreeSelect'
|
||||||
import { SeoMetaFields } from './SeoMetaFields'
|
import { SeoMetaFields } from './SeoMetaFields'
|
||||||
import styles from './CategoryModal.module.css'
|
import styles from './CategoryModal.module.css'
|
||||||
|
|
||||||
@@ -88,9 +88,12 @@ export function CategoryModal({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentOptions = flattenCategories(
|
const parentCategories = editingCategory
|
||||||
categories.filter((category) => category.id !== editingCategory?.id),
|
? categories.filter(
|
||||||
)
|
(category) =>
|
||||||
|
!getDescendantIds(categories, editingCategory.id).includes(category.id),
|
||||||
|
)
|
||||||
|
: categories
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -123,12 +126,13 @@ export function CategoryModal({
|
|||||||
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
|
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('categories.modal.parent')}</label>
|
<label>{t('categories.modal.parent')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={parentOptions}
|
categories={parentCategories}
|
||||||
value={parentId}
|
value={parentId}
|
||||||
onChange={setParentId}
|
onChange={setParentId}
|
||||||
placeholder={t('categories.select.search')}
|
placeholder={t('categories.select.search')}
|
||||||
dropdownVariant="inFlow"
|
allowEmpty
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,7 +158,8 @@ export function CategoryModal({
|
|||||||
id="nameEn"
|
id="nameEn"
|
||||||
name="nameEn"
|
name="nameEn"
|
||||||
type="text"
|
type="text"
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
placeholder={t('categories.modal.nameEnPlaceholder')}
|
placeholder={t('categories.modal.nameEnPlaceholder')}
|
||||||
value={nameEn}
|
value={nameEn}
|
||||||
onChange={(e) => setNameEn(e.target.value)}
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { ChevronDown, Search, X } from 'lucide-react'
|
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
|
||||||
import { useT } from '../i18n/useT'
|
|
||||||
import type { UserProductCategoryOption } from '../services/userProductsService'
|
import type { UserProductCategoryOption } from '../services/userProductsService'
|
||||||
import styles from './CategorySearchSelect.module.css'
|
import { CategoryTreeSelect, type CategoryTreeItem } from './CategoryTreeSelect'
|
||||||
|
|
||||||
export interface FlatCategoryOption extends UserProductCategoryOption {
|
export interface FlatCategoryOption extends UserProductCategoryOption {
|
||||||
depth: number
|
depth: number
|
||||||
@@ -20,13 +17,7 @@ interface CategorySearchSelectProps {
|
|||||||
dropdownVariant?: 'overlay' | 'inFlow'
|
dropdownVariant?: 'overlay' | 'inFlow'
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
|
/** @deprecated Prefer CategoryTreeSelect; kept for user-product category API shape. */
|
||||||
if (isFa) {
|
|
||||||
return category.nameFa?.trim() || category.name
|
|
||||||
}
|
|
||||||
return category.name || category.nameFa?.trim() || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function flattenCategoryTree(
|
export function flattenCategoryTree(
|
||||||
categories: UserProductCategoryOption[],
|
categories: UserProductCategoryOption[],
|
||||||
): FlatCategoryOption[] {
|
): FlatCategoryOption[] {
|
||||||
@@ -40,10 +31,7 @@ export function flattenCategoryTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const list of byParent.values()) {
|
for (const list of byParent.values()) {
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa'))
|
||||||
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
|
|
||||||
return aLabel
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: FlatCategoryOption[] = []
|
const result: FlatCategoryOption[] = []
|
||||||
@@ -52,6 +40,7 @@ export function flattenCategoryTree(
|
|||||||
function walk(parentId: string | null, depth: number) {
|
function walk(parentId: string | null, depth: number) {
|
||||||
const children = byParent.get(parentId) ?? []
|
const children = byParent.get(parentId) ?? []
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
|
if (!ids.has(child.id)) continue
|
||||||
result.push({ ...child, depth })
|
result.push({ ...child, depth })
|
||||||
walk(child.id, depth + 1)
|
walk(child.id, depth + 1)
|
||||||
}
|
}
|
||||||
@@ -59,75 +48,26 @@ export function flattenCategoryTree(
|
|||||||
|
|
||||||
walk(null, 0)
|
walk(null, 0)
|
||||||
|
|
||||||
|
const rooted = new Set(result.map((item) => item.id))
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
if (category.parentId && !ids.has(category.parentId)) {
|
if (!rooted.has(category.id)) {
|
||||||
if (!result.some((item) => item.id === category.id)) {
|
result.push({ ...category, depth: 0 })
|
||||||
result.push({ ...category, depth: 0 })
|
|
||||||
walk(category.id, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterCategoryTree(
|
function toTreeItems(options: UserProductCategoryOption[]): CategoryTreeItem[] {
|
||||||
flat: FlatCategoryOption[],
|
return options.map((option) => ({
|
||||||
query: string,
|
id: option.id,
|
||||||
): FlatCategoryOption[] {
|
parentId: option.parentId,
|
||||||
const q = query.trim().toLowerCase()
|
nameEn: option.name,
|
||||||
if (!q) return flat
|
nameFa: option.nameFa?.trim() || '',
|
||||||
|
}))
|
||||||
const byId = new Map(flat.map((item) => [item.id, item]))
|
|
||||||
const matchedIds = new Set<string>()
|
|
||||||
|
|
||||||
for (const item of flat) {
|
|
||||||
const nameEn = item.name.toLowerCase()
|
|
||||||
const nameFa = (item.nameFa || '').toLowerCase()
|
|
||||||
if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
|
|
||||||
matchedIds.add(item.id)
|
|
||||||
let parentId = item.parentId
|
|
||||||
while (parentId) {
|
|
||||||
matchedIds.add(parentId)
|
|
||||||
parentId = byId.get(parentId)?.parentId ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return flat.filter((item) => matchedIds.has(item.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLastChildInGroup(items: FlatCategoryOption[], index: number): boolean {
|
|
||||||
const current = items[index]
|
|
||||||
if (current.depth === 0) return false
|
|
||||||
const next = items[index + 1]
|
|
||||||
return !next || next.depth < current.depth
|
|
||||||
}
|
|
||||||
|
|
||||||
function CategoryTreeGutters({ depth, isLast }: { depth: number; isLast: boolean }) {
|
|
||||||
if (depth === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.treeGutters} aria-hidden>
|
|
||||||
{Array.from({ length: depth }, (_, level) => {
|
|
||||||
const isBranchLevel = level === depth - 1
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={level}
|
|
||||||
className={[
|
|
||||||
styles.treeGutter,
|
|
||||||
isBranchLevel ? styles.treeGutterBranch : styles.treeGutterContinue,
|
|
||||||
isBranchLevel && isLast ? styles.treeGutterBranchLast : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-product marketplace categories — same tree UX as CategoryTreeSelect. */
|
||||||
export function CategorySearchSelect({
|
export function CategorySearchSelect({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -138,159 +78,18 @@ export function CategorySearchSelect({
|
|||||||
allowEmpty = false,
|
allowEmpty = false,
|
||||||
dropdownVariant = 'overlay',
|
dropdownVariant = 'overlay',
|
||||||
}: CategorySearchSelectProps) {
|
}: CategorySearchSelectProps) {
|
||||||
const t = useT()
|
const categories = useMemo(() => toTreeItems(options), [options])
|
||||||
const { locale } = useLocale()
|
|
||||||
const isFa = locale === 'fa'
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const flat = useMemo(() => flattenCategoryTree(options), [options])
|
|
||||||
const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
|
|
||||||
|
|
||||||
const selected = flat.find((option) => option.id === value)
|
|
||||||
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
|
||||||
const searchPlaceholder = placeholder ?? t('customerProducts.form.fields.searchCategory')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
|
|
||||||
function onClickOutside(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', onClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disabled) {
|
|
||||||
setOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
}
|
|
||||||
}, [disabled])
|
|
||||||
|
|
||||||
function selectOption(nextId: string) {
|
|
||||||
onChange(nextId)
|
|
||||||
setOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownClassName = [
|
|
||||||
styles.dropdown,
|
|
||||||
dropdownVariant === 'inFlow' ? styles.dropdownInFlow : styles.dropdownOverlay,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CategoryTreeSelect
|
||||||
className={styles.wrapper}
|
id={id}
|
||||||
ref={containerRef}
|
categories={categories}
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
value={value}
|
||||||
>
|
onChange={onChange}
|
||||||
<div
|
disabled={disabled}
|
||||||
className={[
|
placeholder={placeholder}
|
||||||
styles.inputWrap,
|
allowEmpty={allowEmpty}
|
||||||
open ? styles.inputWrapOpen : '',
|
dropdownVariant={dropdownVariant}
|
||||||
disabled ? styles.inputWrapDisabled : '',
|
/>
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="text"
|
|
||||||
className={styles.input}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={selected ? selectedLabel : searchPlaceholder}
|
|
||||||
value={open ? query : selectedLabel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value)
|
|
||||||
if (!open) setOpen(true)
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (!disabled) setOpen(true)
|
|
||||||
}}
|
|
||||||
autoComplete="off"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
/>
|
|
||||||
{allowEmpty && value && !open && !disabled ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.clearBtn}
|
|
||||||
onClick={() => onChange('')}
|
|
||||||
aria-label={t('customerProducts.form.fields.clearCategory')}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<ChevronDown
|
|
||||||
size={16}
|
|
||||||
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open && !disabled ? (
|
|
||||||
<ul className={dropdownClassName} role="listbox">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<li className={styles.noResults}>{t('customerProducts.form.fields.noCategories')}</li>
|
|
||||||
) : (
|
|
||||||
filtered.map((option, index) => {
|
|
||||||
const isLastChild = isLastChildInGroup(filtered, index)
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={option.id}
|
|
||||||
className={[
|
|
||||||
styles.optionItem,
|
|
||||||
isLastChild ? styles.optionItemGroupEnd : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
styles.option,
|
|
||||||
option.depth === 0 ? styles.optionParent : styles.optionChild,
|
|
||||||
value === option.id ? styles.optionSelected : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
onClick={() => selectOption(option.id)}
|
|
||||||
role="option"
|
|
||||||
aria-selected={value === option.id}
|
|
||||||
>
|
|
||||||
{option.depth > 0 ? (
|
|
||||||
<CategoryTreeGutters depth={option.depth} isLast={isLastChild} />
|
|
||||||
) : null}
|
|
||||||
<span className={styles.optionBody}>
|
|
||||||
<span className={styles.optionLabel}>
|
|
||||||
{categoryLabel(option, isFa)}
|
|
||||||
</span>
|
|
||||||
{!isFa && option.nameFa ? (
|
|
||||||
<span className={styles.optionSecondary}>{option.nameFa}</span>
|
|
||||||
) : null}
|
|
||||||
{isFa && option.name && option.name !== option.nameFa ? (
|
|
||||||
<span className={styles.optionSecondary} dir="ltr">
|
|
||||||
{option.name}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapperOpen {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treePanel {
|
||||||
|
position: static;
|
||||||
|
max-height: min(280px, 40vh);
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeRow {
|
||||||
|
padding: 3px 6px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectBtn {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.55);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--card-media-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxChecked {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namePair {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameEn {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameFa {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameSep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: min(160px, 22vh);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background: rgba(148, 163, 184, 0.06);
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedRow .namePair {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedRow .nameEn,
|
||||||
|
.selectedRow .nameFa {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Minus, Plus, Search, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { Tooltip } from './Tooltip'
|
||||||
|
import type { CategoryTreeItem } from './CategoryTreeSelect'
|
||||||
|
import treeStyles from './CategoryTree.module.css'
|
||||||
|
import selectStyles from './CategoryTreeSelect.module.css'
|
||||||
|
import styles from './CategoryTreeMultiSelect.module.css'
|
||||||
|
|
||||||
|
interface CategoryTreeMultiSelectProps {
|
||||||
|
categories: CategoryTreeItem[]
|
||||||
|
selectedIds: string[]
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
|
||||||
|
return categories
|
||||||
|
.filter((category) => category.parentId === parentId)
|
||||||
|
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAncestorIds(categories: CategoryTreeItem[], id: string) {
|
||||||
|
const byId = new Map(categories.map((category) => [category.id, category]))
|
||||||
|
const ids: string[] = []
|
||||||
|
let current = byId.get(id)
|
||||||
|
|
||||||
|
while (current?.parentId) {
|
||||||
|
ids.push(current.parentId)
|
||||||
|
current = byId.get(current.parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryMatches(category: CategoryTreeItem, query: string) {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return true
|
||||||
|
return (
|
||||||
|
category.nameEn.toLowerCase().includes(q) ||
|
||||||
|
category.nameFa.toLowerCase().includes(q) ||
|
||||||
|
category.nameFa.includes(query.trim())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleIdsForQuery(
|
||||||
|
categories: CategoryTreeItem[],
|
||||||
|
query: string,
|
||||||
|
): Set<string> | null {
|
||||||
|
const q = query.trim()
|
||||||
|
if (!q) return null
|
||||||
|
|
||||||
|
const matched = categories.filter((category) => categoryMatches(category, q))
|
||||||
|
const visible = new Set<string>()
|
||||||
|
|
||||||
|
for (const category of matched) {
|
||||||
|
visible.add(category.id)
|
||||||
|
for (const ancestorId of getAncestorIds(categories, category.id)) {
|
||||||
|
visible.add(ancestorId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryNamePair({ category }: { category: CategoryTreeItem }) {
|
||||||
|
const nameEn = category.nameEn?.trim() || ''
|
||||||
|
const nameFa = category.nameFa?.trim() || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.namePair} dir="ltr">
|
||||||
|
{nameEn ? (
|
||||||
|
<span className={styles.nameEn} dir="ltr" lang="en">
|
||||||
|
{nameEn}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{nameEn && nameFa ? <span className={styles.nameSep}>·</span> : null}
|
||||||
|
{nameFa ? (
|
||||||
|
<span className={styles.nameFa} dir="rtl" lang="fa">
|
||||||
|
{nameFa}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNodes({
|
||||||
|
categories,
|
||||||
|
parentId,
|
||||||
|
selectedIds,
|
||||||
|
expandedIds,
|
||||||
|
visibleIds,
|
||||||
|
forceExpanded,
|
||||||
|
disabled,
|
||||||
|
onToggleExpand,
|
||||||
|
onToggleSelect,
|
||||||
|
}: {
|
||||||
|
categories: CategoryTreeItem[]
|
||||||
|
parentId: string | null
|
||||||
|
selectedIds: Set<string>
|
||||||
|
expandedIds: Set<string>
|
||||||
|
visibleIds: Set<string> | null
|
||||||
|
forceExpanded: boolean
|
||||||
|
disabled: boolean
|
||||||
|
onToggleExpand: (id: string) => void
|
||||||
|
onToggleSelect: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const nodes = getChildren(categories, parentId).filter(
|
||||||
|
(category) => !visibleIds || visibleIds.has(category.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{nodes.map((category) => {
|
||||||
|
const childCount = getChildren(categories, category.id).some(
|
||||||
|
(child) => !visibleIds || visibleIds.has(child.id),
|
||||||
|
)
|
||||||
|
const expanded = forceExpanded || expandedIds.has(category.id)
|
||||||
|
const selected = selectedIds.has(category.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category.id}>
|
||||||
|
<div
|
||||||
|
className={`${selectStyles.row} ${styles.treeRow} ${selected ? selectStyles.rowSelected : ''}`}
|
||||||
|
>
|
||||||
|
{childCount ? (
|
||||||
|
<Tooltip
|
||||||
|
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={selectStyles.toggleBtn}
|
||||||
|
aria-label={
|
||||||
|
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
|
||||||
|
}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => onToggleExpand(category.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span className={selectStyles.toggleSpacer} />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${selectStyles.selectBtn} ${styles.selectBtn}`}
|
||||||
|
onClick={() => onToggleSelect(category.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.checkbox} ${selected ? styles.checkboxChecked : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{selected ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<CategoryNamePair category={category} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{childCount ? (
|
||||||
|
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
|
||||||
|
<div className={treeStyles.childrenInner}>
|
||||||
|
<TreeNodes
|
||||||
|
categories={categories}
|
||||||
|
parentId={category.id}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
visibleIds={visibleIds}
|
||||||
|
forceExpanded={forceExpanded}
|
||||||
|
disabled={disabled}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryTreeMultiSelect({
|
||||||
|
categories,
|
||||||
|
selectedIds,
|
||||||
|
onToggle,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
id,
|
||||||
|
}: CategoryTreeMultiSelectProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||||
|
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
||||||
|
|
||||||
|
const visibleIds = useMemo(
|
||||||
|
() => visibleIdsForQuery(categories, query),
|
||||||
|
[categories, query],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCategories = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedIds
|
||||||
|
.map((id) => categories.find((category) => category.id === id))
|
||||||
|
.filter((category): category is CategoryTreeItem => Boolean(category)),
|
||||||
|
[categories, selectedIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
for (const ancestorId of getAncestorIds(categories, id)) {
|
||||||
|
next.add(ancestorId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [categories, selectedIds])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleToggleExpand(toggleId: string) {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(toggleId)) next.delete(toggleId)
|
||||||
|
else next.add(toggleId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const searching = query.trim().length > 0
|
||||||
|
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
|
||||||
|
ref={containerRef}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<div className={`${selectStyles.inputWrap} ${open ? selectStyles.inputWrapOpen : ''}`}>
|
||||||
|
<Search size={16} className={selectStyles.searchIcon} aria-hidden />
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
className={selectStyles.input}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
if (!open) setOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!disabled) setOpen(true)
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) setOpen(true)
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
/>
|
||||||
|
{query ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={selectStyles.clearBtn}
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
aria-label={t('categories.select.clear')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && !disabled ? (
|
||||||
|
<div className={`${selectStyles.panel} ${styles.treePanel}`}>
|
||||||
|
{!hasVisible ? (
|
||||||
|
<p className={selectStyles.noResults}>{t('categories.select.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<TreeNodes
|
||||||
|
categories={categories}
|
||||||
|
parentId={null}
|
||||||
|
selectedIds={selectedSet}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
visibleIds={visibleIds}
|
||||||
|
forceExpanded={searching}
|
||||||
|
disabled={disabled}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
onToggleSelect={onToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedCategories.length > 0 ? (
|
||||||
|
<div className={styles.selectedList}>
|
||||||
|
{selectedCategories.map((category) => (
|
||||||
|
<div key={category.id} className={styles.selectedRow}>
|
||||||
|
<CategoryNamePair category={category} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => onToggle(category.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('categories.select.clear')}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wrapperOpen {
|
||||||
|
z-index: 130;
|
||||||
|
}
|
||||||
|
|
||||||
.inputWrap {
|
.inputWrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -75,14 +79,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 120;
|
|
||||||
max-height: min(320px, 50vh);
|
max-height: min(320px, 50vh);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 0;
|
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: var(--elevated-surface);
|
background: var(--elevated-surface);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
@@ -90,6 +88,14 @@
|
|||||||
box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14);
|
box-shadow: 0 10px 28px rgba(31, 38, 135, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panelOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
.panelInFlow {
|
.panelInFlow {
|
||||||
position: static;
|
position: static;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
@@ -98,6 +104,29 @@
|
|||||||
z-index: auto;
|
z-index: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noneOption {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
text-align: start;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noneOption:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noneOptionSelected {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -135,7 +164,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: background 0.15s, color 0.15s, transform 0.2s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleBtn:hover {
|
.toggleBtn:hover {
|
||||||
@@ -149,23 +178,6 @@
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
|
||||||
display: inline-flex;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global([dir='rtl']) .chevron {
|
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevronOpen {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global([dir='rtl']) .chevronOpen {
|
|
||||||
transform: scaleX(-1) rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selectBtn {
|
.selectBtn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react'
|
import { ChevronDown, Minus, Plus, Search, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { Category } from '../types/category'
|
|
||||||
import { getAncestorIds, getChildren } from '../utils/categories'
|
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import treeStyles from './CategoryTree.module.css'
|
import treeStyles from './CategoryTree.module.css'
|
||||||
import styles from './CategoryTreeSelect.module.css'
|
import styles from './CategoryTreeSelect.module.css'
|
||||||
|
|
||||||
|
/** Minimal category shape for the shared tree picker. */
|
||||||
|
export interface CategoryTreeItem {
|
||||||
|
id: string
|
||||||
|
parentId: string | null
|
||||||
|
nameEn: string
|
||||||
|
nameFa: string
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryTreeSelectProps {
|
interface CategoryTreeSelectProps {
|
||||||
categories: Category[]
|
categories: CategoryTreeItem[]
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
id?: string
|
id?: string
|
||||||
/** Overlay floats over content; inFlow expands the parent (use inside modals). */
|
/** Show empty option + clear control. */
|
||||||
|
allowEmpty?: boolean
|
||||||
|
/** Label for the empty option when `allowEmpty` is true. */
|
||||||
|
emptyLabel?: string
|
||||||
|
/** Overlay floats over content; inFlow expands the parent. */
|
||||||
dropdownVariant?: 'overlay' | 'inFlow'
|
dropdownVariant?: 'overlay' | 'inFlow'
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryLabel(category: Category, isFa: boolean) {
|
function getChildren(categories: CategoryTreeItem[], parentId: string | null) {
|
||||||
|
return categories
|
||||||
|
.filter((category) => category.parentId === parentId)
|
||||||
|
.sort((a, b) => a.nameEn.localeCompare(b.nameEn))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAncestorIds(categories: CategoryTreeItem[], id: string) {
|
||||||
|
const byId = new Map(categories.map((category) => [category.id, category]))
|
||||||
|
const ids: string[] = []
|
||||||
|
let current = byId.get(id)
|
||||||
|
|
||||||
|
while (current?.parentId) {
|
||||||
|
ids.push(current.parentId)
|
||||||
|
current = byId.get(current.parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryLabel(category: CategoryTreeItem, isFa: boolean) {
|
||||||
if (isFa) {
|
if (isFa) {
|
||||||
return category.nameFa
|
return category.nameFa
|
||||||
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
|
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
|
||||||
@@ -30,7 +59,7 @@ function categoryLabel(category: Category, isFa: boolean) {
|
|||||||
: category.nameFa
|
: category.nameFa
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryMatches(category: Category, query: string) {
|
function categoryMatches(category: CategoryTreeItem, query: string) {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
if (!q) return true
|
if (!q) return true
|
||||||
return (
|
return (
|
||||||
@@ -40,7 +69,10 @@ function categoryMatches(category: Category, query: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleIdsForQuery(categories: Category[], query: string): Set<string> | null {
|
function visibleIdsForQuery(
|
||||||
|
categories: CategoryTreeItem[],
|
||||||
|
query: string,
|
||||||
|
): Set<string> | null {
|
||||||
const q = query.trim()
|
const q = query.trim()
|
||||||
if (!q) return null
|
if (!q) return null
|
||||||
|
|
||||||
@@ -67,7 +99,7 @@ function TreeNodes({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
categories: Category[]
|
categories: CategoryTreeItem[]
|
||||||
parentId: string | null
|
parentId: string | null
|
||||||
value: string
|
value: string
|
||||||
expandedIds: Set<string>
|
expandedIds: Set<string>
|
||||||
@@ -114,9 +146,7 @@ function TreeNodes({
|
|||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
onClick={() => onToggle(category.id)}
|
onClick={() => onToggle(category.id)}
|
||||||
>
|
>
|
||||||
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
|
{expanded ? <Minus size={14} /> : <Plus size={14} />}
|
||||||
<ChevronRight size={16} />
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -127,9 +157,19 @@ function TreeNodes({
|
|||||||
className={styles.selectBtn}
|
className={styles.selectBtn}
|
||||||
onClick={() => onSelect(category.id)}
|
onClick={() => onSelect(category.id)}
|
||||||
>
|
>
|
||||||
<span className={styles.name}>{primary}</span>
|
<span
|
||||||
|
className={styles.name}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
>
|
||||||
|
{primary}
|
||||||
|
</span>
|
||||||
{secondary ? (
|
{secondary ? (
|
||||||
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
|
<span
|
||||||
|
className={styles.nameSecondary}
|
||||||
|
dir={isFa ? 'ltr' : 'rtl'}
|
||||||
|
lang={isFa ? 'en' : 'fa'}
|
||||||
|
>
|
||||||
· {secondary}
|
· {secondary}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -165,6 +205,8 @@ export function CategoryTreeSelect({
|
|||||||
placeholder,
|
placeholder,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
id,
|
id,
|
||||||
|
allowEmpty = false,
|
||||||
|
emptyLabel,
|
||||||
dropdownVariant = 'overlay',
|
dropdownVariant = 'overlay',
|
||||||
}: CategoryTreeSelectProps) {
|
}: CategoryTreeSelectProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
@@ -178,6 +220,7 @@ export function CategoryTreeSelect({
|
|||||||
const selected = categories.find((category) => category.id === value)
|
const selected = categories.find((category) => category.id === value)
|
||||||
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
||||||
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
||||||
|
const emptyOptionLabel = emptyLabel ?? t('categories.select.none')
|
||||||
|
|
||||||
const visibleIds = useMemo(
|
const visibleIds = useMemo(
|
||||||
() => visibleIdsForQuery(categories, query),
|
() => visibleIdsForQuery(categories, query),
|
||||||
@@ -213,17 +256,17 @@ export function CategoryTreeSelect({
|
|||||||
})
|
})
|
||||||
}, [categories, value])
|
}, [categories, value])
|
||||||
|
|
||||||
function handleToggle(id: string) {
|
function handleToggle(toggleId: string) {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(toggleId)) next.delete(toggleId)
|
||||||
else next.add(id)
|
else next.add(toggleId)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOption(id: string) {
|
function selectOption(nextId: string) {
|
||||||
onChange(id)
|
onChange(nextId)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}
|
}
|
||||||
@@ -232,7 +275,11 @@ export function CategoryTreeSelect({
|
|||||||
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
|
<div
|
||||||
|
className={`${styles.wrapper} ${open ? styles.wrapperOpen : ''}`}
|
||||||
|
ref={containerRef}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
||||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
<Search size={16} className={styles.searchIcon} aria-hidden />
|
||||||
<input
|
<input
|
||||||
@@ -254,7 +301,7 @@ export function CategoryTreeSelect({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
/>
|
/>
|
||||||
{value && !open && !disabled ? (
|
{allowEmpty && value && !open && !disabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.clearBtn}
|
className={styles.clearBtn}
|
||||||
@@ -275,11 +322,20 @@ export function CategoryTreeSelect({
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
styles.panel,
|
styles.panel,
|
||||||
dropdownVariant === 'inFlow' ? styles.panelInFlow : '',
|
dropdownVariant === 'inFlow' ? styles.panelInFlow : styles.panelOverlay,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
>
|
>
|
||||||
|
{allowEmpty ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.noneOption} ${value === '' ? styles.noneOptionSelected : ''}`}
|
||||||
|
onClick={() => selectOption('')}
|
||||||
|
>
|
||||||
|
{emptyOptionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{!hasVisible ? (
|
{!hasVisible ? (
|
||||||
<p className={styles.noResults}>{t('categories.select.empty')}</p>
|
<p className={styles.noResults}>{t('categories.select.empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
.modal {
|
.modal {
|
||||||
width: min(560px, calc(100vw - 32px));
|
width: min(720px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
padding: 0 20px 20px;
|
padding: 4px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaGrid {
|
.metaGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px 16px;
|
gap: 16px 24px;
|
||||||
margin: 0 0 18px;
|
margin: 0 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaItem {
|
.metaItem {
|
||||||
@@ -18,44 +18,55 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metaItem dt {
|
.metaItem dt {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 6px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaItem dd {
|
.metaItem dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ltrValue {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentAt {
|
||||||
|
display: inline-block;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
.messageBlock {
|
.messageBlock {
|
||||||
padding-top: 16px;
|
padding: 18px 0 0;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.messageLabel {
|
.messageTitle {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-secondary);
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.messageText {
|
.messageText {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.65;
|
line-height: 1.75;
|
||||||
color: var(--text-primary);
|
color: var(--text-secondary);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 560px) {
|
||||||
.metaGrid {
|
.metaGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,15 @@ function formatDateTime(value: string, locale: 'en' | 'fa') {
|
|||||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||||
return {
|
return {
|
||||||
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
|
date: d.toLocaleDateString(intlLocale, {
|
||||||
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
}),
|
||||||
|
time: d.toLocaleTimeString(intlLocale, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +83,9 @@ export function ContactSubmissionDetailModal({
|
|||||||
aria-labelledby="contact-submission-title"
|
aria-labelledby="contact-submission-title"
|
||||||
>
|
>
|
||||||
<div className={modalStyles.header}>
|
<div className={modalStyles.header}>
|
||||||
<div>
|
<h2 id="contact-submission-title" className={modalStyles.title}>
|
||||||
<h2 id="contact-submission-title" className={modalStyles.title}>
|
{t('website.contact.detailTitle')}
|
||||||
{t('website.contact.detailTitle')}
|
</h2>
|
||||||
</h2>
|
|
||||||
<p className={modalStyles.subtitle}>{submission.title}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={modalStyles.closeBtn}
|
className={modalStyles.closeBtn}
|
||||||
@@ -100,26 +104,26 @@ export function ContactSubmissionDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metaItem}>
|
<div className={styles.metaItem}>
|
||||||
<dt>{t('website.contact.col.email')}</dt>
|
<dt>{t('website.contact.col.email')}</dt>
|
||||||
<dd>{submission.email ?? '—'}</dd>
|
<dd className={styles.ltrValue}>{submission.email ?? '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metaItem}>
|
<div className={styles.metaItem}>
|
||||||
<dt>{t('website.contact.col.cell')}</dt>
|
<dt>{t('website.contact.col.cell')}</dt>
|
||||||
<dd>
|
<dd className={styles.ltrValue}>
|
||||||
{submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'}
|
{submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metaItem}>
|
<div className={styles.metaItem}>
|
||||||
<dt>{t('website.contact.col.date')}</dt>
|
<dt>{t('website.contact.detailSentAt')}</dt>
|
||||||
<dd>{date}</dd>
|
<dd>
|
||||||
</div>
|
<span className={styles.sentAt} dir="ltr">
|
||||||
<div className={styles.metaItem}>
|
{time ? `${date} · ${time}` : date}
|
||||||
<dt>{t('website.contact.col.time')}</dt>
|
</span>
|
||||||
<dd>{time || '—'}</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className={styles.messageBlock}>
|
<div className={styles.messageBlock}>
|
||||||
<h3 className={styles.messageLabel}>{t('website.contact.detailMessage')}</h3>
|
<h3 className={styles.messageTitle}>{submission.title}</h3>
|
||||||
<p className={styles.messageText}>{submission.text}</p>
|
<p className={styles.messageText}>{submission.text}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import type { ProductVariant } from '../types/productVariant'
|
import type { ProductVariant } from '../types/productVariant'
|
||||||
import { buildVariantLabel } from '../types/productVariant'
|
import { buildVariantLabel } from '../types/productVariant'
|
||||||
import type { ProductVariantAttribute } from '../services/productVariantService'
|
import type { ProductVariantAttribute } from '../services/productVariantService'
|
||||||
|
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
|
||||||
import styles from './VariationsModal.module.css'
|
import styles from './VariationsModal.module.css'
|
||||||
|
|
||||||
interface CreateProductVariantModalProps {
|
interface CreateProductVariantModalProps {
|
||||||
@@ -27,6 +29,7 @@ export function CreateProductVariantModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: CreateProductVariantModalProps) {
|
}: CreateProductVariantModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [variationId, setVariationId] = useState('')
|
const [variationId, setVariationId] = useState('')
|
||||||
@@ -150,7 +153,7 @@ export function CreateProductVariantModal({
|
|||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option.id} value={option.id}>
|
<option key={option.id} value={option.id}>
|
||||||
{option.label}
|
{localizeVariationOptionLabel(option.label, t)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -155,6 +155,31 @@
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usedCheckLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usedCheck {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usedCheck:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.compactInput:disabled {
|
.compactInput:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { PackagePlus, Plus, X } from 'lucide-react'
|
import { PackagePlus, Plus, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { listAllProducts } from '../services/productService'
|
import { listAllProducts, listProducts } from '../services/productService'
|
||||||
import {
|
import {
|
||||||
getProductVariationValues,
|
getProductVariationValues,
|
||||||
type ProductVariationSelection,
|
type ProductVariationSelection,
|
||||||
@@ -22,11 +22,15 @@ import {
|
|||||||
getVariationOptions,
|
getVariationOptions,
|
||||||
type StoreItemDraftRow,
|
type StoreItemDraftRow,
|
||||||
} from '../utils/storeItemRows'
|
} from '../utils/storeItemRows'
|
||||||
|
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
|
||||||
import {
|
import {
|
||||||
ConfirmBulkAddStoreItemsModal,
|
ConfirmBulkAddStoreItemsModal,
|
||||||
type BulkAddResult,
|
type BulkAddResult,
|
||||||
} from './ConfirmBulkAddStoreItemsModal'
|
} from './ConfirmBulkAddStoreItemsModal'
|
||||||
import { ProductSearchSelect } from './ProductSearchSelect'
|
import {
|
||||||
|
ProductSearchSelect,
|
||||||
|
type ProductSearchOption,
|
||||||
|
} from './ProductSearchSelect'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import modalStyles from './VariationsModal.module.css'
|
import modalStyles from './VariationsModal.module.css'
|
||||||
import rowStyles from './CreateStoreItemsModal.module.css'
|
import rowStyles from './CreateStoreItemsModal.module.css'
|
||||||
@@ -40,6 +44,7 @@ interface CreateStoreItemsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_MS = 220
|
const ANIMATION_MS = 220
|
||||||
|
const PRODUCT_SEARCH_PAGE_SIZE = 20
|
||||||
|
|
||||||
export function CreateStoreItemsModal({
|
export function CreateStoreItemsModal({
|
||||||
open,
|
open,
|
||||||
@@ -53,20 +58,29 @@ export function CreateStoreItemsModal({
|
|||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([])
|
const [searchResults, setSearchResults] = useState<ProductSearchOption[]>([])
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<ProductSearchOption | null>(null)
|
||||||
const [productId, setProductId] = useState('')
|
const [productId, setProductId] = useState('')
|
||||||
|
const [isUsed, setIsUsed] = useState(false)
|
||||||
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
||||||
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
|
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
|
||||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
|
const [isSearchingProducts, setIsSearchingProducts] = useState(false)
|
||||||
const [isLoadingVariations, setIsLoadingVariations] = useState(false)
|
const [isLoadingVariations, setIsLoadingVariations] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [isBulkCreating, setIsBulkCreating] = useState(false)
|
const [isBulkCreating, setIsBulkCreating] = useState(false)
|
||||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||||
const [bulkResult, setBulkResult] = useState<BulkAddResult | null>(null)
|
const [bulkResult, setBulkResult] = useState<BulkAddResult | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const searchControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
const busy = isSubmitting || isBulkCreating
|
const busy = isSubmitting || isBulkCreating
|
||||||
|
|
||||||
|
const productOptions = useMemo(() => {
|
||||||
|
if (!selectedProduct) return searchResults
|
||||||
|
if (searchResults.some((item) => item.id === selectedProduct.id)) return searchResults
|
||||||
|
return [selectedProduct, ...searchResults]
|
||||||
|
}, [searchResults, selectedProduct])
|
||||||
|
|
||||||
const formVariations = useMemo(
|
const formVariations = useMemo(
|
||||||
() => variations.filter((variation) => getVariationOptions(variation).length > 0),
|
() => variations.filter((variation) => getVariationOptions(variation).length > 0),
|
||||||
[variations],
|
[variations],
|
||||||
@@ -118,6 +132,9 @@ export function CreateStoreItemsModal({
|
|||||||
setMounted(false)
|
setMounted(false)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setProductId('')
|
setProductId('')
|
||||||
|
setIsUsed(false)
|
||||||
|
setSelectedProduct(null)
|
||||||
|
setSearchResults([])
|
||||||
setVariations([])
|
setVariations([])
|
||||||
setRows([])
|
setRows([])
|
||||||
setError('')
|
setError('')
|
||||||
@@ -129,13 +146,54 @@ export function CreateStoreItemsModal({
|
|||||||
}, [open, mounted])
|
}, [open, mounted])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
searchControllerRef.current?.abort()
|
||||||
const controller = new AbortController()
|
return
|
||||||
void loadProducts(controller.signal)
|
}
|
||||||
return () => controller.abort()
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
const searchProducts = useCallback(
|
||||||
|
async (query: string) => {
|
||||||
|
searchControllerRef.current?.abort()
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
setSearchResults([])
|
||||||
|
setIsSearchingProducts(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
searchControllerRef.current = controller
|
||||||
|
setIsSearchingProducts(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listProducts(1, PRODUCT_SEARCH_PAGE_SIZE, controller.signal, {
|
||||||
|
name: query,
|
||||||
|
})
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
setSearchResults(
|
||||||
|
data.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.nameEn,
|
||||||
|
nameFa: item.nameFa,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
setSearchResults([])
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(t('storeItems.create.errorProducts'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) setIsSearchingProducts(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
setVariations([])
|
setVariations([])
|
||||||
@@ -148,31 +206,6 @@ export function CreateStoreItemsModal({
|
|||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [productId])
|
}, [productId])
|
||||||
|
|
||||||
async function loadProducts(signal?: AbortSignal) {
|
|
||||||
setIsLoadingProducts(true)
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const items = await listAllProducts(signal)
|
|
||||||
setProducts(
|
|
||||||
items.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
nameFa: item.nameFa,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setError(err.message)
|
|
||||||
} else {
|
|
||||||
setError(t('storeItems.create.errorProducts'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoadingProducts(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadVariations(nextProductId: string, signal?: AbortSignal) {
|
async function loadVariations(nextProductId: string, signal?: AbortSignal) {
|
||||||
setIsLoadingVariations(true)
|
setIsLoadingVariations(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -213,6 +246,15 @@ export function CreateStoreItemsModal({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProductId(nextProductId)
|
setProductId(nextProductId)
|
||||||
|
if (!nextProductId) {
|
||||||
|
setSelectedProduct(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const match =
|
||||||
|
productOptions.find((item) => item.id === nextProductId) ??
|
||||||
|
searchResults.find((item) => item.id === nextProductId) ??
|
||||||
|
null
|
||||||
|
setSelectedProduct(match)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRow(rowId: string, patch: Partial<StoreItemDraftRow>) {
|
function updateRow(rowId: string, patch: Partial<StoreItemDraftRow>) {
|
||||||
@@ -281,6 +323,7 @@ export function CreateStoreItemsModal({
|
|||||||
try {
|
try {
|
||||||
await batchCreateStoreItems({
|
await batchCreateStoreItems({
|
||||||
productId,
|
productId,
|
||||||
|
isUsed,
|
||||||
items: buildPayload(),
|
items: buildPayload(),
|
||||||
})
|
})
|
||||||
onCreated?.()
|
onCreated?.()
|
||||||
@@ -297,7 +340,7 @@ export function CreateStoreItemsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openBulkConfirm() {
|
function openBulkConfirm() {
|
||||||
if (busy || isLoadingProducts || products.length === 0) return
|
if (busy) return
|
||||||
setBulkResult(null)
|
setBulkResult(null)
|
||||||
setBulkConfirmOpen(true)
|
setBulkConfirmOpen(true)
|
||||||
}
|
}
|
||||||
@@ -315,7 +358,7 @@ export function CreateStoreItemsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddAllProducts() {
|
async function handleAddAllProducts() {
|
||||||
if (isBulkCreating || isLoadingProducts) return
|
if (isBulkCreating) return
|
||||||
|
|
||||||
setIsBulkCreating(true)
|
setIsBulkCreating(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -327,14 +370,11 @@ export function CreateStoreItemsModal({
|
|||||||
const skip = new Set(existingProductIds)
|
const skip = new Set(existingProductIds)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const catalog =
|
const catalog = (await listAllProducts()).map((item) => ({
|
||||||
products.length > 0
|
id: item.id,
|
||||||
? products
|
title: item.title,
|
||||||
: (await listAllProducts()).map((item) => ({
|
nameFa: item.nameFa,
|
||||||
id: item.id,
|
}))
|
||||||
title: item.title,
|
|
||||||
nameFa: item.nameFa,
|
|
||||||
}))
|
|
||||||
|
|
||||||
for (const product of catalog) {
|
for (const product of catalog) {
|
||||||
if (skip.has(product.id)) {
|
if (skip.has(product.id)) {
|
||||||
@@ -412,15 +452,28 @@ export function CreateStoreItemsModal({
|
|||||||
<div className={modalStyles.field}>
|
<div className={modalStyles.field}>
|
||||||
<ProductSearchSelect
|
<ProductSearchSelect
|
||||||
id="store-item-product"
|
id="store-item-product"
|
||||||
options={products}
|
options={productOptions}
|
||||||
value={productId}
|
value={productId}
|
||||||
onChange={handleProductChange}
|
onChange={handleProductChange}
|
||||||
|
onQueryChange={(query) => void searchProducts(query)}
|
||||||
|
isSearching={isSearchingProducts}
|
||||||
placeholder={t('storeItems.create.productPlaceholder')}
|
placeholder={t('storeItems.create.productPlaceholder')}
|
||||||
disabled={isLoadingProducts || busy}
|
disabled={busy}
|
||||||
aria-label={t('storeItems.create.productPlaceholder')}
|
aria-label={t('storeItems.create.productPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className={rowStyles.usedCheckLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={rowStyles.usedCheck}
|
||||||
|
checked={isUsed}
|
||||||
|
onChange={(e) => setIsUsed(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>{t('storeItems.create.isUsed')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{productId && isLoadingVariations && (
|
{productId && isLoadingVariations && (
|
||||||
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
||||||
)}
|
)}
|
||||||
@@ -462,7 +515,7 @@ export function CreateStoreItemsModal({
|
|||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option.id} value={option.id}>
|
<option key={option.id} value={option.id}>
|
||||||
{option.label}
|
{localizeVariationOptionLabel(option.label, t)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -537,7 +590,7 @@ export function CreateStoreItemsModal({
|
|||||||
type="button"
|
type="button"
|
||||||
className={rowStyles.footerBulkBtn}
|
className={rowStyles.footerBulkBtn}
|
||||||
onClick={openBulkConfirm}
|
onClick={openBulkConfirm}
|
||||||
disabled={busy || isLoadingProducts || products.length === 0}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<PackagePlus size={16} />
|
<PackagePlus size={16} />
|
||||||
{t('storeItems.create.addAllProducts')}
|
{t('storeItems.create.addAllProducts')}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartDay {
|
.chartDay {
|
||||||
gap: 1px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartMonth {
|
.chartMonth {
|
||||||
@@ -172,19 +172,26 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.barWrap {
|
.barWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 0;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chartMonth .barWrap {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.barWrap:hover,
|
.barWrap:hover,
|
||||||
.barWrap:focus-within {
|
.barWrap:focus-within {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -219,24 +226,24 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 6px 18px rgba(31, 38, 135, 0.18);
|
box-shadow: 0 6px 18px rgba(31, 38, 135, 0.18);
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 0;
|
flex: none;
|
||||||
width: 100%;
|
width: 40%;
|
||||||
max-width: 8px;
|
max-width: 7px;
|
||||||
min-width: 2px;
|
min-width: 4px;
|
||||||
border-radius: 3px 3px 1px 1px;
|
border-radius: 3px 3px 1px 1px;
|
||||||
transition: height 0.3s ease;
|
transition: height 0.3s ease;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartMonth .bar {
|
.chartMonth .bar {
|
||||||
flex: none;
|
width: 12px;
|
||||||
width: 10px;
|
|
||||||
max-width: none;
|
max-width: none;
|
||||||
min-width: 10px;
|
min-width: 12px;
|
||||||
border-radius: 4px 4px 2px 2px;
|
border-radius: 4px 4px 2px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +253,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.barMonth {
|
.barMonth {
|
||||||
width: 10px;
|
width: 12px;
|
||||||
max-width: 10px;
|
max-width: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
|||||||
@@ -43,15 +43,19 @@ function formatDayLabel(dateKey: string, locale: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tooltip label with month context (e.g. ۶ شهریور / Sep 6). */
|
/** Tooltip label with month context (e.g. ۲ مهر / Sep 6). */
|
||||||
function formatDayTooltip(dateKey: string, locale: string): string {
|
function formatDayTooltip(dateKey: string, locale: string): string {
|
||||||
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
numberingSystem: 'latn',
|
...(locale === 'fa' ? {} : { numberingSystem: 'latn' as const }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTooltipCount(value: number, locale: string): string {
|
||||||
|
return value.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')
|
||||||
|
}
|
||||||
|
|
||||||
/** Full calendar date for subtitle ranges (e.g. ۴ شهریور ۱۴۰۵ / September 4, 2026). */
|
/** Full calendar date for subtitle ranges (e.g. ۴ شهریور ۱۴۰۵ / September 4, 2026). */
|
||||||
function formatRangeDate(dateKey: string, locale: string): string {
|
function formatRangeDate(dateKey: string, locale: string): string {
|
||||||
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
return parseDateKey(dateKey).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||||
@@ -236,12 +240,12 @@ export function DailyActivityChart({
|
|||||||
const primaryTip = t(primaryBarTitleKey, {
|
const primaryTip = t(primaryBarTitleKey, {
|
||||||
day: item.label,
|
day: item.label,
|
||||||
month: item.label,
|
month: item.label,
|
||||||
count: item.primary,
|
count: formatTooltipCount(item.primary, locale),
|
||||||
})
|
})
|
||||||
const secondaryTip = t(secondaryBarTitleKey, {
|
const secondaryTip = t(secondaryBarTitleKey, {
|
||||||
day: item.label,
|
day: item.label,
|
||||||
month: item.label,
|
month: item.label,
|
||||||
count: item.secondary,
|
count: formatTooltipCount(item.secondary, locale),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -254,7 +258,13 @@ export function DailyActivityChart({
|
|||||||
height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0),
|
height: Math.max(primaryHeight, item.primary > 0 ? 4 : 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.barTooltip}>{primaryTip}</span>
|
<span
|
||||||
|
className={styles.barTooltip}
|
||||||
|
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||||
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
{primaryTip}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showSecondary ? (
|
{showSecondary ? (
|
||||||
@@ -265,7 +275,13 @@ export function DailyActivityChart({
|
|||||||
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
|
height: Math.max(secondaryHeight, item.secondary > 0 ? 4 : 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.barTooltip}>{secondaryTip}</span>
|
<span
|
||||||
|
className={styles.barTooltip}
|
||||||
|
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||||
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
{secondaryTip}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -289,11 +305,11 @@ export function DailyActivityChart({
|
|||||||
const tooltipDay = formatDayTooltip(item.date, locale)
|
const tooltipDay = formatDayTooltip(item.date, locale)
|
||||||
const primaryTip = t(primaryBarTitleKey, {
|
const primaryTip = t(primaryBarTitleKey, {
|
||||||
day: tooltipDay,
|
day: tooltipDay,
|
||||||
count: item.count,
|
count: formatTooltipCount(item.count, locale),
|
||||||
})
|
})
|
||||||
const secondaryTip = t(secondaryBarTitleKey, {
|
const secondaryTip = t(secondaryBarTitleKey, {
|
||||||
day: tooltipDay,
|
day: tooltipDay,
|
||||||
count: secondaryCount,
|
count: formatTooltipCount(secondaryCount, locale),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -306,7 +322,13 @@ export function DailyActivityChart({
|
|||||||
height: Math.max(primaryHeight, item.count > 0 ? 4 : 0),
|
height: Math.max(primaryHeight, item.count > 0 ? 4 : 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.barTooltip}>{primaryTip}</span>
|
<span
|
||||||
|
className={styles.barTooltip}
|
||||||
|
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||||
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
{primaryTip}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showSecondary ? (
|
{showSecondary ? (
|
||||||
@@ -317,7 +339,13 @@ export function DailyActivityChart({
|
|||||||
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
|
height: Math.max(secondaryHeight, secondaryCount > 0 ? 4 : 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.barTooltip}>{secondaryTip}</span>
|
<span
|
||||||
|
className={styles.barTooltip}
|
||||||
|
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||||
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
{secondaryTip}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getVariationOptions,
|
getVariationOptions,
|
||||||
type StoreItemDraftRow,
|
type StoreItemDraftRow,
|
||||||
} from '../utils/storeItemRows'
|
} from '../utils/storeItemRows'
|
||||||
|
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import modalStyles from './VariationsModal.module.css'
|
import modalStyles from './VariationsModal.module.css'
|
||||||
import rowStyles from './CreateStoreItemsModal.module.css'
|
import rowStyles from './CreateStoreItemsModal.module.css'
|
||||||
@@ -72,6 +73,7 @@ export function EditStoreItemsModal({
|
|||||||
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
||||||
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
|
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
|
||||||
const [removedIds, setRemovedIds] = useState<string[]>([])
|
const [removedIds, setRemovedIds] = useState<string[]>([])
|
||||||
|
const [isUsed, setIsUsed] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -123,6 +125,7 @@ export function EditStoreItemsModal({
|
|||||||
setClosing(false)
|
setClosing(false)
|
||||||
setRemovedIds([])
|
setRemovedIds([])
|
||||||
setRows(itemsToRows(items))
|
setRows(itemsToRows(items))
|
||||||
|
setIsUsed(items.some((item) => item.isUsed))
|
||||||
setError('')
|
setError('')
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
void loadVariations(productId, controller.signal)
|
void loadVariations(productId, controller.signal)
|
||||||
@@ -223,6 +226,7 @@ export function EditStoreItemsModal({
|
|||||||
try {
|
try {
|
||||||
const data = await syncProductStoreItems({
|
const data = await syncProductStoreItems({
|
||||||
productId,
|
productId,
|
||||||
|
isUsed,
|
||||||
items: rows.map((row) => ({
|
items: rows.map((row) => ({
|
||||||
id: row.storeItemId,
|
id: row.storeItemId,
|
||||||
selections: formVariations.flatMap((variation) => {
|
selections: formVariations.flatMap((variation) => {
|
||||||
@@ -294,6 +298,17 @@ export function EditStoreItemsModal({
|
|||||||
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<label className={rowStyles.usedCheckLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={rowStyles.usedCheck}
|
||||||
|
checked={isUsed}
|
||||||
|
onChange={(e) => setIsUsed(e.target.checked)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<span>{t('storeItems.create.isUsed')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={rowStyles.itemRowHeader}
|
className={rowStyles.itemRowHeader}
|
||||||
style={{ gridTemplateColumns: gridTemplate }}
|
style={{ gridTemplateColumns: gridTemplate }}
|
||||||
@@ -329,7 +344,7 @@ export function EditStoreItemsModal({
|
|||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option.id} value={option.id}>
|
<option key={option.id} value={option.id}>
|
||||||
{option.label}
|
{localizeVariationOptionLabel(option.label, t)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
|||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('website.linkGenerator.pickCategory')}
|
placeholder={t('website.linkGenerator.pickCategory')}
|
||||||
dropdownVariant="inFlow"
|
allowEmpty={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,6 +98,16 @@
|
|||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cropAreaTransparent {
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #e2e8f0 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #e2e8f0 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #e2e8f0 75%);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.cropControls {
|
.cropControls {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -186,11 +196,6 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact .changeBtn {
|
|
||||||
align-self: stretch;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact .uploadZone {
|
.compact .uploadZone {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -202,8 +207,36 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact .cropControls {
|
||||||
|
padding: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .zoomLabel {
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .cropActions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .cancelBtn,
|
||||||
|
.compact .applyBtn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.compact .changeBtn {
|
.compact .changeBtn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
justify-content: center;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ interface ImageCropperProps {
|
|||||||
onChange: (value: string | null) => void
|
onChange: (value: string | null) => void
|
||||||
/** Crop aspect ratio. Defaults to square (1). */
|
/** Crop aspect ratio. Defaults to square (1). */
|
||||||
aspect?: number
|
aspect?: number
|
||||||
|
/**
|
||||||
|
* Output format. When omitted, follows the uploaded file
|
||||||
|
* (PNG stays PNG with transparent padding; other images become JPEG with white padding).
|
||||||
|
*/
|
||||||
outputFormat?: 'jpeg' | 'png'
|
outputFormat?: 'jpeg' | 'png'
|
||||||
|
/** Fill color for JPEG and for crop areas outside the image. */
|
||||||
|
fillColor?: string
|
||||||
|
/** Minimum zoom. Values below 1 shrink the image inside the crop frame. */
|
||||||
|
minZoom?: number
|
||||||
|
maxZoom?: number
|
||||||
accept?: string
|
accept?: string
|
||||||
uploadLabel?: string
|
uploadLabel?: string
|
||||||
hint?: string
|
hint?: string
|
||||||
@@ -19,11 +28,25 @@ interface ImageCropperProps {
|
|||||||
compact?: boolean
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPngSource(file: File): boolean {
|
||||||
|
return (
|
||||||
|
file.type === 'image/png' ||
|
||||||
|
file.name.toLowerCase().endsWith('.png')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPngDataUrl(src: string): boolean {
|
||||||
|
return /^data:image\/png/i.test(src)
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageCropper({
|
export function ImageCropper({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
aspect = 1,
|
aspect = 1,
|
||||||
outputFormat = 'jpeg',
|
outputFormat,
|
||||||
|
fillColor,
|
||||||
|
minZoom = 0.4,
|
||||||
|
maxZoom = 3,
|
||||||
accept = 'image/*',
|
accept = 'image/*',
|
||||||
uploadLabel,
|
uploadLabel,
|
||||||
hint,
|
hint,
|
||||||
@@ -35,10 +58,17 @@ export function ImageCropper({
|
|||||||
const resolvedHint = hint ?? t('products.form.thumbnailHint')
|
const resolvedHint = hint ?? t('products.form.thumbnailHint')
|
||||||
const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange')
|
const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange')
|
||||||
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||||
|
const [sourceIsPng, setSourceIsPng] = useState(false)
|
||||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
|
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
|
||||||
|
|
||||||
|
const resolvedFormat: 'jpeg' | 'png' =
|
||||||
|
outputFormat ?? (sourceIsPng ? 'png' : 'jpeg')
|
||||||
|
const resolvedFillColor =
|
||||||
|
fillColor ?? (resolvedFormat === 'jpeg' ? '#ffffff' : undefined)
|
||||||
|
const useTransparentPad = resolvedFormat === 'png' && !resolvedFillColor
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCrop({ x: 0, y: 0 })
|
setCrop({ x: 0, y: 0 })
|
||||||
setZoom(1)
|
setZoom(1)
|
||||||
@@ -52,6 +82,8 @@ export function ImageCropper({
|
|||||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
const png = isPngSource(file)
|
||||||
|
setSourceIsPng(png)
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
setImageSrc(reader.result as string)
|
setImageSrc(reader.result as string)
|
||||||
@@ -65,7 +97,10 @@ export function ImageCropper({
|
|||||||
|
|
||||||
async function applyCrop() {
|
async function applyCrop() {
|
||||||
if (!imageSrc || !croppedArea) return
|
if (!imageSrc || !croppedArea) return
|
||||||
const cropped = await getCroppedImage(imageSrc, croppedArea, outputFormat)
|
const format: 'jpeg' | 'png' =
|
||||||
|
outputFormat ?? (isPngDataUrl(imageSrc) || sourceIsPng ? 'png' : 'jpeg')
|
||||||
|
const fill = fillColor ?? (format === 'jpeg' ? '#ffffff' : undefined)
|
||||||
|
const cropped = await getCroppedImage(imageSrc, croppedArea, format, fill)
|
||||||
onChange(cropped)
|
onChange(cropped)
|
||||||
setImageSrc(null)
|
setImageSrc(null)
|
||||||
setZoom(1)
|
setZoom(1)
|
||||||
@@ -101,6 +136,10 @@ export function ImageCropper({
|
|||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cropSurfaceStyle: React.CSSProperties = useTransparentPad
|
||||||
|
? { ...frameStyle }
|
||||||
|
: { ...frameStyle, background: resolvedFillColor ?? '#1e293b' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${compact ? styles.compact : ''}`}>
|
<div className={`${styles.wrapper} ${compact ? styles.compact : ''}`}>
|
||||||
{value && !imageSrc && (
|
{value && !imageSrc && (
|
||||||
@@ -128,16 +167,27 @@ export function ImageCropper({
|
|||||||
|
|
||||||
{imageSrc && (
|
{imageSrc && (
|
||||||
<div className={styles.cropPanel}>
|
<div className={styles.cropPanel}>
|
||||||
<div className={styles.cropArea} style={frameStyle}>
|
<div
|
||||||
|
className={`${styles.cropArea} ${useTransparentPad ? styles.cropAreaTransparent : ''}`}
|
||||||
|
style={cropSurfaceStyle}
|
||||||
|
>
|
||||||
<Cropper
|
<Cropper
|
||||||
key={String(aspect)}
|
key={String(aspect)}
|
||||||
image={imageSrc}
|
image={imageSrc}
|
||||||
crop={crop}
|
crop={crop}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
|
minZoom={minZoom}
|
||||||
|
maxZoom={maxZoom}
|
||||||
aspect={aspect}
|
aspect={aspect}
|
||||||
onCropChange={setCrop}
|
onCropChange={setCrop}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
onCropComplete={onCropComplete}
|
onCropComplete={onCropComplete}
|
||||||
|
restrictPosition={minZoom >= 1}
|
||||||
|
style={{
|
||||||
|
containerStyle: useTransparentPad
|
||||||
|
? { background: 'transparent' }
|
||||||
|
: { background: resolvedFillColor ?? '#1e293b' },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.cropControls}>
|
<div className={styles.cropControls}>
|
||||||
@@ -145,8 +195,8 @@ export function ImageCropper({
|
|||||||
{t('products.form.zoom')}
|
{t('products.form.zoom')}
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={1}
|
min={minZoom}
|
||||||
max={3}
|
max={maxZoom}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
value={zoom}
|
value={zoom}
|
||||||
onChange={(e) => setZoom(Number(e.target.value))}
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
|||||||
@@ -135,6 +135,15 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockHeaderActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blockTitle {
|
.blockTitle {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Pencil, Plus, X } from 'lucide-react'
|
import { Pencil, Plus, X } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { InvoiceItemTemplate } from '../types/invoice'
|
import type { InvoiceAccountTemplate, InvoiceItemTemplate } from '../types/invoice'
|
||||||
import { formatIrtInput } from '../utils/irtPrice'
|
import { formatIrtInput } from '../utils/irtPrice'
|
||||||
import type { DraftAccount, DraftKeyPoint, DraftLineItem } from '../utils/invoiceDraft'
|
import type { DraftAccount, DraftKeyPoint, DraftLineItem, DraftPaymentMethod } from '../utils/invoiceDraft'
|
||||||
import {
|
import {
|
||||||
|
draftAccountFromAccountTemplate,
|
||||||
draftItemFromItemTemplate,
|
draftItemFromItemTemplate,
|
||||||
emptyDraftAccount,
|
emptyDraftAccount,
|
||||||
emptyDraftItem,
|
emptyDraftItem,
|
||||||
emptyDraftKeyPoint,
|
emptyDraftKeyPoint,
|
||||||
|
emptyDraftPaymentMethod,
|
||||||
} from '../utils/invoiceDraft'
|
} from '../utils/invoiceDraft'
|
||||||
import formStyles from './InvoiceForm.module.css'
|
import formStyles from './InvoiceForm.module.css'
|
||||||
import styles from './InvoiceDraftFields.module.css'
|
import styles from './InvoiceDraftFields.module.css'
|
||||||
@@ -19,32 +21,50 @@ function focusKeyPointInput(index: number) {
|
|||||||
el?.select()
|
el?.select()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusPaymentMethodInput(index: number) {
|
||||||
|
const el = document.querySelector<HTMLInputElement>(`input[data-payment-method-index="${index}"]`)
|
||||||
|
el?.focus()
|
||||||
|
el?.select()
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
itemTemplates: InvoiceItemTemplate[]
|
itemTemplates: InvoiceItemTemplate[]
|
||||||
|
accountTemplates: InvoiceAccountTemplate[]
|
||||||
items: DraftLineItem[]
|
items: DraftLineItem[]
|
||||||
keyPoints: DraftKeyPoint[]
|
keyPoints: DraftKeyPoint[]
|
||||||
|
paymentMethods: DraftPaymentMethod[]
|
||||||
accounts: DraftAccount[]
|
accounts: DraftAccount[]
|
||||||
selectedItemTemplateId: string
|
selectedItemTemplateId: string
|
||||||
onSelectedItemTemplateId: (id: string) => void
|
onSelectedItemTemplateId: (id: string) => void
|
||||||
|
selectedAccountTemplateId: string
|
||||||
|
onSelectedAccountTemplateId: (id: string) => void
|
||||||
onItemsChange: (items: DraftLineItem[]) => void
|
onItemsChange: (items: DraftLineItem[]) => void
|
||||||
onKeyPointsChange: (points: DraftKeyPoint[]) => void
|
onKeyPointsChange: (points: DraftKeyPoint[]) => void
|
||||||
|
onPaymentMethodsChange: (methods: DraftPaymentMethod[]) => void
|
||||||
onAccountsChange: (accounts: DraftAccount[]) => void
|
onAccountsChange: (accounts: DraftAccount[]) => void
|
||||||
showItemTemplatePicker?: boolean
|
showItemTemplatePicker?: boolean
|
||||||
|
showAccountTemplatePicker?: boolean
|
||||||
/** Rendered after line items (before key points / accounts), e.g. totals row. */
|
/** Rendered after line items (before key points / accounts), e.g. totals row. */
|
||||||
afterItems?: ReactNode
|
afterItems?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceDraftFields({
|
export function InvoiceDraftFields({
|
||||||
itemTemplates,
|
itemTemplates,
|
||||||
|
accountTemplates,
|
||||||
items,
|
items,
|
||||||
keyPoints,
|
keyPoints,
|
||||||
|
paymentMethods,
|
||||||
accounts,
|
accounts,
|
||||||
selectedItemTemplateId,
|
selectedItemTemplateId,
|
||||||
onSelectedItemTemplateId,
|
onSelectedItemTemplateId,
|
||||||
|
selectedAccountTemplateId,
|
||||||
|
onSelectedAccountTemplateId,
|
||||||
onItemsChange,
|
onItemsChange,
|
||||||
onKeyPointsChange,
|
onKeyPointsChange,
|
||||||
|
onPaymentMethodsChange,
|
||||||
onAccountsChange,
|
onAccountsChange,
|
||||||
showItemTemplatePicker = true,
|
showItemTemplatePicker = true,
|
||||||
|
showAccountTemplatePicker = true,
|
||||||
afterItems,
|
afterItems,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
@@ -70,6 +90,13 @@ export function InvoiceDraftFields({
|
|||||||
onSelectedItemTemplateId('')
|
onSelectedItemTemplateId('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addFromAccountTemplate() {
|
||||||
|
const template = accountTemplates.find((entry) => entry.id === selectedAccountTemplateId)
|
||||||
|
if (!template) return
|
||||||
|
onAccountsChange([...accounts, draftAccountFromAccountTemplate(template)])
|
||||||
|
onSelectedAccountTemplateId('')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.itemsStack}>
|
<div className={styles.itemsStack}>
|
||||||
@@ -250,16 +277,107 @@ export function InvoiceDraftFields({
|
|||||||
|
|
||||||
<div className={styles.blockSection}>
|
<div className={styles.blockSection}>
|
||||||
<div className={styles.blockHeader}>
|
<div className={styles.blockHeader}>
|
||||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
<h4 className={styles.blockTitle}>{t('invoiceDraft.paymentMethods')}</h4>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||||
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
|
onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t('invoiceDraft.addAccount')}
|
{t('invoiceDraft.addPaymentMethod')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{paymentMethods.length === 0 ? (
|
||||||
|
<p className={styles.templateHint}>{t('invoiceDraft.noPaymentMethods')}</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.repeatStack}>
|
||||||
|
{paymentMethods.map((method, index) => (
|
||||||
|
<div key={method.key} className={styles.repeatRow}>
|
||||||
|
<div className={`${formStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
|
||||||
|
<input
|
||||||
|
data-payment-method-index={index}
|
||||||
|
value={method.text}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPaymentMethodsChange(
|
||||||
|
paymentMethods.map((m) =>
|
||||||
|
m.key === method.key ? { ...m, text: e.target.value } : m,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const next = index + 1
|
||||||
|
if (next < paymentMethods.length) {
|
||||||
|
focusPaymentMethodInput(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])
|
||||||
|
window.setTimeout(() => focusPaymentMethodInput(next), 0)
|
||||||
|
}}
|
||||||
|
placeholder={t('invoiceDraft.paymentMethodPlaceholder')}
|
||||||
|
aria-label={t('invoiceDraft.paymentMethodAria', { index: index + 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeFieldBtn}
|
||||||
|
onClick={() =>
|
||||||
|
onPaymentMethodsChange(paymentMethods.filter((m) => m.key !== method.key))
|
||||||
|
}
|
||||||
|
aria-label={t('invoiceDraft.removePaymentMethod')}
|
||||||
|
title={t('invoiceDraft.removePaymentMethod')}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.blockSection}>
|
||||||
|
<div className={styles.blockHeader}>
|
||||||
|
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
||||||
|
<div className={styles.blockHeaderActions}>
|
||||||
|
{showAccountTemplatePicker ? (
|
||||||
|
<div className={styles.itemActionsLeft}>
|
||||||
|
<select
|
||||||
|
id="account-template-pick"
|
||||||
|
className={styles.compactSelect}
|
||||||
|
value={selectedAccountTemplateId}
|
||||||
|
onChange={(e) => onSelectedAccountTemplateId(e.target.value)}
|
||||||
|
aria-label={t('invoiceDraft.addFromAccountTemplateAria')}
|
||||||
|
>
|
||||||
|
<option value="">{t('invoiceDraft.addFromAccountTemplate')}</option>
|
||||||
|
{accountTemplates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.bankName}
|
||||||
|
{template.accountHolderName ? ` — ${template.accountHolderName}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${formStyles.btn} ${formStyles.btnGhost} ${styles.compactBtn}`}
|
||||||
|
onClick={addFromAccountTemplate}
|
||||||
|
disabled={!selectedAccountTemplateId}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
{t('invoiceDraft.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||||
|
onClick={() => onAccountsChange([...accounts, emptyDraftAccount()])}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('invoiceDraft.addAccount')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{accounts.length === 0 ? (
|
{accounts.length === 0 ? (
|
||||||
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p>
|
<p className={styles.templateHint}>{t('invoiceDraft.noAccounts')}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
font-size: var(--field-font-size);
|
font-size: var(--field-font-size);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-align: left;
|
text-align: start;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -64,19 +64,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: static;
|
position: fixed;
|
||||||
margin-top: 4px;
|
z-index: 1200;
|
||||||
z-index: auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: none;
|
overflow: hidden;
|
||||||
overflow: visible;
|
isolation: isolate;
|
||||||
background: var(--elevated-surface);
|
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: var(--glass-shadow);
|
box-shadow: var(--glass-shadow);
|
||||||
|
animation: menuIn 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(var(--blur-glass, 16px)) saturate(1.8);
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-glass, 16px)) saturate(1.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menuIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchWrap {
|
.searchWrap {
|
||||||
@@ -110,6 +128,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.optionsList {
|
.optionsList {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { Check, ChevronDown, Search } from 'lucide-react'
|
import { Check, ChevronDown, Search } from 'lucide-react'
|
||||||
import styles from './MultiSelectDropdown.module.css'
|
import styles from './MultiSelectDropdown.module.css'
|
||||||
|
|
||||||
@@ -6,6 +7,8 @@ export interface MultiSelectOption<T extends string | number = number> {
|
|||||||
value: T
|
value: T
|
||||||
label: string
|
label: string
|
||||||
depth?: number
|
depth?: number
|
||||||
|
/** Extra text matched by search (e.g. EN+FA names, tags). */
|
||||||
|
keywords?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MultiSelectDropdownProps<T extends string | number = number> {
|
interface MultiSelectDropdownProps<T extends string | number = number> {
|
||||||
@@ -19,6 +22,13 @@ interface MultiSelectDropdownProps<T extends string | number = number> {
|
|||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MenuPosition = {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
width: number
|
||||||
|
maxHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
export function MultiSelectDropdown<T extends string | number = number>({
|
export function MultiSelectDropdown<T extends string | number = number>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -32,8 +42,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [triggerLabel, setTriggerLabel] = useState(placeholder)
|
const [triggerLabel, setTriggerLabel] = useState(placeholder)
|
||||||
|
const [menuPos, setMenuPos] = useState<MenuPosition | null>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const measureRef = useRef<HTMLSpanElement>(null)
|
const measureRef = useRef<HTMLSpanElement>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -45,6 +57,29 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
[options, value],
|
[options, value],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const updateMenuPosition = () => {
|
||||||
|
const trigger = triggerRef.current
|
||||||
|
if (!trigger) return
|
||||||
|
|
||||||
|
const rect = trigger.getBoundingClientRect()
|
||||||
|
const gap = 4
|
||||||
|
const viewportPad = 8
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - gap - viewportPad
|
||||||
|
const spaceAbove = rect.top - gap - viewportPad
|
||||||
|
const preferBelow = spaceBelow >= 180 || spaceBelow >= spaceAbove
|
||||||
|
const maxHeight = Math.max(160, Math.min(320, preferBelow ? spaceBelow : spaceAbove))
|
||||||
|
const top = preferBelow
|
||||||
|
? rect.bottom + gap
|
||||||
|
: Math.max(viewportPad, rect.top - gap - maxHeight)
|
||||||
|
|
||||||
|
setMenuPos({
|
||||||
|
top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
maxHeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const trigger = triggerRef.current
|
const trigger = triggerRef.current
|
||||||
const measure = measureRef.current
|
const measure = measureRef.current
|
||||||
@@ -74,9 +109,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
const style = getComputedStyle(triggerEl)
|
const style = getComputedStyle(triggerEl)
|
||||||
measureEl.style.font = style.font
|
measureEl.style.font = style.font
|
||||||
|
|
||||||
const padding =
|
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||||
parseFloat(style.paddingLeft) +
|
|
||||||
parseFloat(style.paddingRight)
|
|
||||||
const gap = parseFloat(style.columnGap || style.gap || '10')
|
const gap = parseFloat(style.columnGap || style.gap || '10')
|
||||||
const reservedWidth = padding + gap + 16 + (searchable ? gap + 16 : 0)
|
const reservedWidth = padding + gap + 16 + (searchable ? gap + 16 : 0)
|
||||||
const availableWidth = triggerEl.clientWidth - reservedWidth
|
const availableWidth = triggerEl.clientWidth - reservedWidth
|
||||||
@@ -103,9 +136,32 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const query = searchQuery.trim().toLowerCase()
|
const query = searchQuery.trim().toLowerCase()
|
||||||
if (!searchable || !query) return options
|
if (!searchable || !query) return options
|
||||||
return options.filter((option) => option.label.toLowerCase().includes(query))
|
return options.filter((option) => {
|
||||||
|
const haystack = `${option.label} ${option.keywords ?? ''}`.toLowerCase()
|
||||||
|
return haystack.includes(query)
|
||||||
|
})
|
||||||
}, [options, searchQuery, searchable])
|
}, [options, searchQuery, searchable])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMenuPos(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMenuPosition()
|
||||||
|
|
||||||
|
function onReposition() {
|
||||||
|
updateMenuPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', onReposition)
|
||||||
|
window.addEventListener('scroll', onReposition, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onReposition)
|
||||||
|
window.removeEventListener('scroll', onReposition, true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
@@ -113,9 +169,10 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
const target = e.target as Node
|
||||||
setOpen(false)
|
if (containerRef.current?.contains(target)) return
|
||||||
}
|
if (menuRef.current?.contains(target)) return
|
||||||
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEscape(e: KeyboardEvent) {
|
function handleEscape(e: KeyboardEvent) {
|
||||||
@@ -134,7 +191,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
if (open && searchable) {
|
if (open && searchable) {
|
||||||
searchInputRef.current?.focus()
|
searchInputRef.current?.focus()
|
||||||
}
|
}
|
||||||
}, [open, searchable])
|
}, [open, searchable, menuPos])
|
||||||
|
|
||||||
function toggleOption(optionValue: T) {
|
function toggleOption(optionValue: T) {
|
||||||
if (value.includes(optionValue)) {
|
if (value.includes(optionValue)) {
|
||||||
@@ -144,6 +201,68 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
onChange([...value, optionValue])
|
onChange([...value, optionValue])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menu =
|
||||||
|
open && menuPos
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={styles.dropdown}
|
||||||
|
style={{
|
||||||
|
top: menuPos.top,
|
||||||
|
left: menuPos.left,
|
||||||
|
width: menuPos.width,
|
||||||
|
maxHeight: menuPos.maxHeight,
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
{searchable && (
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="search"
|
||||||
|
className={styles.searchInput}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className={styles.optionsList} role="listbox" aria-multiselectable="true">
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<li className={styles.empty}>
|
||||||
|
{options.length === 0 ? 'No options available.' : 'No matching options.'}
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
filteredOptions.map((option) => {
|
||||||
|
const selected = value.includes(option.value)
|
||||||
|
return (
|
||||||
|
<li key={String(option.value)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
||||||
|
style={{ paddingInlineStart: `${10 + (option.depth ?? 0) * 18}px` }}
|
||||||
|
onClick={() => toggleOption(option.value)}
|
||||||
|
>
|
||||||
|
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
|
||||||
|
{selected && <Check size={12} strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} ref={containerRef}>
|
<div className={styles.wrapper} ref={containerRef}>
|
||||||
<span ref={measureRef} className={styles.measureText} aria-hidden="true" />
|
<span ref={measureRef} className={styles.measureText} aria-hidden="true" />
|
||||||
@@ -163,54 +282,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
<span className={styles.triggerText}>{triggerLabel}</span>
|
<span className={styles.triggerText}>{triggerLabel}</span>
|
||||||
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
{menu}
|
||||||
{open && (
|
|
||||||
<div className={styles.dropdown}>
|
|
||||||
{searchable && (
|
|
||||||
<div className={styles.searchWrap}>
|
|
||||||
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="search"
|
|
||||||
className={styles.searchInput}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className={styles.optionsList} role="listbox" aria-multiselectable="true">
|
|
||||||
{filteredOptions.length === 0 ? (
|
|
||||||
<li className={styles.empty}>
|
|
||||||
{options.length === 0 ? 'No options available.' : 'No matching options.'}
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
filteredOptions.map((option) => {
|
|
||||||
const selected = value.includes(option.value)
|
|
||||||
return (
|
|
||||||
<li key={String(option.value)}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={selected}
|
|
||||||
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
|
||||||
style={{ paddingLeft: `${10 + (option.depth ?? 0) * 18}px` }}
|
|
||||||
onClick={() => toggleOption(option.value)}
|
|
||||||
>
|
|
||||||
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
|
|
||||||
{selected && <Check size={12} strokeWidth={3} />}
|
|
||||||
</span>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.modalWide {
|
.modalWide {
|
||||||
max-width: 720px;
|
max-width: 960px;
|
||||||
|
width: min(960px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
|
|
||||||
.itemRow {
|
.itemRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
|
grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
|
|
||||||
.tableHead {
|
.tableHead {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
|
grid-template-columns: 64px minmax(220px, 1.6fr) 64px 110px 110px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 12px 2px;
|
padding: 0 12px 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -140,6 +141,126 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shippingBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(148, 163, 184, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingSectionTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressText {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingGroupList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingGroupCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingGroupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingGroupTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingGroupAmount {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingMethod {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shippingItemNames {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTitleLink {
|
||||||
|
display: inline;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTitleLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalsBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalsRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalsValue {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalsRowGrand {
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useT } from '../i18n/useT'
|
|||||||
import type { Order } from '../services/orderService'
|
import type { Order } from '../services/orderService'
|
||||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||||
import { formatIrtPrice } from '../utils/irtPrice'
|
import { formatIrtPrice } from '../utils/irtPrice'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
import modalStyles from './VariationsModal.module.css'
|
import modalStyles from './VariationsModal.module.css'
|
||||||
import styles from './OrderItemsModal.module.css'
|
import styles from './OrderItemsModal.module.css'
|
||||||
|
|
||||||
@@ -30,6 +31,56 @@ function totalQuantity(order: Order) {
|
|||||||
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrderShipping(shippingAddress: Record<string, unknown>) {
|
||||||
|
const root = asRecord(shippingAddress) ?? {}
|
||||||
|
const deliveryMode =
|
||||||
|
root.deliveryMode === 'pickup' || root.deliveryMode === 'delivery'
|
||||||
|
? root.deliveryMode
|
||||||
|
: root.province === 'Pickup'
|
||||||
|
? 'pickup'
|
||||||
|
: 'delivery'
|
||||||
|
const shipping = asRecord(root.shipping)
|
||||||
|
const groupsRaw = Array.isArray(shipping?.groups) ? shipping.groups : []
|
||||||
|
const groups = groupsRaw
|
||||||
|
.map((entry) => {
|
||||||
|
const row = asRecord(entry)
|
||||||
|
if (!row) return null
|
||||||
|
const groupId = typeof row.groupId === 'string' ? row.groupId : ''
|
||||||
|
if (!groupId) return null
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
title: typeof row.title === 'string' ? row.title : '',
|
||||||
|
methodLabel: typeof row.methodLabel === 'string' ? row.methodLabel : null,
|
||||||
|
amount: typeof row.amount === 'number' && Number.isFinite(row.amount) ? row.amount : 0,
|
||||||
|
pasKraye: row.pasKraye === true,
|
||||||
|
itemNames: Array.isArray(row.itemNames)
|
||||||
|
? row.itemNames.filter((name): name is string => typeof name === 'string')
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
||||||
|
|
||||||
|
const addressParts = [
|
||||||
|
typeof root.province === 'string' ? root.province : '',
|
||||||
|
typeof root.city === 'string' ? root.city : '',
|
||||||
|
typeof root.address === 'string' ? root.address : '',
|
||||||
|
typeof root.postalCode === 'string' ? root.postalCode : '',
|
||||||
|
].filter((part) => part && part !== 'Pickup' && part !== 'Store' && part !== 'In-store pickup')
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveryMode,
|
||||||
|
addressLine: addressParts.join(' · '),
|
||||||
|
groups,
|
||||||
|
hasPasKraye: groups.some((group) => group.pasKraye),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
|
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
@@ -61,6 +112,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
if (!mounted || !order) return null
|
if (!mounted || !order) return null
|
||||||
|
|
||||||
const itemCount = totalQuantity(order)
|
const itemCount = totalQuantity(order)
|
||||||
|
const shippingInfo = parseOrderShipping(order.shippingAddress)
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -105,6 +157,44 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.shippingBlock}>
|
||||||
|
<div className={styles.shippingSectionTitle}>{t('orderItems.delivery')}</div>
|
||||||
|
{shippingInfo.deliveryMode === 'pickup' ? (
|
||||||
|
<p className={styles.addressText}>{t('orderItems.pickup')}</p>
|
||||||
|
) : shippingInfo.addressLine ? (
|
||||||
|
<p className={styles.addressText}>{shippingInfo.addressLine}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shippingInfo.groups.length > 0 ? (
|
||||||
|
<ul className={styles.shippingGroupList}>
|
||||||
|
{shippingInfo.groups.map((group) => (
|
||||||
|
<li key={group.groupId} className={styles.shippingGroupCard}>
|
||||||
|
<div className={styles.shippingGroupHeader}>
|
||||||
|
<span className={styles.shippingGroupTitle}>{group.title}</span>
|
||||||
|
<span className={styles.shippingGroupAmount}>
|
||||||
|
{group.pasKraye
|
||||||
|
? t('orderItems.pasKraye')
|
||||||
|
: group.amount > 0
|
||||||
|
? formatIrtPrice(group.amount)
|
||||||
|
: t('orderItems.freeShipping')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.methodLabel ? (
|
||||||
|
<div className={styles.shippingMethod}>{group.methodLabel}</div>
|
||||||
|
) : null}
|
||||||
|
{shippingInfo.groups.length > 1 && group.itemNames.length > 0 ? (
|
||||||
|
<ul className={styles.shippingItemNames}>
|
||||||
|
{group.itemNames.map((name) => (
|
||||||
|
<li key={`${group.groupId}-${name}`}>{name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{order.items.length === 0 ? (
|
{order.items.length === 0 ? (
|
||||||
<p className={styles.empty}>{t('orderItems.empty')}</p>
|
<p className={styles.empty}>{t('orderItems.empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -117,7 +207,15 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
<span>{t('orderItems.lineTotal')}</span>
|
<span>{t('orderItems.lineTotal')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.itemList}>
|
<ul className={styles.itemList}>
|
||||||
{order.items.map((item) => (
|
{order.items.map((item) => {
|
||||||
|
const displayTitle = item.productNameFa?.trim() || item.productTitle
|
||||||
|
const productHref = buildWebsiteContentUrl('product', {
|
||||||
|
id: item.productId,
|
||||||
|
nameFa: item.productNameFa,
|
||||||
|
title: item.productTitle,
|
||||||
|
slug: item.productSlug,
|
||||||
|
})
|
||||||
|
return (
|
||||||
<li key={item.id} className={styles.itemRow}>
|
<li key={item.id} className={styles.itemRow}>
|
||||||
<div className={styles.itemThumb}>
|
<div className={styles.itemThumb}>
|
||||||
{item.productImage ? (
|
{item.productImage ? (
|
||||||
@@ -127,7 +225,18 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.itemTitle}>{item.productTitle}</div>
|
{productHref ? (
|
||||||
|
<a
|
||||||
|
className={styles.itemTitleLink}
|
||||||
|
href={productHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className={styles.itemTitle}>{displayTitle}</div>
|
||||||
|
)}
|
||||||
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
|
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
|
||||||
{item.variantSku && (
|
{item.variantSku && (
|
||||||
<div className={styles.itemSku}>
|
<div className={styles.itemSku}>
|
||||||
@@ -139,20 +248,43 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
<div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div>
|
<div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div>
|
||||||
<div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
|
<div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.summary}>
|
<div className={styles.totalsBlock}>
|
||||||
<span className={styles.summaryLabel}>
|
<div className={styles.totalsRow}>
|
||||||
{t('orderItems.summary', {
|
<span>{t('orderItems.subtotal')}</span>
|
||||||
count: itemCount,
|
<span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
|
||||||
itemsLabel:
|
</div>
|
||||||
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
|
<div className={styles.totalsRow}>
|
||||||
})}
|
<span>{t('orderItems.shipping')}</span>
|
||||||
</span>
|
<span className={styles.totalsValue}>
|
||||||
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
{order.shippingTotal > 0
|
||||||
|
? formatIrtPrice(order.shippingTotal)
|
||||||
|
: shippingInfo.hasPasKraye
|
||||||
|
? t('orderItems.pasKraye')
|
||||||
|
: t('orderItems.freeShipping')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{order.discountTotal > 0 ? (
|
||||||
|
<div className={styles.totalsRow}>
|
||||||
|
<span>{t('orderItems.discount')}</span>
|
||||||
|
<span className={styles.totalsValue}>{formatIrtPrice(order.discountTotal)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.totalsRow} ${styles.totalsRowGrand}`}>
|
||||||
|
<span>
|
||||||
|
{t('orderItems.summary', {
|
||||||
|
count: itemCount,
|
||||||
|
itemsLabel:
|
||||||
|
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={modalStyles.actions}>
|
<div className={modalStyles.actions}>
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paymentHint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.06);
|
||||||
|
border: 1px solid rgba(var(--primary-rgb) / 0.18);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.stepOption {
|
.stepOption {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { useToast } from '../context/ToastContext'
|
|||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { updateOrder, type Order } from '../services/orderService'
|
import { updateOrder, type Order } from '../services/orderService'
|
||||||
import type { OrderProcessStep } from '../services/settingsService'
|
import {
|
||||||
|
isSystemPaymentProcessStep,
|
||||||
|
type OrderProcessStep,
|
||||||
|
} from '../services/settingsService'
|
||||||
import { defaultStepColorForId, normalizeStepColor, stepBadgeStyle } from '../utils/stepColors'
|
import { defaultStepColorForId, normalizeStepColor, stepBadgeStyle } from '../utils/stepColors'
|
||||||
import modalStyles from './VariationsModal.module.css'
|
import modalStyles from './VariationsModal.module.css'
|
||||||
import styles from './OrderStepModal.module.css'
|
import styles from './OrderStepModal.module.css'
|
||||||
@@ -71,6 +74,7 @@ export function OrderStepModal({
|
|||||||
|
|
||||||
async function handleSelectStep(stepId: string) {
|
async function handleSelectStep(stepId: string) {
|
||||||
if (!order || savingStepId) return
|
if (!order || savingStepId) return
|
||||||
|
if (isSystemPaymentProcessStep(stepId)) return
|
||||||
|
|
||||||
if (stepId === order.processStepId) {
|
if (stepId === order.processStepId) {
|
||||||
onClose()
|
onClose()
|
||||||
@@ -104,6 +108,8 @@ export function OrderStepModal({
|
|||||||
order.processStepLabel?.trim() ||
|
order.processStepLabel?.trim() ||
|
||||||
currentStep?.label ||
|
currentStep?.label ||
|
||||||
order.processStepId
|
order.processStepId
|
||||||
|
const selectableSteps = steps.filter((step) => !isSystemPaymentProcessStep(step.id))
|
||||||
|
const paymentIsCurrent = isSystemPaymentProcessStep(order.processStepId)
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -151,8 +157,12 @@ export function OrderStepModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{paymentIsCurrent && (
|
||||||
|
<p className={styles.paymentHint}>{t('orderStep.paymentHint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.stepList} role="listbox" aria-label={t('orderStep.listAria')}>
|
<div className={styles.stepList} role="listbox" aria-label={t('orderStep.listAria')}>
|
||||||
{steps.map((step, index) => {
|
{selectableSteps.map((step, index) => {
|
||||||
const selected = order.processStepId === step.id
|
const selected = order.processStepId === step.id
|
||||||
const saving = savingStepId === step.id
|
const saving = savingStepId === step.id
|
||||||
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
|
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
|
||||||
@@ -178,7 +188,7 @@ export function OrderStepModal({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!steps.length && (
|
{!selectableSteps.length && (
|
||||||
<p className={styles.emptyText}>{t('orderStep.empty')}</p>
|
<p className={styles.emptyText}>{t('orderStep.empty')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,14 +49,34 @@
|
|||||||
.txMeta {
|
.txMeta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 6px 16px;
|
gap: 8px 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.txMetaItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txMetaLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.txMetaItem strong {
|
.txMetaItem strong {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txMono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBadge {
|
.statusBadge {
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
|||||||
if (!mounted || !order) return null
|
if (!mounted || !order) return null
|
||||||
|
|
||||||
function transactionDetails(transaction: OrderTransaction) {
|
function transactionDetails(transaction: OrderTransaction) {
|
||||||
const rows: { label: string; value: string; isStatus?: boolean }[] = [
|
const rows: { label: string; value: string; isStatus?: boolean; mono?: boolean }[] = [
|
||||||
|
{
|
||||||
|
label: t('orderTransactions.transactionId'),
|
||||||
|
value: transaction.id,
|
||||||
|
mono: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('orderTransactions.status'),
|
label: t('orderTransactions.status'),
|
||||||
value: t(STATUS_KEYS[transaction.status] ?? 'orderTransactions.status.pending'),
|
value: t(STATUS_KEYS[transaction.status] ?? 'orderTransactions.status.pending'),
|
||||||
@@ -108,6 +113,13 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (transaction.paidAt) {
|
||||||
|
rows.push({
|
||||||
|
label: t('orderTransactions.paidAt'),
|
||||||
|
value: formatDateTime(transaction.paidAt, dateLocale),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (transaction.type === 'pos' && transaction.posType) {
|
if (transaction.type === 'pos' && transaction.posType) {
|
||||||
rows.push({ label: t('orderTransactions.posType'), value: transaction.posType })
|
rows.push({ label: t('orderTransactions.posType'), value: transaction.posType })
|
||||||
}
|
}
|
||||||
@@ -116,11 +128,38 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
|||||||
rows.push({ label: t('orderTransactions.account'), value: transaction.transferAccount })
|
rows.push({ label: t('orderTransactions.account'), value: transaction.transferAccount })
|
||||||
}
|
}
|
||||||
if (transaction.transferRefNumber) {
|
if (transaction.transferRefNumber) {
|
||||||
rows.push({ label: t('orderTransactions.refCode'), value: transaction.transferRefNumber })
|
rows.push({
|
||||||
|
label: t('orderTransactions.refCode'),
|
||||||
|
value: transaction.transferRefNumber,
|
||||||
|
mono: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (transaction.type === 'e_payment_gate' && transaction.gatewayType) {
|
if (transaction.type === 'e_payment_gate') {
|
||||||
rows.push({ label: t('orderTransactions.gateway'), value: transaction.gatewayType })
|
if (transaction.gatewayType) {
|
||||||
|
rows.push({ label: t('orderTransactions.gateway'), value: transaction.gatewayType })
|
||||||
|
}
|
||||||
|
if (transaction.gatewayTrackId) {
|
||||||
|
rows.push({
|
||||||
|
label: t('orderTransactions.gatewayTrackId'),
|
||||||
|
value: transaction.gatewayTrackId,
|
||||||
|
mono: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (transaction.gatewayRef) {
|
||||||
|
rows.push({
|
||||||
|
label: t('orderTransactions.gatewayRef'),
|
||||||
|
value: transaction.gatewayRef,
|
||||||
|
mono: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (transaction.cardPan) {
|
||||||
|
rows.push({
|
||||||
|
label: t('orderTransactions.cardPan'),
|
||||||
|
value: transaction.cardPan,
|
||||||
|
mono: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (transaction.notes) {
|
if (transaction.notes) {
|
||||||
rows.push({ label: t('orderTransactions.notes'), value: transaction.notes })
|
rows.push({ label: t('orderTransactions.notes'), value: transaction.notes })
|
||||||
@@ -176,13 +215,15 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
|||||||
<div className={styles.txMeta}>
|
<div className={styles.txMeta}>
|
||||||
{details.map((detail) => (
|
{details.map((detail) => (
|
||||||
<div key={detail.label} className={styles.txMetaItem}>
|
<div key={detail.label} className={styles.txMetaItem}>
|
||||||
{detail.label}:{' '}
|
<span className={styles.txMetaLabel}>{detail.label}</span>
|
||||||
{detail.isStatus ? (
|
{detail.isStatus ? (
|
||||||
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
|
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
|
||||||
{detail.value}
|
{detail.value}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<strong>{detail.value}</strong>
|
<strong className={detail.mono ? styles.txMono : undefined}>
|
||||||
|
{detail.value}
|
||||||
|
</strong>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,6 +4,42 @@
|
|||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes homeReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.revealItem {
|
||||||
|
opacity: 0;
|
||||||
|
animation: homeReveal 0.58s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
animation-delay: var(--reveal-delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revealItemFill {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revealItemFill > * {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.revealItem {
|
||||||
|
opacity: 1;
|
||||||
|
animation: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function PortfolioCard({
|
|||||||
</h3>
|
</h3>
|
||||||
) : null}
|
) : null}
|
||||||
{titleEn ? (
|
{titleEn ? (
|
||||||
<p className={cardStyles.titleEn} lang="en" dir={isFa ? 'rtl' : 'ltr'}>
|
<p className={cardStyles.titleEn} lang="en" dir="ltr">
|
||||||
{titleEn}
|
{titleEn}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -155,6 +155,10 @@
|
|||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
unicode-bidi: plaintext;
|
unicode-bidi: plaintext;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bodyLtr {
|
.bodyLtr {
|
||||||
@@ -202,7 +206,8 @@
|
|||||||
|
|
||||||
.variantBadge,
|
.variantBadge,
|
||||||
.commentBadge,
|
.commentBadge,
|
||||||
.translateBadge {
|
.translateBadge,
|
||||||
|
.actionBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
@@ -241,3 +246,12 @@
|
|||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menuItemDanger {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItemDanger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { Link } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Globe,
|
Globe,
|
||||||
Info,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Layers,
|
Layers,
|
||||||
MessageSquare,
|
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ImageOff,
|
ImageOff,
|
||||||
|
Info,
|
||||||
|
MessageSquare,
|
||||||
|
Search,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import type { Product } from '../types/product'
|
import type { Product } from '../types/product'
|
||||||
@@ -17,8 +19,7 @@ import { Tooltip } from './Tooltip'
|
|||||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { hasBusinessModule } from '../utils/businessModules'
|
import { hasBusinessModule } from '../utils/businessModules'
|
||||||
import { formatProductCategoryChip } from '../services/productService'
|
import { formatProductCategoryChip } from '../services/productService'
|
||||||
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
import { ProductCardOverflowMenu } from './ProductCardOverflowMenu'
|
||||||
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
|
||||||
import styles from './ProductCard.module.css'
|
import styles from './ProductCard.module.css'
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
@@ -29,11 +30,21 @@ interface ProductCardProps {
|
|||||||
onTranslate: (id: string) => void
|
onTranslate: (id: string) => void
|
||||||
onQuickInfo: (id: string) => void
|
onQuickInfo: (id: string) => void
|
||||||
onComments: (id: string) => void
|
onComments: (id: string) => void
|
||||||
|
onSeo: (id: string) => void
|
||||||
onTechnicalInfo: (id: string) => void
|
onTechnicalInfo: (id: string) => void
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
onVariations: (id: string) => void
|
onVariations: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CardAction = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
onClick: () => void
|
||||||
|
danger?: boolean
|
||||||
|
badge?: number
|
||||||
|
}
|
||||||
|
|
||||||
export function ProductCard({
|
export function ProductCard({
|
||||||
product,
|
product,
|
||||||
commentCount,
|
commentCount,
|
||||||
@@ -42,6 +53,7 @@ export function ProductCard({
|
|||||||
onTranslate,
|
onTranslate,
|
||||||
onQuickInfo,
|
onQuickInfo,
|
||||||
onComments,
|
onComments,
|
||||||
|
onSeo,
|
||||||
onTechnicalInfo,
|
onTechnicalInfo,
|
||||||
onRemove,
|
onRemove,
|
||||||
onVariations,
|
onVariations,
|
||||||
@@ -59,7 +71,6 @@ export function ProductCard({
|
|||||||
: ''
|
: ''
|
||||||
: product.nameFa
|
: product.nameFa
|
||||||
const categoryLabel = formatProductCategoryChip(product, isFa)
|
const categoryLabel = formatProductCategoryChip(product, isFa)
|
||||||
const websiteUrl = buildWebsiteContentUrl('product', product)
|
|
||||||
const imageSrc = product.image?.trim() || ''
|
const imageSrc = product.image?.trim() || ''
|
||||||
const [imageFailed, setImageFailed] = useState(false)
|
const [imageFailed, setImageFailed] = useState(false)
|
||||||
const showImage = Boolean(imageSrc) && !imageFailed
|
const showImage = Boolean(imageSrc) && !imageFailed
|
||||||
@@ -68,6 +79,70 @@ export function ProductCard({
|
|||||||
setImageFailed(false)
|
setImageFailed(false)
|
||||||
}, [imageSrc])
|
}, [imageSrc])
|
||||||
|
|
||||||
|
const primaryActions: CardAction[] = [
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: t('products.card.edit'),
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => onEdit(product.id),
|
||||||
|
},
|
||||||
|
...(showTranslationControls
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'translate',
|
||||||
|
label: t('products.card.translate'),
|
||||||
|
icon: Globe,
|
||||||
|
onClick: () => onTranslate(product.id),
|
||||||
|
badge: translationCount > 0 ? translationCount : undefined,
|
||||||
|
} satisfies CardAction,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: 'variations',
|
||||||
|
label: t('products.card.variations'),
|
||||||
|
icon: Layers,
|
||||||
|
onClick: () => onVariations(product.id),
|
||||||
|
badge: variantCount > 0 ? variantCount : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'technical',
|
||||||
|
label: t('products.card.technical'),
|
||||||
|
icon: ClipboardList,
|
||||||
|
onClick: () => onTechnicalInfo(product.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const menuActions = [
|
||||||
|
{
|
||||||
|
key: 'seo',
|
||||||
|
label: t('products.card.seo'),
|
||||||
|
icon: Search,
|
||||||
|
onClick: () => onSeo(product.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quickInfo',
|
||||||
|
label: t('products.card.quickInfo'),
|
||||||
|
icon: Info,
|
||||||
|
onClick: () => onQuickInfo(product.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comments',
|
||||||
|
label:
|
||||||
|
commentCount > 0
|
||||||
|
? `${t('products.card.comments')} (${commentCount})`
|
||||||
|
: t('products.card.comments'),
|
||||||
|
icon: MessageSquare,
|
||||||
|
onClick: () => onComments(product.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remove',
|
||||||
|
label: t('products.card.remove'),
|
||||||
|
icon: Trash2,
|
||||||
|
onClick: () => onRemove(product.id),
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={styles.card}>
|
<article className={styles.card}>
|
||||||
<Link to={`/products/detail/${product.id}`} className={styles.clickable}>
|
<Link to={`/products/detail/${product.id}`} className={styles.clickable}>
|
||||||
@@ -103,89 +178,35 @@ export function ProductCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{categoryLabel ? (
|
{categoryLabel ? (
|
||||||
<span className={styles.categoryChip}>{categoryLabel}</span>
|
<span className={styles.categoryChip} title={categoryLabel}>
|
||||||
|
{categoryLabel}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<Tooltip label={t('products.card.edit')}>
|
{primaryActions.map((action) => {
|
||||||
<button type="button" onClick={() => onEdit(product.id)} aria-label={t('products.card.edit')}>
|
const Icon = action.icon
|
||||||
<Pencil size={16} />
|
return (
|
||||||
</button>
|
<Tooltip key={action.key} label={action.label}>
|
||||||
</Tooltip>
|
<button
|
||||||
<OpenOnWebsiteControl href={websiteUrl} />
|
type="button"
|
||||||
{showTranslationControls ? (
|
className={styles.iconBtn}
|
||||||
<Tooltip label={t('products.card.translate')}>
|
onClick={action.onClick}
|
||||||
<button
|
aria-label={
|
||||||
type="button"
|
action.badge != null ? `${action.label} (${action.badge})` : action.label
|
||||||
className={styles.iconBtn}
|
}
|
||||||
onClick={() => onTranslate(product.id)}
|
>
|
||||||
aria-label={
|
<Icon size={16} />
|
||||||
translationCount > 0
|
{action.badge != null ? (
|
||||||
? `${t('products.card.translate')} (${translationCount})`
|
<span className={styles.actionBadge}>{action.badge}</span>
|
||||||
: t('products.card.translate')
|
) : null}
|
||||||
}
|
</button>
|
||||||
>
|
</Tooltip>
|
||||||
<Globe size={16} />
|
)
|
||||||
{translationCount > 0 && (
|
})}
|
||||||
<span className={styles.translateBadge}>{translationCount}</span>
|
<ProductCardOverflowMenu productName={primaryName} items={menuActions} />
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
<Tooltip label={t('products.card.quickInfo')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onQuickInfo(product.id)}
|
|
||||||
aria-label={t('products.card.quickInfo')}
|
|
||||||
>
|
|
||||||
<Info size={16} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t('products.card.variations')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.iconBtn}
|
|
||||||
onClick={() => onVariations(product.id)}
|
|
||||||
aria-label={`${t('products.card.variations')} (${variantCount})`}
|
|
||||||
>
|
|
||||||
<Layers size={16} />
|
|
||||||
{variantCount > 0 && <span className={styles.variantBadge}>{variantCount}</span>}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t('products.card.comments')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.iconBtn}
|
|
||||||
onClick={() => onComments(product.id)}
|
|
||||||
aria-label={`${t('products.card.comments')} (${commentCount})`}
|
|
||||||
>
|
|
||||||
<MessageSquare size={16} />
|
|
||||||
{commentCount > 0 && (
|
|
||||||
<span className={styles.commentBadge}>{commentCount}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t('products.card.technical')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onTechnicalInfo(product.id)}
|
|
||||||
aria-label={t('products.card.technical')}
|
|
||||||
>
|
|
||||||
<ClipboardList size={16} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t('products.card.remove')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.danger}
|
|
||||||
onClick={() => onRemove(product.id)}
|
|
||||||
aria-label={t('products.card.remove')}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { MoreHorizontal, type LucideIcon } from 'lucide-react'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { Tooltip } from './Tooltip'
|
||||||
|
import { fitFixedPopover } from '../utils/fitFixedPopover'
|
||||||
|
import menuStyles from './CustomerActionsOverflowMenu.module.css'
|
||||||
|
import cardStyles from './ProductCard.module.css'
|
||||||
|
|
||||||
|
type PopoverPosition = {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductCardOverflowItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
onClick: () => void
|
||||||
|
danger?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductCardOverflowMenuProps {
|
||||||
|
productName: string
|
||||||
|
items: ProductCardOverflowItem[]
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCardOverflowMenu({
|
||||||
|
productName,
|
||||||
|
items,
|
||||||
|
disabled = false,
|
||||||
|
}: ProductCardOverflowMenuProps) {
|
||||||
|
const t = useT()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [popoverPos, setPopoverPos] = useState<PopoverPosition | null>(null)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
function updatePopoverPosition() {
|
||||||
|
const rect = triggerRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
|
||||||
|
const size = popoverRef.current
|
||||||
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||||
|
: { width: 200, height: 44 * Math.max(items.length, 1) + 12 }
|
||||||
|
|
||||||
|
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
if (disabled || items.length === 0) return
|
||||||
|
if (open) {
|
||||||
|
setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updatePopoverPosition()
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
updatePopoverPosition()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (wrapRef.current?.contains(target)) return
|
||||||
|
if (popoverRef.current?.contains(target)) return
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscape(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReposition() {
|
||||||
|
updatePopoverPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
window.addEventListener('resize', handleReposition)
|
||||||
|
window.addEventListener('scroll', handleReposition, true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
window.removeEventListener('resize', handleReposition)
|
||||||
|
window.removeEventListener('scroll', handleReposition, true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
const moreLabel = t('products.card.moreActions')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={menuStyles.wrap} ref={wrapRef}>
|
||||||
|
<Tooltip label={moreLabel}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={`${cardStyles.iconBtn} ${open ? menuStyles.triggerOpen : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleOpen()
|
||||||
|
}}
|
||||||
|
aria-label={`${moreLabel}: ${productName}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
popoverPos &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className={menuStyles.menu}
|
||||||
|
role="menu"
|
||||||
|
aria-label={`${moreLabel}: ${productName}`}
|
||||||
|
style={{
|
||||||
|
top: popoverPos.top,
|
||||||
|
left: popoverPos.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map(({ key, label, icon: Icon, onClick, danger }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`${menuStyles.item}${danger ? ` ${cardStyles.menuItemDanger}` : ''}`}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onClick()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ function formatTechnicalValue(item: ProductTechnicalValue): string {
|
|||||||
const labels = item.optionLabels ?? (Array.isArray(item.value) ? item.value : [])
|
const labels = item.optionLabels ?? (Array.isArray(item.value) ? item.value : [])
|
||||||
return labels.length ? labels.join(', ') : '—'
|
return labels.length ? labels.join(', ') : '—'
|
||||||
}
|
}
|
||||||
|
if (item.type === 'multi_value') {
|
||||||
|
const items = Array.isArray(item.value)
|
||||||
|
? item.value.map((entry) => String(entry).trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
return items.length ? items.join('، ') : '—'
|
||||||
|
}
|
||||||
if (item.type === 'select') {
|
if (item.type === 'select') {
|
||||||
return item.optionLabel ?? (item.value ? String(item.value) : '—')
|
return item.optionLabel ?? (item.value ? String(item.value) : '—')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export function ProductFieldAiPromptModal({
|
|||||||
className={aiStyles.aiPromptTextarea}
|
className={aiStyles.aiPromptTextarea}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
rows={10}
|
rows={14}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
required
|
required
|
||||||
lang={isFa ? 'fa' : 'en'}
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ChevronDown, Search, X } from 'lucide-react'
|
import { ChevronDown, Loader2, Search, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import styles from './SearchableSelect.module.css'
|
import styles from './SearchableSelect.module.css'
|
||||||
|
|
||||||
|
export const PRODUCT_SEARCH_DEBOUNCE_MS = 350
|
||||||
|
|
||||||
export interface ProductSearchOption {
|
export interface ProductSearchOption {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -18,6 +20,13 @@ interface ProductSearchSelectProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
id?: string
|
id?: string
|
||||||
minSearchLength?: number
|
minSearchLength?: number
|
||||||
|
isSearching?: boolean
|
||||||
|
/**
|
||||||
|
* When set, typing is debounced and the parent fetches results.
|
||||||
|
* Local filtering is skipped.
|
||||||
|
*/
|
||||||
|
onQueryChange?: (query: string) => void
|
||||||
|
debounceMs?: number
|
||||||
'aria-label'?: string
|
'aria-label'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +45,9 @@ export function ProductSearchSelect({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
id,
|
id,
|
||||||
minSearchLength = 3,
|
minSearchLength = 3,
|
||||||
|
isSearching = false,
|
||||||
|
onQueryChange,
|
||||||
|
debounceMs = PRODUCT_SEARCH_DEBOUNCE_MS,
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
}: ProductSearchSelectProps) {
|
}: ProductSearchSelectProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
@@ -45,20 +57,35 @@ export function ProductSearchSelect({
|
|||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const onQueryChangeRef = useRef(onQueryChange)
|
||||||
|
onQueryChangeRef.current = onQueryChange
|
||||||
|
|
||||||
const selected = options.find((option) => option.id === value)
|
const selected = options.find((option) => option.id === value)
|
||||||
const selectedLabel = selected ? formatProductLabel(selected, preferFa) : ''
|
const selectedLabel = selected ? formatProductLabel(selected, preferFa) : ''
|
||||||
|
const trimmedQuery = query.trim()
|
||||||
|
const canSearch = trimmedQuery.length >= minSearchLength
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cb = onQueryChangeRef.current
|
||||||
|
if (!cb) return
|
||||||
|
if (!canSearch) {
|
||||||
|
cb('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => cb(trimmedQuery), debounceMs)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [canSearch, trimmedQuery, debounceMs])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const trimmed = query.trim()
|
if (!canSearch) return []
|
||||||
if (trimmed.length < minSearchLength) return []
|
if (onQueryChange) return options
|
||||||
const q = trimmed.toLowerCase()
|
const q = trimmedQuery.toLowerCase()
|
||||||
return options.filter(
|
return options.filter(
|
||||||
(option) =>
|
(option) =>
|
||||||
option.title.toLowerCase().includes(q) ||
|
option.title.toLowerCase().includes(q) ||
|
||||||
option.nameFa.includes(trimmed),
|
option.nameFa.includes(trimmedQuery),
|
||||||
)
|
)
|
||||||
}, [options, query, minSearchLength])
|
}, [canSearch, onQueryChange, options, trimmedQuery])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -76,13 +103,14 @@ export function ProductSearchSelect({
|
|||||||
onChange(id)
|
onChange(id)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
|
onQueryChangeRef.current?.('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
if (!disabled) setOpen(true)
|
if (!disabled) setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDropdown = open && query.trim().length >= minSearchLength
|
const showDropdown = open && (canSearch || trimmedQuery.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}>
|
<div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}>
|
||||||
@@ -115,17 +143,19 @@ export function ProductSearchSelect({
|
|||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
{isSearching && open ? (
|
||||||
|
<Loader2 size={16} className={`${styles.chevron} ${styles.spin}`} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showDropdown && (
|
{showDropdown && canSearch && (
|
||||||
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox">
|
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox">
|
||||||
{filtered.length === 0 ? (
|
{isSearching && filtered.length === 0 ? (
|
||||||
<li className={styles.noResults}>
|
<li className={styles.noResults}>{t('storeItems.search.searching')}</li>
|
||||||
{options.length === 0
|
) : filtered.length === 0 ? (
|
||||||
? t('storeItems.search.noProducts')
|
<li className={styles.noResults}>{t('storeItems.search.noMatch')}</li>
|
||||||
: t('storeItems.search.noMatch')}
|
|
||||||
</li>
|
|
||||||
) : (
|
) : (
|
||||||
filtered.map((option) => (
|
filtered.map((option) => (
|
||||||
<li key={option.id}>
|
<li key={option.id}>
|
||||||
@@ -172,7 +202,7 @@ export function ProductSearchSelect({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open && query.trim().length > 0 && query.trim().length < minSearchLength && (
|
{open && trimmedQuery.length > 0 && trimmedQuery.length < minSearchLength && (
|
||||||
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}>
|
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}>
|
||||||
<li className={styles.noResults}>
|
<li className={styles.noResults}>
|
||||||
{t('storeItems.search.minChars', { count: minSearchLength })}
|
{t('storeItems.search.minChars', { count: minSearchLength })}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.modal {
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { getProduct, updateProduct } from '../services/productService'
|
||||||
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
|
import { SeoMetaFields } from './SeoMetaFields'
|
||||||
|
import styles from './VariationsModal.module.css'
|
||||||
|
import seoStyles from './ProductSeoModal.module.css'
|
||||||
|
|
||||||
|
interface ProductSeoModalProps {
|
||||||
|
open: boolean
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function ProductSeoModal({
|
||||||
|
open,
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
onClose,
|
||||||
|
}: ProductSeoModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [seoMetaTitle, setSeoMetaTitle] = useState('')
|
||||||
|
const [seoMetaDescription, setSeoMetaDescription] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !productId) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
void loadData(controller.signal)
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [open, productId])
|
||||||
|
|
||||||
|
async function loadData(signal?: AbortSignal) {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const product = await getProduct(productId, signal)
|
||||||
|
setSeoMetaTitle(product.seoMetaTitle ?? '')
|
||||||
|
setSeoMetaDescription(product.seoMetaDescription ?? '')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(t('products.seo.errorLoad'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setIsSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await updateProduct(productId, seoMetaPayload(seoMetaTitle, seoMetaDescription))
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(t('products.seo.errorSave'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={() => !isSaving && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${seoStyles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="product-seo-title"
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<h3 id="product-seo-title" className={styles.title}>
|
||||||
|
{t('products.seo.title')}
|
||||||
|
</h3>
|
||||||
|
{productName ? <p className={styles.subtitle}>{productName}</p> : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSaving}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.body}>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className={styles.emptyText}>{t('products.seo.loading')}</p>
|
||||||
|
) : (
|
||||||
|
<SeoMetaFields
|
||||||
|
seoMetaTitle={seoMetaTitle}
|
||||||
|
seoMetaDescription={seoMetaDescription}
|
||||||
|
onTitleChange={setSeoMetaTitle}
|
||||||
|
onDescriptionChange={setSeoMetaDescription}
|
||||||
|
disabled={isSaving}
|
||||||
|
hint={t('seo.meta.hint')}
|
||||||
|
titleLabel={t('seo.meta.title')}
|
||||||
|
descriptionLabel={t('seo.meta.description')}
|
||||||
|
titlePlaceholder={t('seo.meta.titlePlaceholder')}
|
||||||
|
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <p className={styles.errorText}>{error}</p> : null}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.submitBtn}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? t('products.seo.saving') : t('products.seo.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { Sparkles, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import {
|
import {
|
||||||
|
fillProductTechnicalInfoByAi,
|
||||||
getProductTechnicalInfo,
|
getProductTechnicalInfo,
|
||||||
saveProductTechnicalInfo,
|
saveProductTechnicalInfo,
|
||||||
valuesToFormState,
|
valuesToFormState,
|
||||||
@@ -12,8 +15,11 @@ import type {
|
|||||||
ProductTechnicalFormValues,
|
ProductTechnicalFormValues,
|
||||||
TechnicalFieldType,
|
TechnicalFieldType,
|
||||||
} from '../types/technicalForm'
|
} from '../types/technicalForm'
|
||||||
|
import { ProductTechnicalValuesAiPromptModal } from './ProductTechnicalValuesAiPromptModal'
|
||||||
|
import { TechnicalMultiValueEditor } from './TechnicalMultiValueEditor'
|
||||||
import styles from './VariationsModal.module.css'
|
import styles from './VariationsModal.module.css'
|
||||||
import fieldStyles from './TechnicalFormModal.module.css'
|
import fieldStyles from './TechnicalFormModal.module.css'
|
||||||
|
import aiStyles from '../styles/ai.module.css'
|
||||||
|
|
||||||
interface ProductTechnicalInfoModalProps {
|
interface ProductTechnicalInfoModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -32,12 +38,17 @@ export function ProductTechnicalInfoModal({
|
|||||||
categoryId,
|
categoryId,
|
||||||
onClose,
|
onClose,
|
||||||
}: ProductTechnicalInfoModalProps) {
|
}: ProductTechnicalInfoModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [form, setForm] = useState<CategoryTechnicalForm | null>(null)
|
const [form, setForm] = useState<CategoryTechnicalForm | null>(null)
|
||||||
const [values, setValues] = useState<ProductTechnicalFormValues>({})
|
const [values, setValues] = useState<ProductTechnicalFormValues>({})
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isAiOpen, setIsAiOpen] = useState(false)
|
||||||
|
const [isAiRunning, setIsAiRunning] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [infoMessage, setInfoMessage] = useState('')
|
const [infoMessage, setInfoMessage] = useState('')
|
||||||
|
|
||||||
@@ -45,8 +56,10 @@ export function ProductTechnicalInfoModal({
|
|||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
|
setIsAiOpen(false)
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
setClosing(true)
|
setClosing(true)
|
||||||
|
setIsAiOpen(false)
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setMounted(false)
|
setMounted(false)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
@@ -75,16 +88,16 @@ export function ProductTechnicalInfoModal({
|
|||||||
if (data.message) {
|
if (data.message) {
|
||||||
setInfoMessage(data.message)
|
setInfoMessage(data.message)
|
||||||
} else if (!categoryId) {
|
} else if (!categoryId) {
|
||||||
setInfoMessage('Assign a category to this product before adding technical data.')
|
setInfoMessage(t('products.technical.needCategory'))
|
||||||
} else if (!data.form) {
|
} else if (!data.form) {
|
||||||
setInfoMessage('No technical form is defined for this product category.')
|
setInfoMessage(t('products.technical.noForm'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to load technical data.')
|
setError(t('products.technical.loadError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -92,13 +105,13 @@ export function ProductTechnicalInfoModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || closing) return
|
if (!mounted || closing || isAiOpen) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [mounted, closing, onClose])
|
}, [mounted, closing, isAiOpen, onClose])
|
||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
@@ -123,8 +136,11 @@ export function ProductTechnicalInfoModal({
|
|||||||
return form.fields.every((field) => {
|
return form.fields.every((field) => {
|
||||||
if (!field.isRequired) return true
|
if (!field.isRequired) return true
|
||||||
const value = values[field.key]
|
const value = values[field.key]
|
||||||
if (field.type === 'multi_select') {
|
if (field.type === 'multi_select' || field.type === 'multi_value') {
|
||||||
return Array.isArray(value) && value.length > 0
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.some((item) => String(item).trim().length > 0)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return typeof value === 'string' && value.trim().length > 0
|
return typeof value === 'string' && value.trim().length > 0
|
||||||
})
|
})
|
||||||
@@ -145,13 +161,26 @@ export function ProductTechnicalInfoModal({
|
|||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to save technical data.')
|
setError(t('products.technical.saveError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAiRun(prompt: string) {
|
||||||
|
setIsAiRunning(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await fillProductTechnicalInfoByAi(productId, prompt)
|
||||||
|
setValues((prev) => ({ ...prev, ...result.values }))
|
||||||
|
setInfoMessage(t('products.technical.ai.filled'))
|
||||||
|
setIsAiOpen(false)
|
||||||
|
} finally {
|
||||||
|
setIsAiRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderField(field: CategoryTechnicalForm['fields'][number]) {
|
function renderField(field: CategoryTechnicalForm['fields'][number]) {
|
||||||
const value = values[field.key]
|
const value = values[field.key]
|
||||||
const type = field.type as TechnicalFieldType
|
const type = field.type as TechnicalFieldType
|
||||||
@@ -173,7 +202,7 @@ export function ProductTechnicalInfoModal({
|
|||||||
value={typeof value === 'string' ? value : ''}
|
value={typeof value === 'string' ? value : ''}
|
||||||
onChange={(e) => setFieldValue(field.key, e.target.value)}
|
onChange={(e) => setFieldValue(field.key, e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Select...</option>
|
<option value="">{t('products.technical.selectPlaceholder')}</option>
|
||||||
{field.options.map((option) => (
|
{field.options.map((option) => (
|
||||||
<option key={option.id} value={option.value}>
|
<option key={option.id} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -201,6 +230,19 @@ export function ProductTechnicalInfoModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'multi_value') {
|
||||||
|
const items = Array.isArray(value) ? value.map(String) : []
|
||||||
|
return (
|
||||||
|
<TechnicalMultiValueEditor
|
||||||
|
id={`tech-${field.key}`}
|
||||||
|
values={items}
|
||||||
|
onChange={(next) => setFieldValue(field.key, next)}
|
||||||
|
addLabel={t('products.technical.addValue')}
|
||||||
|
removeLabel={t('products.technical.removeValue')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
id={`tech-${field.key}`}
|
id={`tech-${field.key}`}
|
||||||
@@ -211,68 +253,106 @@ export function ProductTechnicalInfoModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canUseAi = Boolean(form && form.fields.length > 0)
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<>
|
||||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`}
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={onClose}
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="product-technical-title"
|
|
||||||
>
|
>
|
||||||
<div className={styles.header}>
|
<div
|
||||||
<div>
|
className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
<h3 id="product-technical-title" className={styles.title}>
|
onClick={(e) => e.stopPropagation()}
|
||||||
Technical Data
|
role="dialog"
|
||||||
</h3>
|
aria-modal="true"
|
||||||
{productName && <p className={styles.subtitle}>{productName}</p>}
|
aria-labelledby="product-technical-title"
|
||||||
</div>
|
lang={isFa ? 'fa' : 'en'}
|
||||||
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
<X size={20} />
|
>
|
||||||
</button>
|
<div className={styles.header}>
|
||||||
</div>
|
<div>
|
||||||
|
<h3 id="product-technical-title" className={styles.title}>
|
||||||
<div className={styles.body}>
|
{t('products.technical.title')}
|
||||||
{isLoading ? (
|
</h3>
|
||||||
<p className={styles.emptyText}>Loading technical data...</p>
|
{productName && <p className={styles.subtitle}>{productName}</p>}
|
||||||
) : infoMessage && !form ? (
|
</div>
|
||||||
<p className={styles.emptyText}>{infoMessage}</p>
|
|
||||||
) : form && form.fields.length === 0 ? (
|
|
||||||
<p className={styles.emptyText}>This category form has no fields yet.</p>
|
|
||||||
) : (
|
|
||||||
form?.fields.map((field) => (
|
|
||||||
<div key={field.id} className={styles.field}>
|
|
||||||
<label htmlFor={`tech-${field.key}`}>
|
|
||||||
{field.label}
|
|
||||||
{field.isRequired && ' *'}
|
|
||||||
</label>
|
|
||||||
{renderField(field)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className={styles.errorText}>{error}</p>}
|
|
||||||
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
className={styles.closeBtn}
|
||||||
className={styles.submitBtn}
|
onClick={onClose}
|
||||||
onClick={() => void handleSave()}
|
aria-label={t('common.close')}
|
||||||
disabled={!form || !isFormValid() || isSaving || isLoading}
|
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving…' : 'Save'}
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.body}>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className={styles.emptyText}>{t('products.technical.loading')}</p>
|
||||||
|
) : infoMessage && !form ? (
|
||||||
|
<p className={styles.emptyText}>{infoMessage}</p>
|
||||||
|
) : form && form.fields.length === 0 ? (
|
||||||
|
<p className={styles.emptyText}>{t('products.technical.emptyFields')}</p>
|
||||||
|
) : (
|
||||||
|
form?.fields.map((field) => (
|
||||||
|
<div key={field.id} className={styles.field}>
|
||||||
|
<label htmlFor={`tech-${field.key}`}>
|
||||||
|
{field.label}
|
||||||
|
{field.isRequired && ' *'}
|
||||||
|
</label>
|
||||||
|
{renderField(field)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{infoMessage && form ? (
|
||||||
|
<p className={styles.emptyText}>{infoMessage}</p>
|
||||||
|
) : null}
|
||||||
|
{error && <p className={styles.errorText}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={fieldStyles.productTechnicalActions}>
|
||||||
|
<div className={fieldStyles.actionsMain}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.submitBtn}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={!form || !isFormValid() || isSaving || isLoading || isAiRunning}
|
||||||
|
>
|
||||||
|
{isSaving ? t('products.technical.saving') : t('products.technical.save')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{canUseAi ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${aiStyles.aiBtn} ${fieldStyles.actionsAi}`}
|
||||||
|
onClick={() => setIsAiOpen(true)}
|
||||||
|
disabled={isLoading || isSaving || isAiRunning}
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
{isAiRunning
|
||||||
|
? t('products.form.aiGenerating')
|
||||||
|
: t('products.technical.generateAi')}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
,
|
<ProductTechnicalValuesAiPromptModal
|
||||||
document.body,
|
open={isAiOpen}
|
||||||
|
productName={productName}
|
||||||
|
fields={form?.fields ?? []}
|
||||||
|
isRunning={isAiRunning}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isAiRunning) setIsAiOpen(false)
|
||||||
|
}}
|
||||||
|
onRun={handleAiRun}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Sparkles, X } from 'lucide-react'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import type { CategoryTechnicalForm } from '../types/technicalForm'
|
||||||
|
import modalStyles from './CategoryModal.module.css'
|
||||||
|
import aiStyles from '../styles/ai.module.css'
|
||||||
|
import styles from './CategoryAiPromptModal.module.css'
|
||||||
|
|
||||||
|
export function buildProductTechnicalValuesAiPrompt(ctx: {
|
||||||
|
productName: string
|
||||||
|
fields: CategoryTechnicalForm['fields']
|
||||||
|
}) {
|
||||||
|
const productName = ctx.productName || '—'
|
||||||
|
const hasOtherFeatures = ctx.fields.some(
|
||||||
|
(field) =>
|
||||||
|
field.type === 'multi_value' &&
|
||||||
|
/سایر\s*ویژگی/.test(field.label.replace(/\u200c/g, '')),
|
||||||
|
)
|
||||||
|
const fieldLines = ctx.fields
|
||||||
|
.map((field) => {
|
||||||
|
const options =
|
||||||
|
field.type === 'select' || field.type === 'multi_select'
|
||||||
|
? field.options.map((option) => option.value).join(' | ')
|
||||||
|
: ''
|
||||||
|
const optionPart = options ? ` — گزینهها: ${options}` : ''
|
||||||
|
const isOtherFeatures =
|
||||||
|
field.type === 'multi_value' &&
|
||||||
|
/سایر\s*ویژگی/.test(field.label.replace(/\u200c/g, ''))
|
||||||
|
const multiValueHint = isOtherFeatures
|
||||||
|
? ' — هر مشخصه/ویژگی دیگری از محصول که در بقیه فیلدها جا نشده (آرایه رشتهها)'
|
||||||
|
: field.type === 'multi_value'
|
||||||
|
? ' — آرایهای از رشتههای متنی آزاد'
|
||||||
|
: ''
|
||||||
|
return `- ${field.key} (${field.label}, ${field.type}${field.isRequired ? '، الزامی' : ''})${optionPart}${multiValueHint}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const otherFeaturesRule = hasOtherFeatures
|
||||||
|
? `- فیلد «سایر ویژگیها» را با هر مشخصات یا ویژگی دیگری از محصول پر کن که در فیلدهای مشخص دیگر جا نمیگیرد (مثلاً قابلیتها، لوازم همراه، استانداردها، نکات خاص صفحه محصول). هر مورد را بهصورت یک آیتم جدا در آرایه بنویس.`
|
||||||
|
: `- برای فیلدهای multi_value یک آرایه از مقادیر متنی کوتاه بده؛ اگر برچسب آنها «سایر ویژگیها» است، هر مشخصهٔ باقیماندهٔ محصول را آنجا بگذار.`
|
||||||
|
|
||||||
|
return `برای محصول زیر، مقادیر فرم اطلاعات فنی را پر کن.
|
||||||
|
|
||||||
|
محصول: ${productName}
|
||||||
|
|
||||||
|
فیلدهای فرم:
|
||||||
|
${fieldLines || '—'}
|
||||||
|
|
||||||
|
الزامات:
|
||||||
|
- فقط برای همین فیلدها مقدار بده و از کلیدهای دقیق فیلد استفاده کن.
|
||||||
|
- برای select / multi_select فقط از مقادیر مجاز گزینهها استفاده کن.
|
||||||
|
${otherFeaturesRule}
|
||||||
|
- متنها را به فارسی و کوتاه و فنی بنویس.
|
||||||
|
- مشخصات غیرواقعی اختراع نکن؛ اگر مطمئن نیستی آن فیلد را خالی بگذار.
|
||||||
|
- در صورت نیاز، لینک صفحه محصول را در ادامه همین پرامپت اضافه کن (سیستم صفحه را باز میکند و از متن آن استفاده میکند).`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductTechnicalValuesAiPromptModalProps {
|
||||||
|
open: boolean
|
||||||
|
productName: string
|
||||||
|
fields: CategoryTechnicalForm['fields']
|
||||||
|
isRunning: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onRun: (prompt: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function ProductTechnicalValuesAiPromptModal({
|
||||||
|
open,
|
||||||
|
productName,
|
||||||
|
fields,
|
||||||
|
isRunning,
|
||||||
|
onClose,
|
||||||
|
onRun,
|
||||||
|
}: ProductTechnicalValuesAiPromptModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [prompt, setPrompt] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setPrompt(
|
||||||
|
buildProductTechnicalValuesAiPrompt({
|
||||||
|
productName,
|
||||||
|
fields,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setError('')
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, mounted, productName, fields])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !isRunning) onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [mounted, closing, isRunning, onClose])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = prompt.trim()
|
||||||
|
if (trimmed.length < 10) {
|
||||||
|
setError(t('products.form.aiPromptTooShort'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await onRun(trimmed)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(t('products.technical.ai.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="product-technical-ai-prompt-title"
|
||||||
|
lang="fa"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||||
|
<div className={styles.headerBlock}>
|
||||||
|
<h2 id="product-technical-ai-prompt-title" className={modalStyles.title}>
|
||||||
|
{t('products.technical.ai.title')}
|
||||||
|
</h2>
|
||||||
|
<p className={styles.subtitle}>{t('products.technical.ai.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={modalStyles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isRunning}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
|
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
||||||
|
<label htmlFor="product-technical-ai-prompt">{t('products.form.aiPrompt')}</label>
|
||||||
|
<textarea
|
||||||
|
id="product-technical-ai-prompt"
|
||||||
|
className={aiStyles.aiPromptTextarea}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
rows={14}
|
||||||
|
disabled={isRunning}
|
||||||
|
required
|
||||||
|
lang="fa"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className={styles.error}>{error}</p> : null}
|
||||||
|
|
||||||
|
<div className={modalStyles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={modalStyles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isRunning}
|
||||||
|
>
|
||||||
|
{t('products.form.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={aiStyles.aiBtn}
|
||||||
|
disabled={isRunning || prompt.trim().length < 10}
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import {
|
import {
|
||||||
countSelectedOptions,
|
countSelectedOptions,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
saveProductVariationValues,
|
saveProductVariationValues,
|
||||||
type ProductVariationSelection,
|
type ProductVariationSelection,
|
||||||
} from '../services/productVariationService'
|
} from '../services/productVariationService'
|
||||||
|
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
|
||||||
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
||||||
import styles from './VariationsModal.module.css'
|
import styles from './VariationsModal.module.css'
|
||||||
|
|
||||||
@@ -30,6 +33,9 @@ export function ProductVariantsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onVariantsChange,
|
onVariantsChange,
|
||||||
}: ProductVariantsModalProps) {
|
}: ProductVariantsModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
||||||
@@ -72,7 +78,7 @@ export function ProductVariantsModal({
|
|||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to load product variations.')
|
setError(t('products.variants.loadError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -90,6 +96,12 @@ export function ProductVariantsModal({
|
|||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
|
function variationLabel(variation: ProductVariationSelection) {
|
||||||
|
if (variation.type === 'color') return t('categories.variations.type.color')
|
||||||
|
if (variation.type === 'size') return t('categories.variations.type.size')
|
||||||
|
return variation.name
|
||||||
|
}
|
||||||
|
|
||||||
function handleSelectionChange(variationId: string, optionIds: string[]) {
|
function handleSelectionChange(variationId: string, optionIds: string[]) {
|
||||||
setVariations((prev) =>
|
setVariations((prev) =>
|
||||||
prev.map((variation) =>
|
prev.map((variation) =>
|
||||||
@@ -113,7 +125,7 @@ export function ProductVariantsModal({
|
|||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to save product variations.')
|
setError(t('products.variants.saveError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
@@ -131,54 +143,63 @@ export function ProductVariantsModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="product-variants-title"
|
aria-labelledby="product-variants-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
>
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h3 id="product-variants-title" className={styles.title}>
|
<h3 id="product-variants-title" className={styles.title}>
|
||||||
Product Variations
|
{t('products.variants.title')}
|
||||||
</h3>
|
</h3>
|
||||||
{productName && <p className={styles.subtitle}>{productName}</p>}
|
{productName && <p className={styles.subtitle}>{productName}</p>}
|
||||||
</div>
|
</div>
|
||||||
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
<button
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{!categoryId && (
|
{!categoryId && (
|
||||||
<p className={styles.errorText}>
|
<p className={styles.errorText}>{t('products.variants.needCategory')}</p>
|
||||||
Assign a category to this product before managing variations.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className={styles.errorText}>{error}</p>}
|
{error && <p className={styles.errorText}>{error}</p>}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className={styles.emptyText}>Loading variations...</p>
|
<p className={styles.emptyText}>{t('products.variants.loading')}</p>
|
||||||
) : variations.length === 0 ? (
|
) : variations.length === 0 ? (
|
||||||
<p className={styles.emptyText}>
|
<p className={styles.emptyText}>
|
||||||
{categoryId
|
{categoryId
|
||||||
? 'This category has no variations. Add variations on the category first.'
|
? t('products.variants.emptyCategory')
|
||||||
: 'No variations available.'}
|
: t('products.variants.empty')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.variationFields}>
|
<div className={styles.variationFields}>
|
||||||
{variations.map((variation) => (
|
{variations.map((variation) => {
|
||||||
<div key={variation.id} className={styles.field}>
|
const name = variationLabel(variation)
|
||||||
<label htmlFor={`variation-${variation.id}`}>{variation.name}</label>
|
return (
|
||||||
<MultiSelectDropdown
|
<div key={variation.id} className={styles.field}>
|
||||||
id={`variation-${variation.id}`}
|
<label htmlFor={`variation-${variation.id}`}>{name}</label>
|
||||||
options={variation.options.map((option) => ({
|
<MultiSelectDropdown
|
||||||
value: option.id,
|
id={`variation-${variation.id}`}
|
||||||
label: option.label,
|
options={variation.options.map((option) => ({
|
||||||
}))}
|
value: option.id,
|
||||||
value={variation.selectedOptionIds}
|
label: localizeVariationOptionLabel(option.label, t),
|
||||||
onChange={(optionIds) => handleSelectionChange(variation.id, optionIds)}
|
}))}
|
||||||
placeholder={`Select ${variation.name.toLowerCase()} values`}
|
value={variation.selectedOptionIds}
|
||||||
disabled={isSaving}
|
onChange={(optionIds) =>
|
||||||
/>
|
handleSelectionChange(variation.id, optionIds)
|
||||||
</div>
|
}
|
||||||
))}
|
placeholder={t('products.variants.selectValues', { name })}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -189,7 +210,7 @@ export function ProductVariantsModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -197,13 +218,12 @@ export function ProductVariantsModal({
|
|||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
disabled={!categoryId || isLoading || isSaving || variations.length === 0}
|
disabled={!categoryId || isLoading || isSaving || variations.length === 0}
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving…' : 'Save variations'}
|
{isSaving ? t('products.variants.saving') : t('products.variants.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
document.body,
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
|
||||||
|
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
|
||||||
|
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
|
||||||
|
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
||||||
|
|
||||||
|
@keyframes overlayFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes overlayFadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes modalFadeOut {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b91c1c;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasksBlock {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasksHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasksLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addTaskRowBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.08);
|
||||||
|
border: 1px dashed rgba(var(--primary-rgb) / 0.4);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addTaskRowBtn:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.14);
|
||||||
|
border-color: rgba(var(--primary-rgb) / 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskRow input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskRow input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeTaskBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeTaskBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeTaskBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn,
|
||||||
|
.saveBtn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
box-shadow: 0 4px 14px rgba(var(--primary-dark-rgb) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:disabled,
|
||||||
|
.saveBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.timeRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
createRollCall,
|
||||||
|
updateRollCall,
|
||||||
|
type RollCall,
|
||||||
|
type RollCallInput,
|
||||||
|
} from '../services/rollCallService'
|
||||||
|
import modalStyles from './VariationsModal.module.css'
|
||||||
|
import styles from './RollCallFormModal.module.css'
|
||||||
|
|
||||||
|
interface RollCallFormModalProps {
|
||||||
|
open: boolean
|
||||||
|
rollCall: RollCall | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (rollCall: RollCall) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
function emptyTasks() {
|
||||||
|
return ['']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RollCallFormModal({
|
||||||
|
open,
|
||||||
|
rollCall,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: RollCallFormModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale, dir } = useLocale()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [workDate, setWorkDate] = useState('')
|
||||||
|
const [startTime, setStartTime] = useState('09:00')
|
||||||
|
const [endTime, setEndTime] = useState('17:00')
|
||||||
|
const [tasks, setTasks] = useState<string[]>(emptyTasks())
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setError('')
|
||||||
|
if (rollCall) {
|
||||||
|
setWorkDate(rollCall.workDate)
|
||||||
|
setStartTime(rollCall.startTime)
|
||||||
|
setEndTime(rollCall.endTime)
|
||||||
|
setTasks(
|
||||||
|
rollCall.tasks.length > 0
|
||||||
|
? rollCall.tasks.map((task) => task.title)
|
||||||
|
: emptyTasks(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setWorkDate(new Date().toISOString().slice(0, 10))
|
||||||
|
setStartTime('09:00')
|
||||||
|
setEndTime('17:00')
|
||||||
|
setTasks(emptyTasks())
|
||||||
|
}
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, mounted, rollCall])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
|
||||||
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [mounted, closing, onClose])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
const isEdit = Boolean(rollCall?.id)
|
||||||
|
|
||||||
|
function updateTask(index: number, value: string) {
|
||||||
|
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTask() {
|
||||||
|
setTasks((prev) => [...prev, ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTask(index: number) {
|
||||||
|
setTasks((prev) => {
|
||||||
|
if (prev.length <= 1) return ['']
|
||||||
|
return prev.filter((_, i) => i !== index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const cleanedTasks = tasks.map((task) => task.trim()).filter(Boolean)
|
||||||
|
if (!workDate || !startTime || !endTime) {
|
||||||
|
setError(t('rollCall.error.incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cleanedTasks.length === 0) {
|
||||||
|
setError(t('rollCall.error.tasksRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: RollCallInput = {
|
||||||
|
workDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
tasks: cleanedTasks.map((title) => ({ title })),
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = rollCall
|
||||||
|
? await updateRollCall(rollCall.id, input)
|
||||||
|
: await createRollCall(input)
|
||||||
|
onSaved(result.rollCall)
|
||||||
|
showToast(
|
||||||
|
rollCall ? t('rollCall.toast.updated') : t('rollCall.toast.created'),
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError ? err.message : t('rollCall.error.save')
|
||||||
|
setError(message)
|
||||||
|
showToast(message, 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="roll-call-form-title"
|
||||||
|
lang={locale}
|
||||||
|
dir={dir}
|
||||||
|
>
|
||||||
|
<div className={modalStyles.header}>
|
||||||
|
<div>
|
||||||
|
<h2 id="roll-call-form-title" className={modalStyles.title}>
|
||||||
|
{isEdit ? t('rollCall.modal.editTitle') : t('rollCall.modal.addTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className={modalStyles.subtitle}>{t('rollCall.modal.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={modalStyles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={modalStyles.body} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={modalStyles.field}>
|
||||||
|
<label htmlFor="roll-call-date">{t('rollCall.field.date')}</label>
|
||||||
|
<input
|
||||||
|
id="roll-call-date"
|
||||||
|
type="date"
|
||||||
|
value={workDate}
|
||||||
|
onChange={(e) => setWorkDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.timeRow}>
|
||||||
|
<div className={modalStyles.field}>
|
||||||
|
<label htmlFor="roll-call-start">{t('rollCall.field.startTime')}</label>
|
||||||
|
<input
|
||||||
|
id="roll-call-start"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={modalStyles.field}>
|
||||||
|
<label htmlFor="roll-call-end">{t('rollCall.field.endTime')}</label>
|
||||||
|
<input
|
||||||
|
id="roll-call-end"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tasksBlock}>
|
||||||
|
<div className={styles.tasksHeader}>
|
||||||
|
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.taskList}>
|
||||||
|
{tasks.map((task, index) => (
|
||||||
|
<div key={index} className={styles.taskRow}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={task}
|
||||||
|
onChange={(e) => updateTask(index, e.target.value)}
|
||||||
|
placeholder={t('rollCall.field.taskPlaceholder')}
|
||||||
|
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeTaskBtn}
|
||||||
|
onClick={() => removeTask(index)}
|
||||||
|
aria-label={t('rollCall.removeTask')}
|
||||||
|
disabled={tasks.length === 1 && !task.trim()}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
|
||||||
|
<Plus size={18} strokeWidth={2.5} />
|
||||||
|
{t('rollCall.addTask')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('rollCall.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.saveBtn} disabled={saving}>
|
||||||
|
{saving ? t('rollCall.saving') : t('rollCall.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { updateRollCall, type RollCall } from '../services/rollCallService'
|
||||||
|
import modalStyles from './VariationsModal.module.css'
|
||||||
|
import styles from './RollCallFormModal.module.css'
|
||||||
|
|
||||||
|
interface RollCallTasksModalProps {
|
||||||
|
open: boolean
|
||||||
|
rollCall: RollCall | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (rollCall: RollCall) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function RollCallTasksModal({
|
||||||
|
open,
|
||||||
|
rollCall,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: RollCallTasksModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale, dir } = useLocale()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [tasks, setTasks] = useState<string[]>([''])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setError('')
|
||||||
|
setTasks(
|
||||||
|
rollCall && rollCall.tasks.length > 0
|
||||||
|
? rollCall.tasks.map((task) => task.title)
|
||||||
|
: [''],
|
||||||
|
)
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, mounted, rollCall])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [mounted, closing, onClose])
|
||||||
|
|
||||||
|
if (!mounted || !rollCall) return null
|
||||||
|
|
||||||
|
function updateTask(index: number, value: string) {
|
||||||
|
setTasks((prev) => prev.map((task, i) => (i === index ? value : task)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTask() {
|
||||||
|
setTasks((prev) => [...prev, ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTask(index: number) {
|
||||||
|
setTasks((prev) => {
|
||||||
|
if (prev.length <= 1) return ['']
|
||||||
|
return prev.filter((_, i) => i !== index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!rollCall) return
|
||||||
|
setError('')
|
||||||
|
const cleaned = tasks.map((task) => task.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await updateRollCall(rollCall.id, {
|
||||||
|
tasks: cleaned.map((title) => ({ title })),
|
||||||
|
})
|
||||||
|
onSaved(result.rollCall)
|
||||||
|
showToast(t('rollCall.toast.tasksSaved'), 'success')
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError ? err.message : t('rollCall.error.save')
|
||||||
|
setError(message)
|
||||||
|
showToast(message, 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.modal} ${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="roll-call-tasks-title"
|
||||||
|
lang={locale}
|
||||||
|
dir={dir}
|
||||||
|
>
|
||||||
|
<div className={modalStyles.header}>
|
||||||
|
<div>
|
||||||
|
<h2 id="roll-call-tasks-title" className={modalStyles.title}>
|
||||||
|
{t('rollCall.tasksModal.title')}
|
||||||
|
</h2>
|
||||||
|
<p className={modalStyles.subtitle}>{t('rollCall.tasksModal.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={modalStyles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={modalStyles.body} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.tasksBlock}>
|
||||||
|
<div className={styles.tasksHeader}>
|
||||||
|
<span className={styles.tasksLabel}>{t('rollCall.field.tasks')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.taskList}>
|
||||||
|
{tasks.map((task, index) => (
|
||||||
|
<div key={index} className={styles.taskRow}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={task}
|
||||||
|
onChange={(e) => updateTask(index, e.target.value)}
|
||||||
|
placeholder={t('rollCall.field.taskPlaceholder')}
|
||||||
|
aria-label={t('rollCall.field.taskN', { n: index + 1 })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeTaskBtn}
|
||||||
|
onClick={() => removeTask(index)}
|
||||||
|
aria-label={t('rollCall.removeTask')}
|
||||||
|
disabled={tasks.length === 1 && !task.trim()}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className={styles.addTaskRowBtn} onClick={addTask}>
|
||||||
|
<Plus size={18} strokeWidth={2.5} />
|
||||||
|
{t('rollCall.addTask')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t('rollCall.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.saveBtn} disabled={saving}>
|
||||||
|
{saving ? t('rollCall.saving') : t('rollCall.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -96,11 +96,21 @@
|
|||||||
.dropdownInFlow {
|
.dropdownInFlow {
|
||||||
position: static;
|
position: static;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
max-height: none;
|
max-height: min(280px, 40vh);
|
||||||
overflow: visible;
|
overflow-y: auto;
|
||||||
z-index: auto;
|
z-index: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes dropdownIn {
|
@keyframes dropdownIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -217,6 +227,8 @@
|
|||||||
.optionEn {
|
.optionEn {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionEn[dir='rtl'] {
|
.optionEn[dir='rtl'] {
|
||||||
|
|||||||
@@ -195,13 +195,17 @@ export function SearchableSelect({
|
|||||||
{cat.nameEn ? (
|
{cat.nameEn ? (
|
||||||
<>
|
<>
|
||||||
<span className={styles.optionSep}>·</span>
|
<span className={styles.optionSep}>·</span>
|
||||||
<span className={styles.optionEn}>{cat.nameEn}</span>
|
<span className={styles.optionEn} dir="ltr" lang="en">
|
||||||
|
{cat.nameEn}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className={styles.optionEn}>{cat.nameEn}</span>
|
<span className={styles.optionEn} dir="ltr" lang="en">
|
||||||
|
{cat.nameEn}
|
||||||
|
</span>
|
||||||
{cat.nameFa ? (
|
{cat.nameFa ? (
|
||||||
<>
|
<>
|
||||||
<span className={styles.optionSep}>·</span>
|
<span className={styles.optionSep}>·</span>
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--modal-overlay-bg);
|
||||||
|
backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b91c1c;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label,
|
||||||
|
.fieldLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
min-height: var(--field-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: calc(var(--field-height) + 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 50px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipSelected {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--primary-rgb) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipFa {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipEn {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.72;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipSelected .chipEn {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountRow input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountUnit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pasKrayeCheck {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-inline-start: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pasKrayeCheck input {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountRow input:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
background: rgba(148, 163, 184, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkRow input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkHint {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:disabled,
|
||||||
|
.cancelBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayIn {
|
||||||
|
animation: overlayFadeIn 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayOut {
|
||||||
|
animation: overlayFadeOut 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalIn {
|
||||||
|
animation: modalFadeIn 0.25s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalOut {
|
||||||
|
animation: modalFadeOut 0.22s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlayFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlayFadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalFadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import type {
|
||||||
|
ShippingCostGroup,
|
||||||
|
ShippingCostGroupFormPayload,
|
||||||
|
ShippingMethodId,
|
||||||
|
} from '../types/shippingCostGroup'
|
||||||
|
import { SHIPPING_METHOD_IDS } from '../types/shippingCostGroup'
|
||||||
|
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||||
|
import styles from './ShippingCostGroupModal.module.css'
|
||||||
|
|
||||||
|
const METHOD_EN_LABELS: Record<ShippingMethodId, string> = {
|
||||||
|
post: 'Post',
|
||||||
|
tipax: 'Tipax',
|
||||||
|
mahex: 'Mahex',
|
||||||
|
freight: 'Freight',
|
||||||
|
air: 'Air',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShippingCostGroupModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: ShippingCostGroupFormPayload) => void
|
||||||
|
editingGroup?: ShippingCostGroup | null
|
||||||
|
isSubmitting?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function ShippingCostGroupModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
editingGroup = null,
|
||||||
|
isSubmitting = false,
|
||||||
|
}: ShippingCostGroupModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [nameFa, setNameFa] = useState('')
|
||||||
|
const [nameEn, setNameEn] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [methods, setMethods] = useState<ShippingMethodId[]>([])
|
||||||
|
const [defaultAmount, setDefaultAmount] = useState('')
|
||||||
|
const [pasKraye, setPasKraye] = useState(false)
|
||||||
|
const [isDefault, setIsDefault] = useState(false)
|
||||||
|
const [formError, setFormError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setNameFa(editingGroup?.nameFa ?? '')
|
||||||
|
setNameEn(editingGroup?.nameEn ?? '')
|
||||||
|
setDescription(editingGroup?.description ?? '')
|
||||||
|
setMethods(editingGroup?.methods ?? [])
|
||||||
|
setPasKraye(editingGroup?.pasKraye ?? false)
|
||||||
|
setDefaultAmount(
|
||||||
|
editingGroup
|
||||||
|
? formatIrtInput(String(Math.round(editingGroup.defaultAmount)))
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
setIsDefault(editingGroup?.isDefault ?? false)
|
||||||
|
setFormError('')
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, editingGroup, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !isSubmitting) onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [mounted, closing, onClose, isSubmitting])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
function toggleMethod(method: ShippingMethodId) {
|
||||||
|
setMethods((prev) =>
|
||||||
|
prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFormError('')
|
||||||
|
|
||||||
|
const trimmedFa = nameFa.trim()
|
||||||
|
const trimmedEn = nameEn.trim()
|
||||||
|
const amount = pasKraye ? 0 : parseIrtInput(defaultAmount)
|
||||||
|
|
||||||
|
if (!trimmedFa || !trimmedEn) {
|
||||||
|
setFormError(t('store.shipping.form.errorRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!methods.length) {
|
||||||
|
setFormError(t('store.shipping.form.errorMethods'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!pasKraye && (amount === null || amount < 0)) {
|
||||||
|
setFormError(t('store.shipping.form.errorAmount'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
nameFa: trimmedFa,
|
||||||
|
nameEn: trimmedEn,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
methods,
|
||||||
|
defaultAmount: amount ?? 0,
|
||||||
|
pasKraye,
|
||||||
|
isDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={() => !isSubmitting && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="shipping-group-modal-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h3 id="shipping-group-modal-title" className={styles.title}>
|
||||||
|
{editingGroup ? t('store.shipping.form.editTitle') : t('store.shipping.form.addTitle')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
{formError ? (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="shipping-name-fa">{t('store.shipping.form.nameFa')}</label>
|
||||||
|
<input
|
||||||
|
id="shipping-name-fa"
|
||||||
|
type="text"
|
||||||
|
dir="rtl"
|
||||||
|
className="faText"
|
||||||
|
placeholder={t('store.shipping.form.nameFaPlaceholder')}
|
||||||
|
value={nameFa}
|
||||||
|
onChange={(e) => setNameFa(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="shipping-name-en">{t('store.shipping.form.nameEn')}</label>
|
||||||
|
<input
|
||||||
|
id="shipping-name-en"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
|
placeholder={t('store.shipping.form.nameEnPlaceholder')}
|
||||||
|
value={nameEn}
|
||||||
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="shipping-description">{t('store.shipping.form.description')}</label>
|
||||||
|
<textarea
|
||||||
|
id="shipping-description"
|
||||||
|
rows={3}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
placeholder={t('store.shipping.form.descriptionPlaceholder')}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('store.shipping.form.methods')}</span>
|
||||||
|
<p className={styles.hint}>{t('store.shipping.form.methodsHint')}</p>
|
||||||
|
<div className={styles.chipGrid} role="group" aria-label={t('store.shipping.form.methods')}>
|
||||||
|
{SHIPPING_METHOD_IDS.map((method) => {
|
||||||
|
const checked = methods.includes(method)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={method}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.chip} ${checked ? styles.chipSelected : ''}`}
|
||||||
|
onClick={() => toggleMethod(method)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-pressed={checked}
|
||||||
|
>
|
||||||
|
<span className={styles.chipFa}>
|
||||||
|
{t(`store.shipping.method.${method}` as `store.shipping.method.${typeof method}`)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.chipEn} dir="ltr" lang="en">
|
||||||
|
{METHOD_EN_LABELS[method]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="shipping-default-amount">
|
||||||
|
{t('store.shipping.form.defaultAmount')}
|
||||||
|
</label>
|
||||||
|
<p className={styles.hint}>{t('store.shipping.form.defaultAmountHint')}</p>
|
||||||
|
<div className={styles.amountRow}>
|
||||||
|
<input
|
||||||
|
id="shipping-default-amount"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
|
placeholder={t('store.shipping.form.defaultAmountPlaceholder')}
|
||||||
|
value={defaultAmount}
|
||||||
|
onChange={(e) => setDefaultAmount(formatIrtInput(e.target.value))}
|
||||||
|
disabled={isSubmitting || pasKraye}
|
||||||
|
required={!pasKraye}
|
||||||
|
/>
|
||||||
|
<span className={styles.amountUnit}>IRT</span>
|
||||||
|
<label className={styles.pasKrayeCheck} htmlFor="shipping-pas-kraye">
|
||||||
|
<input
|
||||||
|
id="shipping-pas-kraye"
|
||||||
|
type="checkbox"
|
||||||
|
checked={pasKraye}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked
|
||||||
|
setPasKraye(checked)
|
||||||
|
if (checked) setDefaultAmount('')
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<span>{t('store.shipping.form.pasKraye')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{pasKraye ? (
|
||||||
|
<p className={styles.hint}>{t('store.shipping.form.pasKrayeHint')}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={styles.checkRow} htmlFor="shipping-is-default">
|
||||||
|
<input
|
||||||
|
id="shipping-is-default"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isDefault}
|
||||||
|
onChange={(e) => setIsDefault(e.target.checked)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<span className={styles.checkText}>
|
||||||
|
<span className={styles.checkTitle}>
|
||||||
|
{t('store.shipping.form.isDefault')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.checkHint}>
|
||||||
|
{t('store.shipping.form.isDefaultHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t('store.shipping.form.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? t('store.shipping.form.saving')
|
||||||
|
: editingGroup
|
||||||
|
? t('store.shipping.form.update')
|
||||||
|
: t('store.shipping.form.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s, opacity 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.04);
|
||||||
|
border-color: rgba(var(--primary-rgb) / 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowDisabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.names {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameFa {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameSep {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameEn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defaultBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
border: 1px solid rgba(var(--primary-rgb) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 48%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipValue {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipNumber {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
border: 1px solid rgba(var(--primary-rgb) / 0.18);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-inline-start: 12px;
|
||||||
|
padding-inline-start: 14px;
|
||||||
|
border-inline-start: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn {
|
||||||
|
position: relative;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exceptionBtn {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exceptionBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
inset-inline-end: 2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: white;
|
||||||
|
background: var(--primary);
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
inset-inline-end: 2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html:not([data-theme='dark'])) .categoryBadge {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: rgba(148, 163, 184, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: none;
|
||||||
|
order: 3;
|
||||||
|
margin-inline-start: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
border-inline-start: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Layers, MapPin, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import type { ShippingCostGroup, ShippingMethodId } from '../types/shippingCostGroup'
|
||||||
|
import { getGroupProvinceExceptions } from '../services/shippingCostGroupService'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { formatIrtPrice } from '../utils/irtPrice'
|
||||||
|
import { Tooltip } from './Tooltip'
|
||||||
|
import styles from './ShippingCostGroupRow.module.css'
|
||||||
|
|
||||||
|
interface ShippingCostGroupRowProps {
|
||||||
|
group: ShippingCostGroup
|
||||||
|
disabled?: boolean
|
||||||
|
onEdit: (id: string) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
onExceptions: (id: string) => void
|
||||||
|
onCategories: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodLabelKey(method: ShippingMethodId) {
|
||||||
|
return `store.shipping.method.${method}` as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShippingCostGroupRow({
|
||||||
|
group,
|
||||||
|
disabled = false,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
onExceptions,
|
||||||
|
onCategories,
|
||||||
|
}: ShippingCostGroupRowProps) {
|
||||||
|
const t = useT()
|
||||||
|
const nameFa = group.nameFa?.trim() || ''
|
||||||
|
const nameEn = group.nameEn?.trim() || ''
|
||||||
|
const exceptionCount = getGroupProvinceExceptions(group).length
|
||||||
|
const categoryCount = group.categoryIds?.length ?? group.categories?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.row} ${disabled ? styles.rowDisabled : ''}`}>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<div className={styles.names}>
|
||||||
|
{nameFa ? <span className={styles.nameFa}>{nameFa}</span> : null}
|
||||||
|
{nameFa && nameEn ? <span className={styles.nameSep}>·</span> : null}
|
||||||
|
{nameEn ? (
|
||||||
|
<span className={styles.nameEn} dir="ltr" lang="en">
|
||||||
|
{nameEn}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{group.isDefault ? (
|
||||||
|
<span className={styles.defaultBadge}>
|
||||||
|
{t('store.shipping.row.defaultBadge')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{group.description ? (
|
||||||
|
<p className={styles.description}>{group.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<div className={styles.chip}>
|
||||||
|
<span className={styles.chipLabel}>{t('store.shipping.row.defaultAmount')}</span>
|
||||||
|
<span className={styles.chipValue}>
|
||||||
|
<span className={styles.chipNumber}>
|
||||||
|
{group.pasKraye
|
||||||
|
? t('store.shipping.form.pasKraye')
|
||||||
|
: formatIrtPrice(group.defaultAmount)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.methods.map((method) => (
|
||||||
|
<div key={method} className={styles.methodChip}>
|
||||||
|
{t(methodLabelKey(method))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<Tooltip label={t('store.shipping.tooltip.categories')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.controlBtn}
|
||||||
|
onClick={() => onCategories(group.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('store.shipping.tooltip.categories')}
|
||||||
|
>
|
||||||
|
<Layers size={16} />
|
||||||
|
{categoryCount > 0 ? (
|
||||||
|
<span className={styles.categoryBadge}>{categoryCount}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('store.shipping.tooltip.exceptions')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.controlBtn} ${styles.exceptionBtn}`}
|
||||||
|
onClick={() => onExceptions(group.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('store.shipping.tooltip.exceptions')}
|
||||||
|
>
|
||||||
|
<MapPin size={16} />
|
||||||
|
{exceptionCount > 0 ? (
|
||||||
|
<span className={styles.exceptionBadge}>{exceptionCount}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('store.shipping.tooltip.edit')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.controlBtn}
|
||||||
|
onClick={() => onEdit(group.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('store.shipping.tooltip.edit')}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('store.shipping.tooltip.remove')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.controlBtn} ${styles.danger}`}
|
||||||
|
onClick={() => onRemove(group.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('store.shipping.tooltip.remove')}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
listProductCategories,
|
||||||
|
mapProductCategoryToUi,
|
||||||
|
} from '../services/productCategoryService'
|
||||||
|
import { CategoryTreeMultiSelect } from './CategoryTreeMultiSelect'
|
||||||
|
import type { CategoryTreeItem } from './CategoryTreeSelect'
|
||||||
|
import modalStyles from './VariationsModal.module.css'
|
||||||
|
import styles from './PickStoreSpecialItemsModal.module.css'
|
||||||
|
|
||||||
|
interface ShippingGroupCategoriesModalProps {
|
||||||
|
open: boolean
|
||||||
|
groupName: string
|
||||||
|
selectedCategoryIds: string[]
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (categoryIds: string[]) => Promise<void> | void
|
||||||
|
isSubmitting?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function ShippingGroupCategoriesModal({
|
||||||
|
open,
|
||||||
|
groupName,
|
||||||
|
selectedCategoryIds,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isSubmitting = false,
|
||||||
|
}: ShippingGroupCategoriesModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [categories, setCategories] = useState<CategoryTreeItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setSelectedIds(selectedCategoryIds)
|
||||||
|
setError('')
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when open toggles
|
||||||
|
}, [open, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
void loadCategories(controller.signal)
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !isSubmitting && !saving) onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [mounted, closing, onClose, isSubmitting, saving])
|
||||||
|
|
||||||
|
async function loadCategories(signal?: AbortSignal) {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await listProductCategories(signal)
|
||||||
|
setCategories(
|
||||||
|
items.map(mapProductCategoryToUi).map((category) => ({
|
||||||
|
id: category.id,
|
||||||
|
parentId: category.parentId,
|
||||||
|
nameEn: category.nameEn,
|
||||||
|
nameFa: category.nameFa,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(t('store.shipping.groupCategories.errorLoad'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelection(categoryId: string) {
|
||||||
|
setSelectedIds((prev) =>
|
||||||
|
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await onSave(selectedIds)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(t('store.shipping.groupCategories.errorSave'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = isSubmitting || saving
|
||||||
|
const treeReady = useMemo(() => categories.length > 0, [categories.length])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !busy && onClose()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="shipping-group-categories-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={modalStyles.header}>
|
||||||
|
<div>
|
||||||
|
<h2 id="shipping-group-categories-title" className={modalStyles.title}>
|
||||||
|
{t('store.shipping.groupCategories.title')}
|
||||||
|
</h2>
|
||||||
|
<p className={modalStyles.subtitle}>
|
||||||
|
{t('store.shipping.groupCategories.subtitle', { name: groupName })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={modalStyles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={modalStyles.body}>
|
||||||
|
{error ? <p className={styles.error}>{error}</p> : null}
|
||||||
|
|
||||||
|
{isLoading && !treeReady ? (
|
||||||
|
<p className={styles.empty}>{t('store.shipping.groupCategories.loading')}</p>
|
||||||
|
) : (
|
||||||
|
<CategoryTreeMultiSelect
|
||||||
|
categories={categories}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggle={toggleSelection}
|
||||||
|
placeholder={t('categories.select.search')}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t('store.shipping.form.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.confirmBtn}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? t('store.shipping.form.saving')
|
||||||
|
: t('store.shipping.groupCategories.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--modal-overlay-bg);
|
||||||
|
backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 20px 12px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn:disabled,
|
||||||
|
.closeBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 28px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
|
||||||
|
.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
|
||||||
|
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
|
||||||
|
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
||||||
|
|
||||||
|
@keyframes overlayFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes overlayFadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes modalFadeOut {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import type { CityOption } from '../services/citiesService'
|
||||||
|
import type { ShippingCostGroupProvinceException } from '../types/shippingCostGroup'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { formatIrtPrice } from '../utils/irtPrice'
|
||||||
|
import { StaticShippingExceptionModal } from './StaticShippingExceptionModal'
|
||||||
|
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
|
||||||
|
import { Tooltip } from './Tooltip'
|
||||||
|
import styles from './ShippingGroupExceptionsModal.module.css'
|
||||||
|
|
||||||
|
interface ShippingGroupExceptionsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
groupName: string
|
||||||
|
provinces: CityOption[]
|
||||||
|
exceptions: ShippingCostGroupProvinceException[]
|
||||||
|
isSubmitting?: boolean
|
||||||
|
onSave: (items: Array<{ provinceId: string; amount: number }>) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function ShippingGroupExceptionsModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
groupName,
|
||||||
|
provinces,
|
||||||
|
exceptions,
|
||||||
|
isSubmitting = false,
|
||||||
|
onSave,
|
||||||
|
}: ShippingGroupExceptionsModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [items, setItems] = useState<ShippingCostGroupProvinceException[]>([])
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<ShippingCostGroupProvinceException | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] =
|
||||||
|
useState<ShippingCostGroupProvinceException | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const provinceNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const province of provinces) {
|
||||||
|
map.set(
|
||||||
|
province.id,
|
||||||
|
isFa
|
||||||
|
? province.nameFa || province.nameEn
|
||||||
|
: province.nameEn || province.nameFa,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [provinces, isFa])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setAddOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setItems(exceptions)
|
||||||
|
}, [open, exceptions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !saving && !isSubmitting && !addOpen && !deleteTarget) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [mounted, closing, onClose, saving, isSubmitting, addOpen, deleteTarget])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
async function persist(next: Array<{ provinceId: string; amount: number }>) {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(next)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpsert(data: { provinceId: string; amount: number }) {
|
||||||
|
const next = editing
|
||||||
|
? items.map((item) =>
|
||||||
|
item.provinceId === editing.provinceId
|
||||||
|
? { ...item, provinceId: data.provinceId, amount: data.amount }
|
||||||
|
: item,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
...items.filter((item) => item.provinceId !== data.provinceId),
|
||||||
|
{
|
||||||
|
id: `tmp-${data.provinceId}`,
|
||||||
|
provinceId: data.provinceId,
|
||||||
|
amount: data.amount,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
|
||||||
|
setAddOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
const next = items.filter((item) => item.provinceId !== deleteTarget.provinceId)
|
||||||
|
await persist(next.map(({ provinceId, amount }) => ({ provinceId, amount })))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = saving || isSubmitting
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={() => !busy && !addOpen && !deleteTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="group-exceptions-modal-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.headerText}>
|
||||||
|
<h3 id="group-exceptions-modal-title" className={styles.title}>
|
||||||
|
{t('store.shipping.groupException.title')}
|
||||||
|
</h3>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
{t('store.shipping.groupException.subtitle', { name: groupName })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<Tooltip label={t('store.shipping.groupException.add')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setAddOpen(true)
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('store.shipping.groupException.add')}
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.body}>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className={styles.empty}>{t('store.shipping.groupException.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.provinceId} className={styles.row}>
|
||||||
|
<span className={styles.province}>
|
||||||
|
{provinceNameById.get(item.provinceId) ?? item.provinceId}
|
||||||
|
</span>
|
||||||
|
<span className={styles.amount}>{formatIrtPrice(item.amount)}</span>
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<Tooltip label={t('store.shipping.tooltip.edit')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.controlBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(item)
|
||||||
|
setAddOpen(true)
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('store.shipping.tooltip.edit')}
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('store.shipping.tooltip.remove')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.controlBtn} ${styles.danger}`}
|
||||||
|
onClick={() => setDeleteTarget(item)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('store.shipping.tooltip.remove')}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StaticShippingExceptionModal
|
||||||
|
open={addOpen}
|
||||||
|
provinces={provinces}
|
||||||
|
usedProvinceIds={items.map((item) => item.provinceId)}
|
||||||
|
editing={
|
||||||
|
editing
|
||||||
|
? { provinceId: editing.provinceId, amount: editing.amount }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isSubmitting={busy}
|
||||||
|
onClose={() => {
|
||||||
|
if (!busy) {
|
||||||
|
setAddOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSubmit={(data) => void handleUpsert(data)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title={t('store.shipping.groupException.deleteTitle')}
|
||||||
|
message={
|
||||||
|
deleteTarget
|
||||||
|
? t('store.shipping.groupException.deleteMessage', {
|
||||||
|
name:
|
||||||
|
provinceNameById.get(deleteTarget.provinceId) ??
|
||||||
|
deleteTarget.provinceId,
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onConfirm={() => void handleDelete()}
|
||||||
|
onCancel={() => !busy && setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,22 +25,24 @@
|
|||||||
|
|
||||||
.brandLogo {
|
.brandLogo {
|
||||||
display: block;
|
display: block;
|
||||||
width: 40px;
|
/* Same height as brandName + domain stack; width follows image aspect ratio */
|
||||||
height: 40px;
|
height: calc(13px * 1.2 + 2px + 11px * 1.2);
|
||||||
|
width: auto;
|
||||||
|
max-width: 7.5rem;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brandLogoUploaded {
|
.brandLogoUploaded {
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
background: var(--card-media-bg);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brandEditBtn {
|
.brandEditBtn {
|
||||||
width: 40px;
|
width: calc(13px * 1.2 + 2px + 11px * 1.2);
|
||||||
height: 40px;
|
height: calc(13px * 1.2 + 2px + 11px * 1.2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
line-height: 1.2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import type { BusinessMessageKey } from '../i18n/messages'
|
|||||||
import {
|
import {
|
||||||
BUSINESS_PROFILE_UPDATED_EVENT,
|
BUSINESS_PROFILE_UPDATED_EVENT,
|
||||||
getActiveBusinessDomain,
|
getActiveBusinessDomain,
|
||||||
|
getActiveBusinessId,
|
||||||
} from '../lib/businessContext'
|
} from '../lib/businessContext'
|
||||||
|
import { canAccessRollCall } from '../utils/rollCallAccess'
|
||||||
import { isAbortError } from '../lib/api'
|
import { isAbortError } from '../lib/api'
|
||||||
import { getBusinessProfile } from '../services/businessProfileService'
|
import { getBusinessProfile } from '../services/businessProfileService'
|
||||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
@@ -68,16 +70,19 @@ function isGroupActive(group: NavGroup, pathname: string) {
|
|||||||
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
|
if (pathname === group.basePath || pathname.startsWith(`${group.basePath}/`)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (group.id === 'content') {
|
if (group.id === 'content') {
|
||||||
if (
|
if (
|
||||||
pathname.startsWith('/blog') ||
|
pathname.startsWith('/blog') ||
|
||||||
pathname.startsWith('/videos') ||
|
pathname.startsWith('/videos') ||
|
||||||
pathname.startsWith('/instructions') ||
|
pathname.startsWith('/instructions') ||
|
||||||
pathname.startsWith('/workshops')
|
pathname.startsWith('/workshops')
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (group.id === 'customers' && pathname.startsWith('/roll-call')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return group.children.some(
|
return group.children.some(
|
||||||
(child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
|
(child) => pathname === child.to || pathname.startsWith(`${child.to}/`),
|
||||||
)
|
)
|
||||||
@@ -154,10 +159,40 @@ export function Sidebar({ open = false }: SidebarProps) {
|
|||||||
{ labelKey: 'nav.store.orders', to: '/store/orders' },
|
{ labelKey: 'nav.store.orders', to: '/store/orders' },
|
||||||
{ labelKey: 'nav.store.shipping', to: '/store/shipping' },
|
{ labelKey: 'nav.store.shipping', to: '/store/shipping' },
|
||||||
{ labelKey: 'nav.store.cards', to: '/store/cards' },
|
{ labelKey: 'nav.store.cards', to: '/store/cards' },
|
||||||
|
...(hasBusinessModule(enabledModules, 'store_installments')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
labelKey: 'nav.store.installments' as BusinessMessageKey,
|
||||||
|
to: '/store/installments',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ labelKey: 'nav.store.settings', to: '/store/settings' },
|
{ labelKey: 'nav.store.settings', to: '/store/settings' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
|
...(canAccessRollCall(user, getActiveBusinessId(), enabledModules)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'group' as const,
|
||||||
|
id: 'customers',
|
||||||
|
icon: Users,
|
||||||
|
labelKey: 'nav.customers' as BusinessMessageKey,
|
||||||
|
basePath: '/customers',
|
||||||
|
children: [
|
||||||
|
{ labelKey: 'nav.customers.list' as BusinessMessageKey, to: '/customers' },
|
||||||
|
{ labelKey: 'nav.rollCall' as BusinessMessageKey, to: '/roll-call' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: 'link' as const,
|
||||||
|
id: 'customers',
|
||||||
|
icon: Users,
|
||||||
|
labelKey: 'nav.customers' as BusinessMessageKey,
|
||||||
|
to: '/customers',
|
||||||
|
},
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'finance',
|
id: 'finance',
|
||||||
@@ -241,7 +276,7 @@ export function Sidebar({ open = false }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
|
return !item.moduleId || hasBusinessModule(enabledModules, item.moduleId)
|
||||||
})
|
})
|
||||||
}, [enabledModules])
|
}, [enabledModules, user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--modal-overlay-bg);
|
||||||
|
backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 260;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b91c1c;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountRow input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountUnit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:disabled,
|
||||||
|
.cancelBtn:disabled,
|
||||||
|
.closeBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayIn { animation: overlayFadeIn 0.2s ease forwards; }
|
||||||
|
.overlayOut { animation: overlayFadeOut 0.2s ease forwards; }
|
||||||
|
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
|
||||||
|
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
||||||
|
|
||||||
|
@keyframes overlayFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes overlayFadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes modalFadeOut {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import type { CityOption } from '../services/citiesService'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||||
|
import styles from './StaticShippingExceptionModal.module.css'
|
||||||
|
|
||||||
|
interface StaticShippingExceptionModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: { provinceId: string; amount: number }) => void
|
||||||
|
provinces: CityOption[]
|
||||||
|
usedProvinceIds: string[]
|
||||||
|
editing?: { provinceId: string; amount: number } | null
|
||||||
|
isSubmitting?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
export function StaticShippingExceptionModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
provinces,
|
||||||
|
usedProvinceIds,
|
||||||
|
editing = null,
|
||||||
|
isSubmitting = false,
|
||||||
|
}: StaticShippingExceptionModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [provinceId, setProvinceId] = useState('')
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [formError, setFormError] = useState('')
|
||||||
|
|
||||||
|
const availableProvinces = useMemo(() => {
|
||||||
|
return provinces.filter(
|
||||||
|
(province) =>
|
||||||
|
province.id === editing?.provinceId ||
|
||||||
|
!usedProvinceIds.includes(province.id),
|
||||||
|
)
|
||||||
|
}, [provinces, usedProvinceIds, editing?.provinceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setProvinceId(editing?.provinceId ?? '')
|
||||||
|
setAmount(
|
||||||
|
editing
|
||||||
|
? formatIrtInput(String(Math.round(editing.amount)))
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
setFormError('')
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, editing, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !isSubmitting) onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [mounted, closing, onClose, isSubmitting])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFormError('')
|
||||||
|
const parsedAmount = parseIrtInput(amount)
|
||||||
|
if (!provinceId) {
|
||||||
|
setFormError(t('store.shipping.static.exception.errorProvince'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parsedAmount === null || parsedAmount < 0) {
|
||||||
|
setFormError(t('store.shipping.form.errorAmount'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSubmit({ provinceId, amount: parsedAmount })
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={() => !isSubmitting && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="static-exception-modal-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h3 id="static-exception-modal-title" className={styles.title}>
|
||||||
|
{editing
|
||||||
|
? t('store.shipping.static.exception.editTitle')
|
||||||
|
: t('store.shipping.static.exception.addTitle')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
{formError ? (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="static-exception-province">
|
||||||
|
{t('store.shipping.static.exception.province')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="static-exception-province"
|
||||||
|
value={provinceId}
|
||||||
|
onChange={(e) => setProvinceId(e.target.value)}
|
||||||
|
disabled={isSubmitting || Boolean(editing)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t('store.shipping.static.exception.provincePlaceholder')}</option>
|
||||||
|
{availableProvinces.map((province) => (
|
||||||
|
<option key={province.id} value={province.id}>
|
||||||
|
{isFa ? province.nameFa || province.nameEn : province.nameEn || province.nameFa}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="static-exception-amount">
|
||||||
|
{t('store.shipping.static.exception.amount')}
|
||||||
|
</label>
|
||||||
|
<div className={styles.amountRow}>
|
||||||
|
<input
|
||||||
|
id="static-exception-amount"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
|
placeholder={t('store.shipping.static.amountPlaceholder')}
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(formatIrtInput(e.target.value))}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={styles.amountUnit}>IRT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t('store.shipping.form.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? t('store.shipping.form.saving')
|
||||||
|
: editing
|
||||||
|
? t('store.shipping.form.update')
|
||||||
|
: t('store.shipping.form.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--glass-shadow);
|
box-shadow: var(--glass-shadow);
|
||||||
|
overflow: hidden;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +46,8 @@
|
|||||||
.image {
|
.image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
padding: 10px;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imagePlaceholder {
|
.imagePlaceholder {
|
||||||
@@ -64,7 +65,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.festivalBadge,
|
.festivalBadge,
|
||||||
.stockBadge {
|
.stockBadge,
|
||||||
|
.usedBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
@@ -86,6 +88,14 @@
|
|||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usedBadge {
|
||||||
|
inset-inline-start: 8px;
|
||||||
|
top: auto;
|
||||||
|
bottom: 8px;
|
||||||
|
color: #b45309;
|
||||||
|
background: rgba(255, 251, 235, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
.stockBadge {
|
.stockBadge {
|
||||||
inset-inline-end: 8px;
|
inset-inline-end: 8px;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
@@ -191,7 +201,8 @@
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsLeft button {
|
.controlsLeft button,
|
||||||
|
.controlsLeft a {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -199,10 +210,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsLeft button:hover {
|
.controlsLeft button:hover,
|
||||||
|
.controlsLeft a:hover {
|
||||||
background: rgba(var(--primary-rgb) / 0.1);
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
formatVariantCount,
|
formatVariantCount,
|
||||||
type StoreProductListing,
|
type StoreProductListing,
|
||||||
} from '../utils/storeProductGroups'
|
} from '../utils/storeProductGroups'
|
||||||
|
import { buildWebsiteContentUrl } from '../utils/websiteLinks'
|
||||||
|
import { OpenOnWebsiteControl } from './OpenOnWebsiteControl'
|
||||||
import { StoreItemPrice } from './StoreItemPrice'
|
import { StoreItemPrice } from './StoreItemPrice'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import styles from './StoreItemCard.module.css'
|
import styles from './StoreItemCard.module.css'
|
||||||
@@ -83,6 +85,14 @@ export function StoreItemCard({
|
|||||||
const imageSrc = listing.productImage?.trim() || ''
|
const imageSrc = listing.productImage?.trim() || ''
|
||||||
const [imageFailed, setImageFailed] = useState(false)
|
const [imageFailed, setImageFailed] = useState(false)
|
||||||
const showImage = Boolean(imageSrc) && !imageFailed
|
const showImage = Boolean(imageSrc) && !imageFailed
|
||||||
|
const websiteUrl =
|
||||||
|
listing.variantCount > 0
|
||||||
|
? buildWebsiteContentUrl('product', {
|
||||||
|
id: listing.productId,
|
||||||
|
nameFa: listing.productNameFa,
|
||||||
|
nameEn: listing.productTitle,
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageFailed(false)
|
setImageFailed(false)
|
||||||
@@ -115,6 +125,9 @@ export function StoreItemCard({
|
|||||||
{listing.showFestival && (
|
{listing.showFestival && (
|
||||||
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
|
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
|
||||||
)}
|
)}
|
||||||
|
{listing.isUsed && (
|
||||||
|
<span className={styles.usedBadge}>{t('storeItems.card.used')}</span>
|
||||||
|
)}
|
||||||
{listing.productTotalStock > 0 && (
|
{listing.productTotalStock > 0 && (
|
||||||
<span className={styles.stockBadge}>
|
<span className={styles.stockBadge}>
|
||||||
{t('storeItems.card.inStock', { count: listing.productTotalStock })}
|
{t('storeItems.card.inStock', { count: listing.productTotalStock })}
|
||||||
@@ -164,6 +177,7 @@ export function StoreItemCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<OpenOnWebsiteControl href={websiteUrl} />
|
||||||
<Tooltip label={t('storeItems.card.discount')}>
|
<Tooltip label={t('storeItems.card.discount')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
.priceChip {
|
.price,
|
||||||
display: inline-block;
|
.contactPrice,
|
||||||
padding: 3px 10px;
|
.discountWrap {
|
||||||
font-size: 11px;
|
display: block;
|
||||||
font-weight: 600;
|
width: 100%;
|
||||||
color: var(--primary);
|
text-align: left;
|
||||||
background: rgba(var(--primary-rgb) / 0.12);
|
direction: ltr;
|
||||||
border-radius: 50px;
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contactPrice {
|
.contactPrice {
|
||||||
display: inline-block;
|
font-size: 12px;
|
||||||
padding: 3px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: rgba(148, 163, 184, 0.14);
|
|
||||||
border-radius: 50px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.discountWrap {
|
.discountWrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.originalPrice {
|
.originalPrice {
|
||||||
@@ -36,12 +38,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.salePrice {
|
.salePrice {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #dc2626;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.percent {
|
.percent {
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export function StoreItemPrice({ price, discountedPrice = null }: StoreItemPrice
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className={styles.priceChip}>{formatIrtPrice(price)}</span>
|
return <span className={styles.price}>{formatIrtPrice(price)}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 44px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 88px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, rgba(148, 163, 184, 0.12) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, rgba(148, 163, 184, 0.12) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, rgba(148, 163, 184, 0.12) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, rgba(148, 163, 184, 0.12) 75%);
|
||||||
|
background-size: 12px 12px;
|
||||||
|
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
flex: 0 1 180px;
|
||||||
|
min-width: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.names {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namePrimary {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameSecondary {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameEn {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipValue {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 3px 5px;
|
||||||
|
min-width: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: 'tnum' 1;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipNumber {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipSep {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipUnit {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipHint {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlBtn:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.useBtn {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.useBtn:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.14);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Calculator, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import type { StoreLoanMethod } from '../types/storeLoanMethod'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { Tooltip } from './Tooltip'
|
||||||
|
import styles from './StoreLoanMethodRow.module.css'
|
||||||
|
|
||||||
|
interface StoreLoanMethodRowProps {
|
||||||
|
method: StoreLoanMethod
|
||||||
|
onUse: (id: string) => void
|
||||||
|
onEdit: (id: string) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value: number) {
|
||||||
|
return Math.round(value).toLocaleString('en-US')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInterest(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toLocaleString('en-US')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoreLoanMethodRow({
|
||||||
|
method,
|
||||||
|
onUse,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
}: StoreLoanMethodRowProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const primaryName = isFa ? method.nameFa || method.nameEn : method.nameEn || method.nameFa
|
||||||
|
const secondaryName = isFa
|
||||||
|
? method.nameFa
|
||||||
|
? method.nameEn
|
||||||
|
: ''
|
||||||
|
: method.nameEn
|
||||||
|
? method.nameFa
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
{method.imageUrl ? (
|
||||||
|
<img src={method.imageUrl} alt="" className={styles.logo} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<div className={styles.names}>
|
||||||
|
<span className={`${styles.namePrimary} ${!isFa ? styles.nameEn : ''}`}>
|
||||||
|
{primaryName}
|
||||||
|
</span>
|
||||||
|
{secondaryName ? (
|
||||||
|
<span className={`${styles.nameSecondary} ${isFa ? styles.nameEn : ''}`}>
|
||||||
|
{secondaryName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{method.description ? (
|
||||||
|
<p className={styles.description}>{method.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<div className={styles.chip}>
|
||||||
|
<span className={styles.chipLabel}>
|
||||||
|
{t('store.installments.row.minShopping')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.chipValue}>
|
||||||
|
<span className={styles.chipNumber}>
|
||||||
|
{formatMoney(method.minShoppingAmount)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chip}>
|
||||||
|
<span className={styles.chipLabel}>
|
||||||
|
{t('store.installments.row.creditAmount')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.chipValue}>
|
||||||
|
<span className={styles.chipNumber}>{formatMoney(method.minAmount)}</span>
|
||||||
|
<span className={styles.chipSep}>–</span>
|
||||||
|
<span className={styles.chipNumber}>{formatMoney(method.maxAmount)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chip}>
|
||||||
|
<span className={styles.chipLabel}>
|
||||||
|
{t('store.installments.row.interest')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.chipValue}>
|
||||||
|
<span className={styles.chipNumber}>{formatInterest(method.interest)}</span>
|
||||||
|
<span className={styles.chipUnit}>%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chip}>
|
||||||
|
<span className={styles.chipLabel}>
|
||||||
|
{t('store.installments.row.months')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.chipValue}>
|
||||||
|
<span className={styles.chipNumber}>{method.returnMonthsMin}</span>
|
||||||
|
<span className={styles.chipSep}>–</span>
|
||||||
|
<span className={styles.chipNumber}>{method.returnMonthsMax}</span>
|
||||||
|
<span className={styles.chipUnit}>
|
||||||
|
{t('store.installments.row.monthsUnit')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.chipHint}>
|
||||||
|
{t('store.installments.row.monthsStep', {
|
||||||
|
step: method.returnMonthsStep,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<Tooltip label={t('store.installments.tooltip.use')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.controlBtn} ${styles.useBtn}`}
|
||||||
|
onClick={() => onUse(method.id)}
|
||||||
|
aria-label={t('store.installments.tooltip.use')}
|
||||||
|
>
|
||||||
|
<Calculator size={17} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('store.installments.tooltip.edit')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.controlBtn}
|
||||||
|
onClick={() => onEdit(method.id)}
|
||||||
|
aria-label={t('store.installments.tooltip.edit')}
|
||||||
|
>
|
||||||
|
<Pencil size={17} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('store.installments.tooltip.remove')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.controlBtn} ${styles.danger}`}
|
||||||
|
onClick={() => onRemove(method.id)}
|
||||||
|
aria-label={t('store.installments.tooltip.remove')}
|
||||||
|
>
|
||||||
|
<Trash2 size={17} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--modal-overlay-bg);
|
||||||
|
backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--modal-overlay-blur));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayIn {
|
||||||
|
animation: overlayIn 0.22s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayOut {
|
||||||
|
animation: overlayOut 0.22s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--elevated-surface, var(--surface));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalIn {
|
||||||
|
animation: modalIn 0.22s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalOut {
|
||||||
|
animation: modalOut 0.22s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(148, 163, 184, 0.06);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.9fr 1.5fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:not([type='range']),
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:not([type='range']):focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:disabled,
|
||||||
|
.field select:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderValue {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderMeta > span:nth-child(2) {
|
||||||
|
direction: inherit;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b45309;
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyResult {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryCardWide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryValue {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: 'tnum' 1;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleIndex {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleDate {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleAmount {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-family: var(--font-en), var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlayIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlayOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.planGrid,
|
||||||
|
.summaryGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleRow {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleIndex {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Calculator, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import type { StoreLoanMethod } from '../types/storeLoanMethod'
|
||||||
|
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||||
|
import {
|
||||||
|
calculateStoreLoanPlan,
|
||||||
|
clampCreditAmount,
|
||||||
|
listReturnMonthOptions,
|
||||||
|
} from '../utils/storeLoanCalculator'
|
||||||
|
import styles from './StoreLoanUseModal.module.css'
|
||||||
|
|
||||||
|
interface StoreLoanUseModalProps {
|
||||||
|
open: boolean
|
||||||
|
method: StoreLoanMethod | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
|
function formatMoney(value: number) {
|
||||||
|
return Math.round(value).toLocaleString('en-US')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date, locale: 'en' | 'fa') {
|
||||||
|
return value.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoreLoanUseModal({ open, method, onClose }: StoreLoanUseModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [totalValueInput, setTotalValueInput] = useState('')
|
||||||
|
const [creditInput, setCreditInput] = useState('')
|
||||||
|
const [months, setMonths] = useState(0)
|
||||||
|
|
||||||
|
const monthOptions = useMemo(() => {
|
||||||
|
if (!method) return []
|
||||||
|
return listReturnMonthOptions(
|
||||||
|
method.returnMonthsMin,
|
||||||
|
method.returnMonthsMax,
|
||||||
|
method.returnMonthsStep,
|
||||||
|
)
|
||||||
|
}, [method])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && method) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setTotalValueInput('')
|
||||||
|
setCreditInput('')
|
||||||
|
const options = listReturnMonthOptions(
|
||||||
|
method.returnMonthsMin,
|
||||||
|
method.returnMonthsMax,
|
||||||
|
method.returnMonthsStep,
|
||||||
|
)
|
||||||
|
setMonths(options[0] ?? method.returnMonthsMin)
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, method, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [mounted, closing, onClose])
|
||||||
|
|
||||||
|
const totalValue = parseIrtInput(totalValueInput)
|
||||||
|
const creditRaw = parseIrtInput(creditInput)
|
||||||
|
|
||||||
|
const plan = useMemo(() => {
|
||||||
|
if (!method || totalValue === null || creditRaw === null) return null
|
||||||
|
const creditAmount = clampCreditAmount(
|
||||||
|
creditRaw,
|
||||||
|
totalValue,
|
||||||
|
method.minAmount,
|
||||||
|
method.maxAmount,
|
||||||
|
)
|
||||||
|
return calculateStoreLoanPlan({
|
||||||
|
totalValue,
|
||||||
|
creditAmount,
|
||||||
|
interestPercent: method.interest,
|
||||||
|
months,
|
||||||
|
})
|
||||||
|
}, [method, totalValue, creditRaw, months])
|
||||||
|
|
||||||
|
const validationMessage = useMemo(() => {
|
||||||
|
if (!method) return ''
|
||||||
|
if (totalValue === null) return ''
|
||||||
|
if (totalValue < method.minShoppingAmount) {
|
||||||
|
return t('store.installments.use.errorMinShopping', {
|
||||||
|
amount: formatMoney(method.minShoppingAmount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (creditRaw === null) return ''
|
||||||
|
if (creditRaw > totalValue) {
|
||||||
|
return t('store.installments.use.errorCreditOverTotal')
|
||||||
|
}
|
||||||
|
if (creditRaw < method.minAmount || creditRaw > method.maxAmount) {
|
||||||
|
return t('store.installments.use.errorCreditRange', {
|
||||||
|
min: formatMoney(method.minAmount),
|
||||||
|
max: formatMoney(method.maxAmount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}, [method, totalValue, creditRaw, t])
|
||||||
|
|
||||||
|
if (!mounted || !method) return null
|
||||||
|
|
||||||
|
const primaryName = isFa ? method.nameFa || method.nameEn : method.nameEn || method.nameFa
|
||||||
|
|
||||||
|
function handleTotalChange(raw: string) {
|
||||||
|
const formatted = formatIrtInput(raw)
|
||||||
|
setTotalValueInput(formatted)
|
||||||
|
const nextTotal = parseIrtInput(formatted)
|
||||||
|
if (nextTotal === null || !method) return
|
||||||
|
const suggested = clampCreditAmount(
|
||||||
|
Math.min(nextTotal, method.maxAmount),
|
||||||
|
nextTotal,
|
||||||
|
method.minAmount,
|
||||||
|
method.maxAmount,
|
||||||
|
)
|
||||||
|
if (!creditInput || creditRaw === null) {
|
||||||
|
setCreditInput(formatIrtInput(String(suggested)))
|
||||||
|
} else {
|
||||||
|
const clamped = clampCreditAmount(
|
||||||
|
creditRaw,
|
||||||
|
nextTotal,
|
||||||
|
method.minAmount,
|
||||||
|
method.maxAmount,
|
||||||
|
)
|
||||||
|
setCreditInput(formatIrtInput(String(clamped)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="store-loan-use-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.headerText}>
|
||||||
|
<div className={styles.headerBadge}>
|
||||||
|
<Calculator size={16} />
|
||||||
|
<span>{t('store.installments.use.badge')}</span>
|
||||||
|
</div>
|
||||||
|
<h3 id="store-loan-use-title" className={styles.title}>
|
||||||
|
{primaryName}
|
||||||
|
</h3>
|
||||||
|
<p className={styles.subtitle}>{t('store.installments.use.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.body}>
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>
|
||||||
|
{t('store.installments.use.section.value')}
|
||||||
|
</h4>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="loan-use-total">
|
||||||
|
{t('store.installments.use.totalValue')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-use-total"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={totalValueInput}
|
||||||
|
onChange={(e) => handleTotalChange(e.target.value)}
|
||||||
|
placeholder={t('store.installments.use.totalValuePlaceholder')}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{t('store.installments.use.totalValueHint', {
|
||||||
|
amount: formatMoney(method.minShoppingAmount),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>
|
||||||
|
{t('store.installments.use.section.plan')}
|
||||||
|
</h4>
|
||||||
|
<div className={styles.planGrid}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="loan-use-credit">
|
||||||
|
{t('store.installments.use.creditAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-use-credit"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={creditInput}
|
||||||
|
onChange={(e) => setCreditInput(formatIrtInput(e.target.value))}
|
||||||
|
placeholder={t('store.installments.use.creditAmountPlaceholder')}
|
||||||
|
disabled={totalValue === null}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{t('store.installments.use.creditAmountHint', {
|
||||||
|
min: formatMoney(method.minAmount),
|
||||||
|
max: formatMoney(method.maxAmount),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<div className={styles.sliderHeader}>
|
||||||
|
<label htmlFor="loan-use-months">
|
||||||
|
{t('store.installments.use.months')}
|
||||||
|
</label>
|
||||||
|
<span className={styles.sliderValue}>
|
||||||
|
{t('store.installments.use.monthsOption', { months })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="loan-use-months"
|
||||||
|
className={styles.slider}
|
||||||
|
type="range"
|
||||||
|
min={method.returnMonthsMin}
|
||||||
|
max={method.returnMonthsMax}
|
||||||
|
step={method.returnMonthsStep}
|
||||||
|
value={months}
|
||||||
|
onChange={(e) => setMonths(Number(e.target.value))}
|
||||||
|
disabled={monthOptions.length === 0}
|
||||||
|
/>
|
||||||
|
<div className={styles.sliderMeta}>
|
||||||
|
<span>{method.returnMonthsMin}</span>
|
||||||
|
<span>
|
||||||
|
{t('store.installments.use.monthsHint', {
|
||||||
|
step: method.returnMonthsStep,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>{method.returnMonthsMax}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>
|
||||||
|
{t('store.installments.use.section.result')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{validationMessage ? (
|
||||||
|
<div className={styles.warning} role="status">
|
||||||
|
{validationMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!plan || validationMessage ? (
|
||||||
|
<p className={styles.emptyResult}>
|
||||||
|
{t('store.installments.use.resultEmpty')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.summaryGrid}>
|
||||||
|
<div className={styles.summaryCard}>
|
||||||
|
<span className={styles.summaryLabel}>
|
||||||
|
{t('store.installments.use.result.totalValue')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.summaryValue}>
|
||||||
|
{formatMoney(plan.totalValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryCard}>
|
||||||
|
<span className={styles.summaryLabel}>
|
||||||
|
{t('store.installments.use.result.credit')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.summaryValue}>
|
||||||
|
{formatMoney(plan.creditAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryCard}>
|
||||||
|
<span className={styles.summaryLabel}>
|
||||||
|
{t('store.installments.use.result.cash')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.summaryValue}>
|
||||||
|
{formatMoney(plan.cashPayment)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryCard}>
|
||||||
|
<span className={styles.summaryLabel}>
|
||||||
|
{t('store.installments.use.result.fees')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.summaryValue}>
|
||||||
|
{formatMoney(plan.interestFee)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.summaryCard} ${styles.summaryCardWide}`}>
|
||||||
|
<span className={styles.summaryLabel}>
|
||||||
|
{t('store.installments.use.result.totalRepay')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.summaryValue}>
|
||||||
|
{formatMoney(plan.totalRepay)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.schedule}>
|
||||||
|
<div className={styles.scheduleHead}>
|
||||||
|
<span>{t('store.installments.use.schedule.title')}</span>
|
||||||
|
<span>
|
||||||
|
{t('store.installments.use.schedule.count', {
|
||||||
|
count: plan.installments.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.scheduleList}>
|
||||||
|
{plan.installments.map((item) => (
|
||||||
|
<div key={item.index} className={styles.scheduleRow}>
|
||||||
|
<span className={styles.scheduleIndex}>
|
||||||
|
{t('store.installments.use.schedule.item', {
|
||||||
|
index: item.index,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className={styles.scheduleDate}>
|
||||||
|
{formatDate(item.dueDate, locale)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.scheduleAmount}>
|
||||||
|
{formatMoney(item.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,37 +1,32 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Sparkles, X } from 'lucide-react'
|
import { Sparkles, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import modalStyles from './CategoryModal.module.css'
|
import modalStyles from './CategoryModal.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './CategoryAiPromptModal.module.css'
|
import styles from './CategoryAiPromptModal.module.css'
|
||||||
|
|
||||||
export function buildTechnicalFormAiPrompt(
|
export function buildTechnicalFormAiPrompt(categoryName: string) {
|
||||||
categoryName: string,
|
return `برای دستهبندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
|
||||||
locale: 'fa' | 'en' = 'en',
|
|
||||||
) {
|
|
||||||
if (locale === 'fa') {
|
|
||||||
return `برای دستهبندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
|
|
||||||
|
|
||||||
برای هر فیلد این موارد را مشخص کن:
|
برای هر فیلد این موارد را مشخص کن:
|
||||||
- برچسب کوتاه انگلیسی (مثلاً Screen Size، Material، Color)
|
- برچسب کوتاه فارسی (مثلاً سایز صفحه نمایش، جنس بدنه، ظرفیت)
|
||||||
- نوع ورودی: text، textarea، select یا multi_select
|
- نوع ورودی: ترجیحاً text (یا textarea / multi_value در صورت نیاز)
|
||||||
- الزامی بودن یا نبودن
|
- الزامی بودن یا نبودن
|
||||||
- برای select / multi_select: فهرست گزینهها
|
- multi_value یعنی لیست تکراری از مقادیر متنی آزاد (بدون گزینه)، مناسب برای ویژگیهای چندتایی
|
||||||
|
|
||||||
روی مشخصات و ویژگیهایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی میکنند.`
|
مهم — نوع ورودی:
|
||||||
}
|
- ترجیحاً از ورودی text استفاده کن تا فروشنده بتواند مقدار دلخواه را آزادانه وارد کند.
|
||||||
|
- از select و multi_select حتیالمقدور استفاده نکن؛ فقط وقتی گزینهها واقعاً ثابت و استانداردند (مثل بله/خیر).
|
||||||
|
- اگر بین text و select مردد هستی، text را انتخاب کن.
|
||||||
|
|
||||||
return `Suggest technical data form fields for the "${categoryName}" product category.
|
همیشه در انتهای فرم یک فیلد «سایر ویژگیها» از نوع multi_value در نظر گرفته میشود؛ خودت آن را نساز.
|
||||||
|
|
||||||
For each field include:
|
اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را هم بهصورت text اضافه کن (با واحد رایج در ایران در برچسب، مثلاً سانتیمتر یا کیلوگرم). اگر برای دسته بیمعناست، اضافه نکن.
|
||||||
- A short English label (e.g. "Screen Size", "Material", "Color")
|
|
||||||
- The input type: text, textarea, select, or multi_select
|
|
||||||
- Whether it is required
|
|
||||||
- For select / multi_select: the list of options
|
|
||||||
|
|
||||||
Focus on specs and attributes that shoppers typically compare when buying this type of product.`
|
روی مشخصات و ویژگیهایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی میکنند.
|
||||||
|
برچسبها را به فارسی بنویس.
|
||||||
|
در صورت نیاز، لینک یک صفحه محصول نمونه را در ادامه اضافه کن (سیستم صفحه را باز میکند و فرم را بر اساس مشخصات واقعی آن میسازد).`
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TechnicalFormAiPromptModalProps {
|
interface TechnicalFormAiPromptModalProps {
|
||||||
@@ -52,20 +47,16 @@ export function TechnicalFormAiPromptModal({
|
|||||||
isRunning,
|
isRunning,
|
||||||
}: TechnicalFormAiPromptModalProps) {
|
}: TechnicalFormAiPromptModalProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const { locale } = useLocale()
|
|
||||||
const isFa = locale === 'fa'
|
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [prompt, setPrompt] = useState(() =>
|
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
|
||||||
buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'),
|
|
||||||
)
|
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setPrompt(buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'))
|
setPrompt(buildTechnicalFormAiPrompt(categoryName))
|
||||||
setError('')
|
setError('')
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
setClosing(true)
|
setClosing(true)
|
||||||
@@ -75,7 +66,7 @@ export function TechnicalFormAiPromptModal({
|
|||||||
}, ANIMATION_MS)
|
}, ANIMATION_MS)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [open, mounted, categoryName, isFa])
|
}, [open, mounted, categoryName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || closing) return
|
if (!mounted || closing) return
|
||||||
@@ -118,8 +109,8 @@ export function TechnicalFormAiPromptModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="tech-form-ai-prompt-title"
|
aria-labelledby="tech-form-ai-prompt-title"
|
||||||
lang={isFa ? 'fa' : 'en'}
|
lang="fa"
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||||
<div className={styles.headerBlock}>
|
<div className={styles.headerBlock}>
|
||||||
@@ -147,11 +138,11 @@ export function TechnicalFormAiPromptModal({
|
|||||||
className={aiStyles.aiPromptTextarea}
|
className={aiStyles.aiPromptTextarea}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
rows={8}
|
rows={14}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
required
|
required
|
||||||
lang={isFa ? 'fa' : 'en'}
|
lang="fa"
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
.fieldMainRow {
|
.fieldMainRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 6fr 3fr 2fr 32px;
|
grid-template-columns: 6fr 3fr 2fr auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rowActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.requiredCell {
|
.requiredCell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -176,3 +188,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.productTechnicalActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
|
import { ChevronDown, ChevronUp, Plus, Sparkles, Trash2, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
|
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
@@ -34,6 +34,7 @@ const FIELD_TYPE_KEYS: Record<TechnicalFieldType, BusinessMessageKey> = {
|
|||||||
textarea: 'categories.technical.type.textarea',
|
textarea: 'categories.technical.type.textarea',
|
||||||
select: 'categories.technical.type.select',
|
select: 'categories.technical.type.select',
|
||||||
multi_select: 'categories.technical.type.multi_select',
|
multi_select: 'categories.technical.type.multi_select',
|
||||||
|
multi_value: 'categories.technical.type.multi_value',
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyField(): TechnicalFormFieldDraft {
|
function createEmptyField(): TechnicalFormFieldDraft {
|
||||||
@@ -111,6 +112,17 @@ export function TechnicalFormModal({
|
|||||||
onChange(fields.filter((field) => field.id !== fieldId))
|
onChange(fields.filter((field) => field.id !== fieldId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveField(fieldId: string, delta: -1 | 1) {
|
||||||
|
const index = fields.findIndex((field) => field.id === fieldId)
|
||||||
|
if (index < 0) return
|
||||||
|
const nextIndex = index + delta
|
||||||
|
if (nextIndex < 0 || nextIndex >= fields.length) return
|
||||||
|
const next = [...fields]
|
||||||
|
const [item] = next.splice(index, 1)
|
||||||
|
next.splice(nextIndex, 0, item)
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
function addField() {
|
function addField() {
|
||||||
onChange([...fields, createEmptyField()])
|
onChange([...fields, createEmptyField()])
|
||||||
}
|
}
|
||||||
@@ -215,7 +227,7 @@ export function TechnicalFormModal({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={fieldStyles.fieldList}>
|
<div className={fieldStyles.fieldList}>
|
||||||
{fields.map((field) => (
|
{fields.map((field, index) => (
|
||||||
<div key={field.id} className={fieldStyles.fieldCard}>
|
<div key={field.id} className={fieldStyles.fieldCard}>
|
||||||
<div className={fieldStyles.fieldMainRow}>
|
<div className={fieldStyles.fieldMainRow}>
|
||||||
<input
|
<input
|
||||||
@@ -264,7 +276,29 @@ export function TechnicalFormModal({
|
|||||||
/>
|
/>
|
||||||
<span>{t('categories.technical.required')}</span>
|
<span>{t('categories.technical.required')}</span>
|
||||||
</label>
|
</label>
|
||||||
<div className={fieldStyles.removeCell}>
|
<div className={fieldStyles.rowActions}>
|
||||||
|
<Tooltip label={t('categories.technical.moveUp')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeRowBtn}
|
||||||
|
onClick={() => moveField(field.id, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
aria-label={t('categories.technical.moveUp')}
|
||||||
|
>
|
||||||
|
<ChevronUp size={15} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('categories.technical.moveDown')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeRowBtn}
|
||||||
|
onClick={() => moveField(field.id, 1)}
|
||||||
|
disabled={index === fields.length - 1}
|
||||||
|
aria-label={t('categories.technical.moveDown')}
|
||||||
|
>
|
||||||
|
<ChevronDown size={15} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip label={t('categories.technical.removeField')}>
|
<Tooltip label={t('categories.technical.removeField')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--field-height, 40px);
|
||||||
|
padding: var(--field-padding-y, 8px) var(--field-padding-x, 12px);
|
||||||
|
font-size: var(--field-font-size, 14px);
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm, 8px);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: var(--radius-sm, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: rgba(220, 38, 38, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--primary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed rgba(var(--primary-rgb) / 0.45);
|
||||||
|
border-radius: var(--radius-sm, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.08);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
import styles from './TechnicalMultiValueEditor.module.css'
|
||||||
|
|
||||||
|
interface TechnicalMultiValueEditorProps {
|
||||||
|
id?: string
|
||||||
|
values: string[]
|
||||||
|
onChange: (values: string[]) => void
|
||||||
|
addLabel: string
|
||||||
|
removeLabel: string
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TechnicalMultiValueEditor({
|
||||||
|
id,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
addLabel,
|
||||||
|
removeLabel,
|
||||||
|
placeholder,
|
||||||
|
}: TechnicalMultiValueEditorProps) {
|
||||||
|
const rows = values.length > 0 ? values : ['']
|
||||||
|
|
||||||
|
function updateRow(index: number, next: string) {
|
||||||
|
const copy = [...rows]
|
||||||
|
copy[index] = next
|
||||||
|
onChange(copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(index: number) {
|
||||||
|
if (rows.length <= 1) {
|
||||||
|
onChange([''])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(rows.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
onChange([...rows, ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{rows.map((item, index) => (
|
||||||
|
<div key={`${id ?? 'mv'}-${index}`} className={styles.row}>
|
||||||
|
<input
|
||||||
|
id={index === 0 ? id : undefined}
|
||||||
|
type="text"
|
||||||
|
value={item}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => updateRow(index, e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => removeRow(index)}
|
||||||
|
aria-label={removeLabel}
|
||||||
|
title={removeLabel}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className={styles.addBtn} onClick={addRow}>
|
||||||
|
<Plus size={15} />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -280,34 +280,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.colorSearchWrap {
|
.colorSearchWrap {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
gap: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.colorSearchIcon {
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-start: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colorSearchInput {
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--field-height);
|
min-height: var(--field-height);
|
||||||
padding: var(--field-padding-y) 36px;
|
margin-bottom: 10px;
|
||||||
font-size: var(--field-font-size);
|
padding: 0 var(--field-padding-x);
|
||||||
font-family: var(--font-ui);
|
|
||||||
color: var(--text-primary);
|
|
||||||
background-color: var(--surface);
|
background-color: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.colorSearchWrap:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorSearchIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorSearchInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: calc(var(--field-height) - 2px);
|
||||||
|
padding: var(--field-padding-y) 0;
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.colorSearchInput::placeholder {
|
.colorSearchInput::placeholder {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -315,13 +324,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.colorSearchInput:focus {
|
.colorSearchInput:focus {
|
||||||
border-color: var(--primary);
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.colorSearchClear {
|
.colorSearchClear {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
inset-inline-end: 8px;
|
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -358,6 +365,89 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customColorBlock {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed rgba(148, 163, 184, 0.35);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customColorTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customColorRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customColorNameInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customColorNameInput:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customColorPickerLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customColorPicker {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customColorError {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.customValues {
|
.customValues {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -500,6 +590,12 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.removeRowBtn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { createPortal } from 'react-dom'
|
|||||||
import { Pencil, Plus, X } from 'lucide-react'
|
import { Pencil, Plus, X } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { BusinessMessageKey } from '../i18n/messages'
|
|
||||||
import type { Variation, VariationType } from '../types/variation'
|
import type { Variation, VariationType } from '../types/variation'
|
||||||
import { COLOR_OPTIONS, normalizeColorName } from '../types/variation'
|
|
||||||
import { createId } from '../utils/id'
|
import { createId } from '../utils/id'
|
||||||
|
import { localizeVariationOptionLabel } from '../utils/localizeVariationOption'
|
||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
import { AddVariationModal } from './AddVariationModal'
|
import { AddVariationModal } from './AddVariationModal'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
@@ -122,11 +121,7 @@ export function VariationsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function localizeValue(value: string) {
|
function localizeValue(value: string) {
|
||||||
const normalized = normalizeColorName(value)
|
return localizeVariationOptionLabel(value, t)
|
||||||
if ((COLOR_OPTIONS as string[]).includes(normalized)) {
|
|
||||||
return t(`categories.variations.color.${normalized}` as BusinessMessageKey)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--modal-overlay-bg);
|
||||||
|
backdrop-filter: blur(var(--modal-overlay-blur, 16px));
|
||||||
|
-webkit-backdrop-filter: blur(var(--modal-overlay-blur, 16px));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 260;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b91c1c;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueField {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
|
||||||
|
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
|
||||||
|
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
|
||||||
|
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
||||||
|
|
||||||
|
@keyframes overlayFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes overlayFadeOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes modalFadeOut {
|
||||||
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
to { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import styles from './WebsiteMetaTagModal.module.css'
|
||||||
|
|
||||||
|
const ANIMATION_MS = 220
|
||||||
|
const META_HTML_RE = /^<meta\b[^>]*>/i
|
||||||
|
|
||||||
|
interface WebsiteMetaTagModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: { name: string; content: string }) => void
|
||||||
|
initial?: { name: string; content: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebsiteMetaTagModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initial = null,
|
||||||
|
}: WebsiteMetaTagModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
|
const isEditing = Boolean(initial)
|
||||||
|
const [mounted, setMounted] = useState(open)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [formError, setFormError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
setClosing(false)
|
||||||
|
setName(initial?.name ?? '')
|
||||||
|
setContent(initial?.content ?? '')
|
||||||
|
setFormError('')
|
||||||
|
} else if (mounted) {
|
||||||
|
setClosing(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setMounted(false)
|
||||||
|
setClosing(false)
|
||||||
|
}, ANIMATION_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
// Reset fields only when opening; ignore `initial` identity churn while open.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional
|
||||||
|
}, [open, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || closing) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [mounted, closing, onClose])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
const trimmedContent = content.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
setFormError(t('website.badges.meta.nameInvalid'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!trimmedContent || !META_HTML_RE.test(trimmedContent)) {
|
||||||
|
setFormError(t('website.badges.meta.contentRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSubmit({ name: trimmedName, content: trimmedContent })
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="website-meta-tag-modal-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h3 id="website-meta-tag-modal-title" className={styles.title}>
|
||||||
|
{isEditing ? t('website.badges.meta.editTitle') : t('website.badges.meta.modalTitle')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
{formError ? (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="website-meta-tag-name">{t('website.badges.meta.nameLabel')}</label>
|
||||||
|
<input
|
||||||
|
id="website-meta-tag-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t('website.badges.meta.namePlaceholder')}
|
||||||
|
dir="ltr"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.field} ${styles.valueField}`}>
|
||||||
|
<label htmlFor="website-meta-tag-value">{t('website.badges.meta.valueLabel')}</label>
|
||||||
|
<textarea
|
||||||
|
id="website-meta-tag-value"
|
||||||
|
rows={4}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={t('website.badges.meta.contentPlaceholder')}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn}>
|
||||||
|
{isEditing ? t('website.badges.meta.saveEdit') : t('website.badges.meta.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -65,6 +65,18 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Checkerboard so white/transparent PNG icons stay visible in the CMS. */
|
||||||
|
.imageButtonTransparent {
|
||||||
|
background-color: #64748b;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #475569 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #475569 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #475569 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #475569 75%);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocale } from '@meshkee/dashboard-ui'
|
|||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { WebsiteStaticImageItem } from '../types/websiteStaticImage'
|
import type { WebsiteStaticImageItem } from '../types/websiteStaticImage'
|
||||||
import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio'
|
import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio'
|
||||||
|
import { isPngImageUrl } from '../utils/imageUpload'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import controlStyles from './CategoryRow.module.css'
|
import controlStyles from './CategoryRow.module.css'
|
||||||
import styles from './WebsiteStaticImageGallery.module.css'
|
import styles from './WebsiteStaticImageGallery.module.css'
|
||||||
@@ -106,7 +107,7 @@ export function WebsiteStaticImageGallery({
|
|||||||
<article className={styles.card}>
|
<article className={styles.card}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.imageButton}
|
className={`${styles.imageButton} ${isPngImageUrl(image.url) ? styles.imageButtonTransparent : ''}`}
|
||||||
onClick={() => onAddAt(index)}
|
onClick={() => onAddAt(index)}
|
||||||
aria-label={replaceLabel}
|
aria-label={replaceLabel}
|
||||||
style={{ aspectRatio: cssAspect }}
|
style={{ aspectRatio: cssAspect }}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import {
|
|||||||
createUserProduct,
|
createUserProduct,
|
||||||
getUserProduct,
|
getUserProduct,
|
||||||
getUserProductCategoryTechnicalForm,
|
getUserProductCategoryTechnicalForm,
|
||||||
|
hydrateTechnicalFormValues,
|
||||||
listUserProductCategories,
|
listUserProductCategories,
|
||||||
updateUserProduct,
|
updateUserProduct,
|
||||||
type TechnicalFormField,
|
type TechnicalFormField,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
|
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
|
||||||
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
|
import { TechnicalMultiValueEditor } from '../components/TechnicalMultiValueEditor'
|
||||||
import styles from './AddCustomerProductPage.module.css'
|
import styles from './AddCustomerProductPage.module.css'
|
||||||
|
|
||||||
type StepId = 1 | 2 | 3
|
type StepId = 1 | 2 | 3
|
||||||
@@ -266,7 +268,12 @@ export function AddCustomerProductPage() {
|
|||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
setTechnicalFields(response.form?.fields ?? [])
|
setTechnicalFields(response.form?.fields ?? [])
|
||||||
if (pendingTechnicalValuesRef.current) {
|
if (pendingTechnicalValuesRef.current) {
|
||||||
setTechnicalValues(pendingTechnicalValuesRef.current)
|
setTechnicalValues(
|
||||||
|
hydrateTechnicalFormValues(
|
||||||
|
response.form?.fields ?? [],
|
||||||
|
pendingTechnicalValuesRef.current,
|
||||||
|
),
|
||||||
|
)
|
||||||
pendingTechnicalValuesRef.current = null
|
pendingTechnicalValuesRef.current = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -321,8 +328,9 @@ export function AddCustomerProductPage() {
|
|||||||
if (!field.isRequired) continue
|
if (!field.isRequired) continue
|
||||||
const value = technicalValues[field.id]
|
const value = technicalValues[field.id]
|
||||||
const ok =
|
const ok =
|
||||||
field.type === 'multi_select'
|
field.type === 'multi_select' || field.type === 'multi_value'
|
||||||
? Array.isArray(value) && value.length > 0
|
? Array.isArray(value) &&
|
||||||
|
value.some((item) => String(item).trim().length > 0)
|
||||||
: typeof value === 'string' && value.trim().length > 0
|
: typeof value === 'string' && value.trim().length > 0
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return t('customerProducts.form.error.technicalRequired')
|
return t('customerProducts.form.error.technicalRequired')
|
||||||
@@ -483,6 +491,19 @@ export function AddCustomerProductPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
const items = Array.isArray(value) ? value.map(String) : []
|
||||||
|
return (
|
||||||
|
<TechnicalMultiValueEditor
|
||||||
|
id={id}
|
||||||
|
values={items}
|
||||||
|
onChange={(next) => setTechnicalField(field.id, next)}
|
||||||
|
addLabel={t('customerProducts.form.technical.addValue')}
|
||||||
|
removeLabel={t('customerProducts.form.technical.removeValue')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -751,6 +772,8 @@ export function AddCustomerProductPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aspect={1}
|
aspect={1}
|
||||||
|
minZoom={0.4}
|
||||||
|
outputFormat="jpeg"
|
||||||
hint={t('customerProducts.form.images.thumbnailHint')}
|
hint={t('customerProducts.form.images.thumbnailHint')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import type { BlogPostType } from '../types/blog'
|
import type { BlogPostType } from '../types/blog'
|
||||||
import { BLOG_TYPE_OPTIONS } from '../types/blog'
|
import { BLOG_TYPE_OPTIONS } from '../types/blog'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -61,8 +60,7 @@ export function AddNewBlogPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const selectedAspect =
|
||||||
const selectedAspect =
|
|
||||||
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -273,11 +271,13 @@ export function AddNewBlogPage() {
|
|||||||
|
|
||||||
<div className={`${styles.field} ${styles.col6Span}`}>
|
<div className={`${styles.field} ${styles.col6Span}`}>
|
||||||
<label>{t('blog.form.category')}</label>
|
<label>{t('blog.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('blog.form.categoryPlaceholder')}
|
placeholder={t('blog.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import type { VideoProvider } from '../types/instruction'
|
import type { VideoProvider } from '../types/instruction'
|
||||||
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
|
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -63,8 +62,7 @@ export function AddNewInstructionPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const selectedAspect =
|
||||||
const selectedAspect =
|
|
||||||
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
|
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -257,11 +255,13 @@ export function AddNewInstructionPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('instruction.form.category')}</label>
|
<label>{t('instruction.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('instruction.form.categoryPlaceholder')}
|
placeholder={t('instruction.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { ImageUploader } from '../components/ImageUploader'
|
import { ImageUploader } from '../components/ImageUploader'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
updatePortfolio,
|
updatePortfolio,
|
||||||
} from '../services/portfolioService'
|
} from '../services/portfolioService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -70,8 +69,7 @@ export function AddNewPortfolioPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
const selectedAspect =
|
||||||
const selectedAspect =
|
|
||||||
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,11 +275,13 @@ export function AddNewPortfolioPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('portfolio.form.category')}</label>
|
<label>{t('portfolio.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('portfolio.form.categoryPlaceholder')}
|
placeholder={t('portfolio.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
.col2 { grid-column: span 2; }
|
.col2 { grid-column: span 2; }
|
||||||
.col3 { grid-column: span 3; }
|
.col3 { grid-column: span 3; }
|
||||||
.col4 { grid-column: span 4; }
|
.col4 { grid-column: span 4; }
|
||||||
|
.col5 { grid-column: span 5; }
|
||||||
.col6 { grid-column: 1 / span 6; }
|
.col6 { grid-column: 1 / span 6; }
|
||||||
.col8 { grid-column: span 8; }
|
.col8 { grid-column: span 8; }
|
||||||
.col7 { grid-column: span 7; }
|
.col7 { grid-column: span 7; }
|
||||||
@@ -86,6 +87,64 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalogHint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogInput {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogSelected {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogSelected span,
|
||||||
|
.catalogLink {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogLink {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogClear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalogClear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.summaryField textarea {
|
.summaryField textarea {
|
||||||
min-height: calc((var(--field-height) + 8px) * 2);
|
min-height: calc((var(--field-height) + 8px) * 2);
|
||||||
padding-inline-end: 12px;
|
padding-inline-end: 12px;
|
||||||
@@ -437,6 +496,7 @@
|
|||||||
.col2,
|
.col2,
|
||||||
.col3,
|
.col3,
|
||||||
.col4,
|
.col4,
|
||||||
|
.col5,
|
||||||
.col6,
|
.col6,
|
||||||
.col6Span,
|
.col6Span,
|
||||||
.col7,
|
.col7,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { FileText, Sparkles, X } from 'lucide-react'
|
||||||
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { SearchableSelect } from '../components/SearchableSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { ImageUploader } from '../components/ImageUploader'
|
import { ImageUploader } from '../components/ImageUploader'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
import { SeoMetaFields } from '../components/SeoMetaFields'
|
|
||||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
|
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
|
||||||
import { Tooltip } from '../components/Tooltip'
|
import { Tooltip } from '../components/Tooltip'
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
galleryMediaIdsAfterChange,
|
galleryMediaIdsAfterChange,
|
||||||
resolveDataUrlToMediaId,
|
resolveDataUrlToMediaId,
|
||||||
resolveDataUrlsToMediaIds,
|
resolveDataUrlsToMediaIds,
|
||||||
|
uploadCatalogPdf,
|
||||||
} from '../services/mediaService'
|
} from '../services/mediaService'
|
||||||
import {
|
import {
|
||||||
createProduct,
|
createProduct,
|
||||||
@@ -29,19 +30,20 @@ import {
|
|||||||
fillProductFieldByAi,
|
fillProductFieldByAi,
|
||||||
} from '../services/productAiService'
|
} from '../services/productAiService'
|
||||||
import type { Category, FlatCategory } from '../types/category'
|
import type { Category, FlatCategory } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
|
|
||||||
|
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
export function AddNewProductPage() {
|
export function AddNewProductPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
|
const catalogInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
|
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
|
||||||
@@ -54,22 +56,24 @@ export function AddNewProductPage() {
|
|||||||
const [thumbnail, setThumbnail] = useState<string | null>(null)
|
const [thumbnail, setThumbnail] = useState<string | null>(null)
|
||||||
const [images, setImages] = useState<string[]>([])
|
const [images, setImages] = useState<string[]>([])
|
||||||
const [tags, setTags] = useState<string[]>([])
|
const [tags, setTags] = useState<string[]>([])
|
||||||
const [seoMetaTitle, setSeoMetaTitle] = useState('')
|
|
||||||
const [seoMetaDescription, setSeoMetaDescription] = useState('')
|
|
||||||
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
|
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
|
||||||
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
|
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
|
||||||
|
const [catalogMediaId, setCatalogMediaId] = useState<string | null>(null)
|
||||||
|
const [catalogUrl, setCatalogUrl] = useState<string | null>(null)
|
||||||
|
const [catalogFileName, setCatalogFileName] = useState<string | null>(null)
|
||||||
|
const [catalogFile, setCatalogFile] = useState<File | null>(null)
|
||||||
|
const [catalogRemoved, setCatalogRemoved] = useState(false)
|
||||||
const [loaded, setLoaded] = useState(!isEdit)
|
const [loaded, setLoaded] = useState(!isEdit)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
|
const [aiField, setAiField] = useState<'summary' | 'description' | null>(null)
|
||||||
const [isAiRunning, setIsAiRunning] = useState(false)
|
const [isAiRunning, setIsAiRunning] = useState(false)
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
|
||||||
const selectedCategoryName = useMemo(() => {
|
const selectedCategoryName = useMemo(() => {
|
||||||
const match = categoryOptions.find((item) => item.id === categoryId)
|
const match = categories.find((item) => item.id === categoryId)
|
||||||
if (!match) return ''
|
if (!match) return ''
|
||||||
return match.nameFa?.trim() || match.nameEn || ''
|
return match.nameFa?.trim() || match.nameEn || ''
|
||||||
}, [categoryOptions, categoryId])
|
}, [categories, categoryId])
|
||||||
|
|
||||||
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
|
const canUseAiFill = Boolean(nameFa.trim() || nameEn.trim()) && !isSubmitting
|
||||||
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
|
const canTranslateNameEn = Boolean(nameFa.trim()) && !isSubmitting && !isAiRunning
|
||||||
@@ -175,10 +179,13 @@ export function AddNewProductPage() {
|
|||||||
setThumbnail(form.thumbnail)
|
setThumbnail(form.thumbnail)
|
||||||
setImages(form.images)
|
setImages(form.images)
|
||||||
setTags(form.tags)
|
setTags(form.tags)
|
||||||
setSeoMetaTitle(form.seoMetaTitle)
|
|
||||||
setSeoMetaDescription(form.seoMetaDescription)
|
|
||||||
setThumbnailMediaId(form.thumbnailMediaId)
|
setThumbnailMediaId(form.thumbnailMediaId)
|
||||||
setGalleryMediaIds(form.galleryMediaIds)
|
setGalleryMediaIds(form.galleryMediaIds)
|
||||||
|
setCatalogMediaId(form.catalogMediaId)
|
||||||
|
setCatalogUrl(form.catalogUrl)
|
||||||
|
setCatalogFileName(form.catalogFileName)
|
||||||
|
setCatalogFile(null)
|
||||||
|
setCatalogRemoved(false)
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
@@ -192,6 +199,39 @@ export function AddNewProductPage() {
|
|||||||
|
|
||||||
if (!loaded) return null
|
if (!loaded) return null
|
||||||
|
|
||||||
|
function clearCatalogFile() {
|
||||||
|
setCatalogFile(null)
|
||||||
|
setCatalogMediaId(null)
|
||||||
|
setCatalogUrl(null)
|
||||||
|
setCatalogFileName(null)
|
||||||
|
setCatalogRemoved(true)
|
||||||
|
if (catalogInputRef.current) {
|
||||||
|
catalogInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCatalogFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0] ?? null
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||||
|
setError(t('products.form.catalog.errorType'))
|
||||||
|
e.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > CATALOG_PDF_MAX_BYTES) {
|
||||||
|
setError(t('products.form.catalog.errorSize'))
|
||||||
|
e.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setCatalogFile(file)
|
||||||
|
setCatalogFileName(file.name)
|
||||||
|
setCatalogUrl(null)
|
||||||
|
setCatalogRemoved(false)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
@@ -215,6 +255,14 @@ export function AddNewProductPage() {
|
|||||||
galleryMediaIds,
|
galleryMediaIds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let nextCatalogMediaId: string | null = catalogMediaId
|
||||||
|
if (catalogFile) {
|
||||||
|
const uploaded = await uploadCatalogPdf(catalogFile)
|
||||||
|
nextCatalogMediaId = uploaded.id
|
||||||
|
} else if (catalogRemoved) {
|
||||||
|
nextCatalogMediaId = null
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: nameEn.trim(),
|
title: nameEn.trim(),
|
||||||
nameFa: nameFa.trim(),
|
nameFa: nameFa.trim(),
|
||||||
@@ -224,8 +272,8 @@ export function AddNewProductPage() {
|
|||||||
brandId: brandId || undefined,
|
brandId: brandId || undefined,
|
||||||
featuredMediaId: featuredMediaId ?? undefined,
|
featuredMediaId: featuredMediaId ?? undefined,
|
||||||
galleryMediaIds: nextGalleryMediaIds,
|
galleryMediaIds: nextGalleryMediaIds,
|
||||||
|
catalogMediaId: nextCatalogMediaId ?? undefined,
|
||||||
tags,
|
tags,
|
||||||
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
|
|
||||||
status: 'published' as const,
|
status: 'published' as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +283,7 @@ export function AddNewProductPage() {
|
|||||||
categoryId: categoryId || null,
|
categoryId: categoryId || null,
|
||||||
brandId: brandId || null,
|
brandId: brandId || null,
|
||||||
featuredMediaId,
|
featuredMediaId,
|
||||||
|
catalogMediaId: nextCatalogMediaId,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createProduct(payload)
|
await createProduct(payload)
|
||||||
@@ -293,15 +342,20 @@ export function AddNewProductPage() {
|
|||||||
<div className={styles.formGrid}>
|
<div className={styles.formGrid}>
|
||||||
<div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}>
|
<div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}>
|
||||||
<label>{t('products.form.thumbnail')}</label>
|
<label>{t('products.form.thumbnail')}</label>
|
||||||
<ImageCropper value={thumbnail} onChange={setThumbnail} />
|
<ImageCropper
|
||||||
|
value={thumbnail}
|
||||||
|
onChange={setThumbnail}
|
||||||
|
minZoom={0.4}
|
||||||
|
outputFormat="jpeg"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.col10}>
|
<div className={styles.col10}>
|
||||||
<div className={styles.topFields}>
|
<div className={styles.topFields}>
|
||||||
<div className={`${styles.field} ${styles.fieldCategory}`}>
|
<div className={`${styles.field} ${styles.fieldCategory}`}>
|
||||||
<label>{t('products.form.category')}</label>
|
<label>{t('products.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('products.form.categoryPlaceholder')}
|
placeholder={t('products.form.categoryPlaceholder')}
|
||||||
@@ -437,6 +491,48 @@ export function AddNewProductPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.field} ${styles.col12}`}>
|
||||||
|
<label htmlFor="product-catalog">{t('products.form.catalog')}</label>
|
||||||
|
<p className={styles.catalogHint}>{t('products.form.catalogHint')}</p>
|
||||||
|
<div className={styles.catalogRow}>
|
||||||
|
<input
|
||||||
|
ref={catalogInputRef}
|
||||||
|
id="product-catalog"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
onChange={handleCatalogFileChange}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={styles.catalogInput}
|
||||||
|
/>
|
||||||
|
{(catalogFileName || catalogUrl) && (
|
||||||
|
<div className={styles.catalogSelected}>
|
||||||
|
<FileText size={16} aria-hidden />
|
||||||
|
{catalogUrl && !catalogFile ? (
|
||||||
|
<a
|
||||||
|
href={catalogUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.catalogLink}
|
||||||
|
>
|
||||||
|
{catalogFileName || t('products.form.catalogView')}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{catalogFileName}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.catalogClear}
|
||||||
|
onClick={clearCatalogFile}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
aria-label={t('products.form.catalogRemove')}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col12}`}>
|
<div className={`${styles.field} ${styles.col12}`}>
|
||||||
<label>{t('products.form.tags')}</label>
|
<label>{t('products.form.tags')}</label>
|
||||||
<TagInput
|
<TagInput
|
||||||
@@ -445,19 +541,6 @@ export function AddNewProductPage() {
|
|||||||
placeholder={t('products.form.tagsPlaceholder')}
|
placeholder={t('products.form.tagsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SeoMetaFields
|
|
||||||
seoMetaTitle={seoMetaTitle}
|
|
||||||
seoMetaDescription={seoMetaDescription}
|
|
||||||
onTitleChange={setSeoMetaTitle}
|
|
||||||
onDescriptionChange={setSeoMetaDescription}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
hint={t('seo.meta.hint')}
|
|
||||||
titleLabel={t('seo.meta.title')}
|
|
||||||
descriptionLabel={t('seo.meta.description')}
|
|
||||||
titlePlaceholder={t('seo.meta.titlePlaceholder')}
|
|
||||||
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
import { RichTextEditor } from '../components/RichTextEditor'
|
import { RichTextEditor } from '../components/RichTextEditor'
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import type { VideoProvider } from '../types/video'
|
import type { VideoProvider } from '../types/video'
|
||||||
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
|
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -55,7 +54,6 @@ export function AddNewVideoPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -218,11 +216,13 @@ export function AddNewVideoPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('video.form.category')}</label>
|
<label>{t('video.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('video.form.categoryPlaceholder')}
|
placeholder={t('video.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||||
import { SearchableSelect } from '../components/SearchableSelect'
|
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||||
import { ImageCropper } from '../components/ImageCropper'
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
import { ImageUploader } from '../components/ImageUploader'
|
import { ImageUploader } from '../components/ImageUploader'
|
||||||
import { TagInput } from '../components/TagInput'
|
import { TagInput } from '../components/TagInput'
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
updateWorkshop,
|
updateWorkshop,
|
||||||
} from '../services/workshopService'
|
} from '../services/workshopService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { flattenCategories } from '../utils/categories'
|
|
||||||
import { seoMetaPayload } from '../utils/seoMeta'
|
import { seoMetaPayload } from '../utils/seoMeta'
|
||||||
import pageStyles from '../components/PageContent.module.css'
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
import styles from './AddNewProductPage.module.css'
|
import styles from './AddNewProductPage.module.css'
|
||||||
@@ -61,7 +60,6 @@ export function AddNewWorkshopPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const categoryOptions = flattenCategories(categories)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -256,11 +254,13 @@ export function AddNewWorkshopPage() {
|
|||||||
<div className={styles.formColumn}>
|
<div className={styles.formColumn}>
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label>{t('workshop.form.category')}</label>
|
<label>{t('workshop.form.category')}</label>
|
||||||
<SearchableSelect
|
<CategoryTreeSelect
|
||||||
options={categoryOptions}
|
categories={categories}
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={setCategoryId}
|
onChange={setCategoryId}
|
||||||
placeholder={t('workshop.form.categoryPlaceholder')}
|
placeholder={t('workshop.form.categoryPlaceholder')}
|
||||||
|
allowEmpty
|
||||||
|
emptyLabel={t('categories.select.noneCategory')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 200px) minmax(0, 1fr);
|
||||||
|
gap: 16px 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 10px 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: var(--field-height, 40px);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
|
||||||
|
linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||||
|
background-position:
|
||||||
|
calc(100% - 16px) calc(50% - 2px),
|
||||||
|
calc(100% - 11px) calc(50% - 2px);
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
padding-inline-end: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([dir='rtl']) .field select {
|
||||||
|
background-position:
|
||||||
|
11px calc(50% - 2px),
|
||||||
|
16px calc(50% - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: 84px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:disabled,
|
||||||
|
.field select:disabled,
|
||||||
|
.field textarea:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameFa,
|
||||||
|
.nameEn {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionFull {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minShopping,
|
||||||
|
.interest {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loanGroup,
|
||||||
|
.monthsGroup {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: 10px;
|
||||||
|
border-inline-start: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-inline-end: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthsGroup {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageCol {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageCol > label {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageCol {
|
||||||
|
max-width: 220px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameFa,
|
||||||
|
.nameEn,
|
||||||
|
.minShopping,
|
||||||
|
.loanGroup,
|
||||||
|
.interest,
|
||||||
|
.monthsGroup,
|
||||||
|
.descriptionFull {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loanGroup,
|
||||||
|
.monthsGroup,
|
||||||
|
.descriptionFull {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameFa,
|
||||||
|
.nameEn,
|
||||||
|
.minShopping,
|
||||||
|
.loanGroup,
|
||||||
|
.interest,
|
||||||
|
.monthsGroup,
|
||||||
|
.descriptionFull {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { resolveDataUrlToMediaId } from '../services/mediaService'
|
||||||
|
import {
|
||||||
|
createStoreLoanMethod,
|
||||||
|
getStoreLoanMethod,
|
||||||
|
mapStoreLoanMethodApiToUi,
|
||||||
|
updateStoreLoanMethod,
|
||||||
|
} from '../services/storeLoanMethodService'
|
||||||
|
import type { StoreLoanImageAspect } from '../types/storeLoanMethod'
|
||||||
|
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||||
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
|
import formStyles from './AddNewProductPage.module.css'
|
||||||
|
import styles from './AddStoreInstallmentPage.module.css'
|
||||||
|
|
||||||
|
const IMAGE_ASPECTS: { id: StoreLoanImageAspect; aspect: number }[] = [
|
||||||
|
{ id: '1:1', aspect: 1 },
|
||||||
|
{ id: '3:2', aspect: 3 / 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MONTH_STEP_OPTIONS = [2, 3, 4, 6, 12] as const
|
||||||
|
const DEFAULT_MONTH_STEP = '3'
|
||||||
|
|
||||||
|
function normalizeMonthStep(value: number | string | null | undefined): string {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return (MONTH_STEP_OPTIONS as readonly number[]).includes(parsed)
|
||||||
|
? String(parsed)
|
||||||
|
: DEFAULT_MONTH_STEP
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumberInput(value: string): number | null {
|
||||||
|
const trimmed = value.trim().replace(/,/g, '')
|
||||||
|
if (!trimmed) return null
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaFilenameFromDataUrl(value: string | null): string {
|
||||||
|
if (value?.startsWith('data:image/png')) return 'store-loan-method.png'
|
||||||
|
return 'store-loan-method.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddStoreInstallmentPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const t = useT()
|
||||||
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
|
const [nameFa, setNameFa] = useState('')
|
||||||
|
const [nameEn, setNameEn] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [image, setImage] = useState<string | null>(null)
|
||||||
|
const [imageMediaId, setImageMediaId] = useState<string | null>(null)
|
||||||
|
const [imageAspect, setImageAspect] = useState<StoreLoanImageAspect>('1:1')
|
||||||
|
const [minShoppingAmount, setMinShoppingAmount] = useState('')
|
||||||
|
const [minAmount, setMinAmount] = useState('')
|
||||||
|
const [maxAmount, setMaxAmount] = useState('')
|
||||||
|
const [interest, setInterest] = useState('')
|
||||||
|
const [returnMonthsMin, setReturnMonthsMin] = useState('')
|
||||||
|
const [returnMonthsMax, setReturnMonthsMax] = useState('')
|
||||||
|
const [returnMonthsStep, setReturnMonthsStep] = useState(DEFAULT_MONTH_STEP)
|
||||||
|
const [loaded, setLoaded] = useState(!isEdit)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const selectedAspect =
|
||||||
|
IMAGE_ASPECTS.find((option) => option.id === imageAspect)?.aspect ?? 1
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit || !id) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
async function loadMethod() {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await getStoreLoanMethod(id!, controller.signal)
|
||||||
|
const method = mapStoreLoanMethodApiToUi(data.method)
|
||||||
|
setNameFa(method.nameFa)
|
||||||
|
setNameEn(method.nameEn)
|
||||||
|
setDescription(method.description)
|
||||||
|
setImage(method.imageUrl)
|
||||||
|
setImageMediaId(method.imageMediaId)
|
||||||
|
setImageAspect(method.imageAspectRatio)
|
||||||
|
setMinShoppingAmount(
|
||||||
|
method.minShoppingAmount != null
|
||||||
|
? formatIrtInput(String(Math.round(method.minShoppingAmount)))
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
setMinAmount(
|
||||||
|
method.minAmount != null
|
||||||
|
? formatIrtInput(String(Math.round(method.minAmount)))
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
setMaxAmount(
|
||||||
|
method.maxAmount != null
|
||||||
|
? formatIrtInput(String(Math.round(method.maxAmount)))
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
setInterest(String(method.interest))
|
||||||
|
setReturnMonthsMin(String(method.returnMonthsMin))
|
||||||
|
setReturnMonthsMax(String(method.returnMonthsMax))
|
||||||
|
setReturnMonthsStep(normalizeMonthStep(method.returnMonthsStep))
|
||||||
|
setLoaded(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
|
navigate('/store/installments', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadMethod()
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [isEdit, id, navigate])
|
||||||
|
|
||||||
|
if (!loaded) return null
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const parsedMinShopping = parseIrtInput(minShoppingAmount)
|
||||||
|
const parsedMinAmount = parseIrtInput(minAmount)
|
||||||
|
const parsedMaxAmount = parseIrtInput(maxAmount)
|
||||||
|
const parsedInterest = parseNumberInput(interest)
|
||||||
|
const parsedMonthsMin = parseNumberInput(returnMonthsMin)
|
||||||
|
const parsedMonthsMax = parseNumberInput(returnMonthsMax)
|
||||||
|
const parsedMonthsStep = parseNumberInput(returnMonthsStep)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!nameFa.trim() ||
|
||||||
|
!nameEn.trim() ||
|
||||||
|
parsedMinShopping === null ||
|
||||||
|
parsedMinAmount === null ||
|
||||||
|
parsedMaxAmount === null ||
|
||||||
|
parsedInterest === null ||
|
||||||
|
parsedMonthsMin === null ||
|
||||||
|
parsedMonthsMax === null ||
|
||||||
|
parsedMonthsStep === null
|
||||||
|
) {
|
||||||
|
setError(t('store.installments.form.errorRequired'))
|
||||||
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMinAmount > parsedMaxAmount) {
|
||||||
|
setError(t('store.installments.form.errorAmountRange'))
|
||||||
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMonthsMin > parsedMonthsMax) {
|
||||||
|
setError(t('store.installments.form.errorMonthsRange'))
|
||||||
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(MONTH_STEP_OPTIONS as readonly number[]).includes(parsedMonthsStep)) {
|
||||||
|
setError(t('store.installments.form.errorMonthsStep'))
|
||||||
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((parsedMonthsMax - parsedMonthsMin) % parsedMonthsStep !== 0) {
|
||||||
|
setError(t('store.installments.form.errorMonthsStepReach'))
|
||||||
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextImageMediaId = await resolveDataUrlToMediaId(
|
||||||
|
image,
|
||||||
|
mediaFilenameFromDataUrl(image),
|
||||||
|
imageMediaId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
nameFa: nameFa.trim(),
|
||||||
|
nameEn: nameEn.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
imageAspectRatio: imageAspect,
|
||||||
|
minShoppingAmount: parsedMinShopping,
|
||||||
|
minAmount: parsedMinAmount,
|
||||||
|
maxAmount: parsedMaxAmount,
|
||||||
|
interest: parsedInterest,
|
||||||
|
returnMonthsMin: Math.round(parsedMonthsMin),
|
||||||
|
returnMonthsMax: Math.round(parsedMonthsMax),
|
||||||
|
returnMonthsStep: Math.round(parsedMonthsStep),
|
||||||
|
...(isEdit
|
||||||
|
? { imageMediaId: nextImageMediaId }
|
||||||
|
: nextImageMediaId
|
||||||
|
? { imageMediaId: nextImageMediaId }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && id) {
|
||||||
|
await updateStoreLoanMethod(id, payload)
|
||||||
|
showToast(t('store.installments.toast.updated'), 'success')
|
||||||
|
} else {
|
||||||
|
await createStoreLoanMethod({
|
||||||
|
...payload,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
})
|
||||||
|
showToast(t('store.installments.toast.created'), 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/store/installments')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(err.message)
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(t('store.installments.form.errorSave'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={pageStyles.content}>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={
|
||||||
|
isEdit
|
||||||
|
? [
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: 'Store', href: '/store' },
|
||||||
|
{ label: 'Installments & Facilities', href: '/store/installments' },
|
||||||
|
{ label: 'Edit Installment Method' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: 'Store', href: '/store' },
|
||||||
|
{ label: 'Installments & Facilities', href: '/store/installments' },
|
||||||
|
{ label: 'Add Installment Method' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={pageStyles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h2 className={pageStyles.pageTitle}>
|
||||||
|
{isEdit
|
||||||
|
? t('title.editInstallment')
|
||||||
|
: t('title.addInstallment')}
|
||||||
|
</h2>
|
||||||
|
<p className={pageStyles.pageSubtitle}>
|
||||||
|
{isEdit
|
||||||
|
? t('store.installments.form.edit.subtitle')
|
||||||
|
: t('store.installments.form.add.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={formStyles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className={formStyles.form} onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<div className={styles.imageCol}>
|
||||||
|
<label>{t('store.installments.form.image')}</label>
|
||||||
|
<ImageCropper
|
||||||
|
value={image}
|
||||||
|
onChange={setImage}
|
||||||
|
aspect={selectedAspect}
|
||||||
|
compact
|
||||||
|
uploadLabel={t('store.installments.form.uploadImage')}
|
||||||
|
hint={t('store.installments.form.cropHint')}
|
||||||
|
changeLabel={t('store.installments.form.changeImage')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={formStyles.aspectSwitch}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('store.installments.form.aspectAria')}
|
||||||
|
>
|
||||||
|
{IMAGE_ASPECTS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={imageAspect === option.id}
|
||||||
|
className={`${formStyles.aspectSwitchBtn} ${
|
||||||
|
imageAspect === option.id ? formStyles.aspectSwitchBtnSelected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (option.id === imageAspect) return
|
||||||
|
setImageAspect(option.id)
|
||||||
|
if (image) {
|
||||||
|
setImage(null)
|
||||||
|
setImageMediaId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{option.id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={`${styles.field} ${styles.nameFa}`}>
|
||||||
|
<label htmlFor="loan-name-fa">{t('store.installments.form.nameFa')}</label>
|
||||||
|
<input
|
||||||
|
id="loan-name-fa"
|
||||||
|
type="text"
|
||||||
|
value={nameFa}
|
||||||
|
onChange={(e) => setNameFa(e.target.value)}
|
||||||
|
placeholder={t('store.installments.form.nameFaPlaceholder')}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.field} ${styles.nameEn}`}>
|
||||||
|
<label htmlFor="loan-name-en">{t('store.installments.form.nameEn')}</label>
|
||||||
|
<input
|
||||||
|
id="loan-name-en"
|
||||||
|
type="text"
|
||||||
|
value={nameEn}
|
||||||
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
placeholder={t('store.installments.form.nameEnPlaceholder')}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={`${styles.field} ${styles.descriptionFull}`}>
|
||||||
|
<label htmlFor="loan-description">
|
||||||
|
{t('store.installments.form.description')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="loan-description"
|
||||||
|
rows={3}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t('store.installments.form.descriptionPlaceholder')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={`${styles.field} ${styles.minShopping}`}>
|
||||||
|
<label htmlFor="loan-min-shopping">
|
||||||
|
{t('store.installments.form.minShoppingAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-min-shopping"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={minShoppingAmount}
|
||||||
|
onChange={(e) => setMinShoppingAmount(formatIrtInput(e.target.value))}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{t('store.installments.form.minShoppingAmount.hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.group} ${styles.loanGroup}`}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="loan-min-amount">
|
||||||
|
{t('store.installments.form.minAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-min-amount"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => setMinAmount(formatIrtInput(e.target.value))}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>{t('store.installments.form.minAmount.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="loan-max-amount">
|
||||||
|
{t('store.installments.form.maxAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-max-amount"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => setMaxAmount(formatIrtInput(e.target.value))}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>{t('store.installments.form.maxAmount.hint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={`${styles.field} ${styles.interest}`}>
|
||||||
|
<label htmlFor="loan-interest">
|
||||||
|
{t('store.installments.form.interest')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-interest"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={interest}
|
||||||
|
onChange={(e) => setInterest(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>{t('store.installments.form.interest.hint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.group} ${styles.monthsGroup}`}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="loan-months-min">
|
||||||
|
{t('store.installments.form.returnMonthsMin')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-months-min"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={returnMonthsMin}
|
||||||
|
onChange={(e) => setReturnMonthsMin(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{t('store.installments.form.returnMonthsMin.hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="loan-months-max">
|
||||||
|
{t('store.installments.form.returnMonthsMax')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="loan-months-max"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={returnMonthsMax}
|
||||||
|
onChange={(e) => setReturnMonthsMax(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{t('store.installments.form.returnMonthsMax.hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="loan-months-step">
|
||||||
|
{t('store.installments.form.returnMonthsStep')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="loan-months-step"
|
||||||
|
value={returnMonthsStep}
|
||||||
|
onChange={(e) => setReturnMonthsStep(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{MONTH_STEP_OPTIONS.map((step) => (
|
||||||
|
<option key={step} value={step}>
|
||||||
|
{step}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{t('store.installments.form.returnMonthsStep.hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={formStyles.actions}>
|
||||||
|
<Link to="/store/installments" className={formStyles.cancelBtn}>
|
||||||
|
{t('store.installments.form.cancel')}
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className={formStyles.submitBtn} disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? t('store.installments.form.saving')
|
||||||
|
: isEdit
|
||||||
|
? t('store.installments.form.update')
|
||||||
|
: t('store.installments.form.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -51,6 +51,13 @@
|
|||||||
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
|
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.addFab:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.fabDock {
|
.fabDock {
|
||||||
inset-inline-end: 20px;
|
inset-inline-end: 20px;
|
||||||
|
|||||||
@@ -40,8 +40,30 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoLightCol,
|
.logosBlock {
|
||||||
.logoDarkCol,
|
grid-column: span 12;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logosPair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoField {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.faviconCol {
|
.faviconCol {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -229,12 +251,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
.logoLightCol {
|
.logosBlock {
|
||||||
grid-column: span 2;
|
grid-column: span 4;
|
||||||
}
|
|
||||||
|
|
||||||
.logoDarkCol {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.faviconCol {
|
.faviconCol {
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ import styles from './BusinessProfilePage.module.css'
|
|||||||
|
|
||||||
type AddressDraft = AddressListItem
|
type AddressDraft = AddressListItem
|
||||||
|
|
||||||
|
type LogoAspectId = '1:1' | '3:2' | '16:9'
|
||||||
|
|
||||||
|
const LOGO_ASPECTS: { id: LogoAspectId; aspect: number }[] = [
|
||||||
|
{ id: '1:1', aspect: 1 },
|
||||||
|
{ id: '3:2', aspect: 3 / 2 },
|
||||||
|
{ id: '16:9', aspect: 16 / 9 },
|
||||||
|
]
|
||||||
|
|
||||||
const EMPTY_SOCIAL: BusinessSocialMedia = {
|
const EMPTY_SOCIAL: BusinessSocialMedia = {
|
||||||
whatsapp: '',
|
whatsapp: '',
|
||||||
telegram: '',
|
telegram: '',
|
||||||
@@ -82,6 +90,7 @@ export function BusinessProfilePage() {
|
|||||||
const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
|
const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
|
||||||
const [logoDark, setLogoDark] = useState<string | null>(null)
|
const [logoDark, setLogoDark] = useState<string | null>(null)
|
||||||
const [logoDarkMediaId, setLogoDarkMediaId] = useState<string | null>(null)
|
const [logoDarkMediaId, setLogoDarkMediaId] = useState<string | null>(null)
|
||||||
|
const [logoAspect, setLogoAspect] = useState<LogoAspectId>('1:1')
|
||||||
const [favicon, setFavicon] = useState<string | null>(null)
|
const [favicon, setFavicon] = useState<string | null>(null)
|
||||||
const [faviconMediaId, setFaviconMediaId] = useState<string | null>(null)
|
const [faviconMediaId, setFaviconMediaId] = useState<string | null>(null)
|
||||||
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
||||||
@@ -91,9 +100,25 @@ export function BusinessProfilePage() {
|
|||||||
const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL)
|
const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL)
|
||||||
|
|
||||||
const categoryOptions = useMemo(
|
const categoryOptions = useMemo(
|
||||||
() => flattenBusinessActivityCategories(activityCategories),
|
() => flattenBusinessActivityCategories(activityCategories, locale),
|
||||||
[activityCategories],
|
[activityCategories, locale],
|
||||||
)
|
)
|
||||||
|
const selectedLogoAspect =
|
||||||
|
LOGO_ASPECTS.find((option) => option.id === logoAspect)?.aspect ?? 1
|
||||||
|
|
||||||
|
function handleLogoAspectChange(next: LogoAspectId) {
|
||||||
|
if (next === logoAspect) return
|
||||||
|
setLogoAspect(next)
|
||||||
|
// Applied crops are locked to the old ratio — drop them so the user recrops.
|
||||||
|
if (logo) {
|
||||||
|
setLogo(null)
|
||||||
|
setLogoMediaId(null)
|
||||||
|
}
|
||||||
|
if (logoDark) {
|
||||||
|
setLogoDark(null)
|
||||||
|
setLogoDarkMediaId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addressLabels = useMemo(
|
const addressLabels = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -176,7 +201,7 @@ export function BusinessProfilePage() {
|
|||||||
: item.province,
|
: item.province,
|
||||||
city: item.city,
|
city: item.city,
|
||||||
address: item.address,
|
address: item.address,
|
||||||
postalCode: item.postalCode,
|
postalCode: item.postalCode ?? '',
|
||||||
landline: item.landline ?? '',
|
landline: item.landline ?? '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -307,7 +332,7 @@ export function BusinessProfilePage() {
|
|||||||
province: item.province.trim(),
|
province: item.province.trim(),
|
||||||
city: item.city.trim(),
|
city: item.city.trim(),
|
||||||
address: item.address.trim(),
|
address: item.address.trim(),
|
||||||
postalCode: item.postalCode.trim(),
|
postalCode: item.postalCode.trim() || undefined,
|
||||||
landline: item.landline?.trim() || null,
|
landline: item.landline?.trim() || null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -378,32 +403,60 @@ export function BusinessProfilePage() {
|
|||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3>
|
<h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3>
|
||||||
<div className={formStyles.formGrid}>
|
<div className={formStyles.formGrid}>
|
||||||
<div className={`${formStyles.field} ${styles.logoLightCol}`}>
|
<div className={styles.logosBlock}>
|
||||||
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
|
<div className={styles.logosPair}>
|
||||||
<ImageCropper
|
<div className={`${formStyles.field} ${styles.logoField}`}>
|
||||||
compact
|
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
|
||||||
value={logo}
|
<ImageCropper
|
||||||
onChange={setLogo}
|
compact
|
||||||
outputFormat="png"
|
value={logo}
|
||||||
accept="image/png"
|
onChange={setLogo}
|
||||||
uploadLabel={t('bizProfile.logo.upload')}
|
aspect={selectedLogoAspect}
|
||||||
hint={t('bizProfile.logo.hint')}
|
outputFormat="png"
|
||||||
changeLabel={t('bizProfile.logo.change')}
|
accept="image/png"
|
||||||
/>
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
</div>
|
hint={t('bizProfile.logo.hint')}
|
||||||
|
changeLabel={t('bizProfile.logo.change')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.logoDarkCol}`}>
|
<div className={`${formStyles.field} ${styles.logoField}`}>
|
||||||
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
|
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
|
||||||
<ImageCropper
|
<ImageCropper
|
||||||
compact
|
compact
|
||||||
value={logoDark}
|
value={logoDark}
|
||||||
onChange={setLogoDark}
|
onChange={setLogoDark}
|
||||||
outputFormat="png"
|
aspect={selectedLogoAspect}
|
||||||
accept="image/png"
|
outputFormat="png"
|
||||||
uploadLabel={t('bizProfile.logo.upload')}
|
accept="image/png"
|
||||||
hint={t('bizProfile.logo.hint')}
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
changeLabel={t('bizProfile.logo.change')}
|
hint={t('bizProfile.logo.hint')}
|
||||||
/>
|
changeLabel={t('bizProfile.logo.change')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={formStyles.aspectSwitch}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('bizProfile.logo.aspectAria')}
|
||||||
|
>
|
||||||
|
{LOGO_ASPECTS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={logoAspect === option.id}
|
||||||
|
className={`${formStyles.aspectSwitchBtn} ${
|
||||||
|
logoAspect === option.id ? formStyles.aspectSwitchBtnSelected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleLogoAspectChange(option.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{option.id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.faviconCol}`}>
|
<div className={`${formStyles.field} ${styles.faviconCol}`}>
|
||||||
@@ -412,6 +465,7 @@ export function BusinessProfilePage() {
|
|||||||
compact
|
compact
|
||||||
value={favicon}
|
value={favicon}
|
||||||
onChange={setFavicon}
|
onChange={setFavicon}
|
||||||
|
aspect={1}
|
||||||
outputFormat="png"
|
outputFormat="png"
|
||||||
accept="image/png"
|
accept="image/png"
|
||||||
uploadLabel={t('bizProfile.favicon.upload')}
|
uploadLabel={t('bizProfile.favicon.upload')}
|
||||||
|
|||||||
@@ -1,3 +1,67 @@
|
|||||||
|
.listSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
width: 100%;
|
||||||
|
height: 52px;
|
||||||
|
padding-inline: 52px 48px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput:focus {
|
||||||
|
border-color: rgba(var(--primary-rgb) / 0.45);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchClear {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 12px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchClear:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FolderTree, Globe, Plus, Sparkles } from 'lucide-react'
|
import { FolderTree, Globe, Plus, Search, Sparkles, X } from 'lucide-react'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { CategoryModal } from '../components/CategoryModal'
|
import { CategoryModal } from '../components/CategoryModal'
|
||||||
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
|
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
toCategoryFormPayload,
|
toCategoryFormPayload,
|
||||||
updateProductCategory,
|
updateProductCategory,
|
||||||
} from '../services/productCategoryService'
|
} from '../services/productCategoryService'
|
||||||
|
import { filterCategoriesByQuery, getAncestorIds } from '../utils/categories'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { translateAllProductCategories } from '../services/translationService'
|
import { translateAllProductCategories } from '../services/translationService'
|
||||||
import type { TranslationLocale } from '../types/translation'
|
import type { TranslationLocale } from '../types/translation'
|
||||||
@@ -52,6 +53,7 @@ export function CategoriesPage() {
|
|||||||
const { enabledModules } = useTenantBranding()
|
const { enabledModules } = useTenantBranding()
|
||||||
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
|
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [defaultParentId, setDefaultParentId] = useState('')
|
const [defaultParentId, setDefaultParentId] = useState('')
|
||||||
@@ -119,6 +121,32 @@ export function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const filteredCategories = useMemo(
|
||||||
|
() => filterCategoriesByQuery(categories, searchQuery),
|
||||||
|
[categories, searchQuery],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = searchQuery.trim()
|
||||||
|
if (!query) return
|
||||||
|
|
||||||
|
const normalized = query.toLowerCase()
|
||||||
|
const nextExpanded = new Set<string>()
|
||||||
|
for (const category of categories) {
|
||||||
|
const haystack = `${category.nameFa} ${category.nameEn}`.toLowerCase()
|
||||||
|
if (!haystack.includes(normalized)) continue
|
||||||
|
for (const ancestorId of getAncestorIds(categories, category.id)) {
|
||||||
|
nextExpanded.add(ancestorId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextExpanded.size === 0) return
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const merged = new Set(prev)
|
||||||
|
for (const id of nextExpanded) merged.add(id)
|
||||||
|
return merged
|
||||||
|
})
|
||||||
|
}, [categories, searchQuery])
|
||||||
|
|
||||||
function openAiFabMenu() {
|
function openAiFabMenu() {
|
||||||
if (aiMenuCloseTimerRef.current !== null) {
|
if (aiMenuCloseTimerRef.current !== null) {
|
||||||
window.clearTimeout(aiMenuCloseTimerRef.current)
|
window.clearTimeout(aiMenuCloseTimerRef.current)
|
||||||
@@ -549,30 +577,60 @@ export function CategoriesPage() {
|
|||||||
onAction={() => openCreateModal()}
|
onAction={() => openCreateModal()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<div className={styles.listSection}>
|
||||||
<CategoryTree
|
<div className={styles.searchWrap}>
|
||||||
categories={categories}
|
<Search size={20} className={styles.searchIcon} aria-hidden="true" />
|
||||||
expandedIds={expandedIds}
|
<input
|
||||||
variationCounts={variationCountsMap()}
|
type="search"
|
||||||
technicalFieldCounts={technicalFieldCountsMap()}
|
className={styles.searchInput}
|
||||||
onToggle={toggleExpanded}
|
value={searchQuery}
|
||||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onVariations={openVariations}
|
placeholder={t('categories.searchPlaceholder')}
|
||||||
onTechnicalForm={openTechnicalForm}
|
aria-label={t('categories.search')}
|
||||||
onEdit={(id) => {
|
/>
|
||||||
const category = categories.find((item) => item.id === id)
|
{searchQuery ? (
|
||||||
if (category) openEditModal(category)
|
<button
|
||||||
}}
|
type="button"
|
||||||
onTranslate={(id) => {
|
className={styles.searchClear}
|
||||||
if (!isMultiLanguageEnabled) return
|
onClick={() => setSearchQuery('')}
|
||||||
const category = categories.find((item) => item.id === id)
|
aria-label={t('categories.searchClear')}
|
||||||
if (category) setTranslateTarget(category)
|
>
|
||||||
}}
|
<X size={18} />
|
||||||
onRemove={(id) => {
|
</button>
|
||||||
const category = categories.find((item) => item.id === id)
|
) : null}
|
||||||
if (category) setDeleteTarget(category)
|
</div>
|
||||||
}}
|
|
||||||
/>
|
{filteredCategories.length === 0 ? (
|
||||||
|
<div className={styles.list}>
|
||||||
|
<p className={styles.empty}>{t('categories.searchEmpty')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.list}>
|
||||||
|
<CategoryTree
|
||||||
|
categories={filteredCategories}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
variationCounts={variationCountsMap()}
|
||||||
|
technicalFieldCounts={technicalFieldCountsMap()}
|
||||||
|
onToggle={toggleExpanded}
|
||||||
|
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||||
|
onVariations={openVariations}
|
||||||
|
onTechnicalForm={openTechnicalForm}
|
||||||
|
onEdit={(id) => {
|
||||||
|
const category = categories.find((item) => item.id === id)
|
||||||
|
if (category) openEditModal(category)
|
||||||
|
}}
|
||||||
|
onTranslate={(id) => {
|
||||||
|
if (!isMultiLanguageEnabled) return
|
||||||
|
const category = categories.find((item) => item.id === id)
|
||||||
|
if (category) setTranslateTarget(category)
|
||||||
|
}}
|
||||||
|
onRemove={(id) => {
|
||||||
|
const category = categories.find((item) => item.id === id)
|
||||||
|
if (category) setDeleteTarget(category)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user