Compare commits
32
Commits
d35c2f92b6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018f3142ec | ||
|
|
b25f3cbea0 | ||
|
|
c4e846564e | ||
|
|
ac287e02cc | ||
|
|
eb9b8b9fa3 | ||
|
|
68b985dee3 | ||
|
|
cb20fe3c40 | ||
|
|
06589600bd | ||
|
|
55071af98c | ||
|
|
41f21999d2 | ||
|
|
bc1c5f0747 | ||
|
|
016dd0b081 | ||
|
|
c59ca4444f | ||
|
|
95390933a7 | ||
|
|
6d85a217e1 | ||
|
|
e2950fa20e | ||
|
|
ff419603bf | ||
|
|
8465a29ce1 | ||
|
|
838e84056e | ||
|
|
3eb6495f55 | ||
|
|
00f2e0f729 | ||
|
|
592540cc7e | ||
|
|
0bbc5bbc12 | ||
|
|
61d145d92e | ||
|
|
32f0dbab63 | ||
|
|
06e2442aaf | ||
|
|
b743c09e4d | ||
|
|
d787a69195 | ||
|
|
c24f6c06f7 | ||
|
|
e020c74fb1 | ||
|
|
8630465d34 | ||
|
|
c344ddefdc |
@@ -24,6 +24,7 @@ import { StorePage } from './pages/StorePage'
|
|||||||
import { StoreItemsPage } from './pages/StoreItemsPage'
|
import { 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)
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color.label}
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.chip} ${styles.chipSelected}`}
|
className={`${styles.chip} ${styles.chipSelected}`}
|
||||||
onClick={() =>
|
onClick={() => removeSelectedColor(color.label)}
|
||||||
toggleOption(color, selectedColors, setSelectedColors)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={styles.colorSwatch}
|
className={styles.colorSwatch}
|
||||||
style={{ backgroundColor: preset?.hex ?? '#94a3b8' }}
|
style={{ backgroundColor: color.hex }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{t(colorLabelKey(color))}
|
{localizeVariationOptionLabel(color.label, t)}
|
||||||
<X size={12} aria-hidden="true" />
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</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}
|
|
||||||
ref={containerRef}
|
|
||||||
dir={isFa ? 'rtl' : 'ltr'}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
styles.inputWrap,
|
|
||||||
open ? styles.inputWrapOpen : '',
|
|
||||||
disabled ? styles.inputWrapDisabled : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
|
||||||
<input
|
|
||||||
id={id}
|
id={id}
|
||||||
type="text"
|
categories={categories}
|
||||||
className={styles.input}
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={selected ? selectedLabel : searchPlaceholder}
|
placeholder={placeholder}
|
||||||
value={open ? query : selectedLabel}
|
allowEmpty={allowEmpty}
|
||||||
onChange={(e) => {
|
dropdownVariant={dropdownVariant}
|
||||||
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,12 +146,53 @@ export function CreateStoreItemsModal({
|
|||||||
}, [open, mounted])
|
}, [open, mounted])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
searchControllerRef.current?.abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const searchProducts = useCallback(
|
||||||
|
async (query: string) => {
|
||||||
|
searchControllerRef.current?.abort()
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
setSearchResults([])
|
||||||
|
setIsSearchingProducts(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
void loadProducts(controller.signal)
|
searchControllerRef.current = controller
|
||||||
return () => controller.abort()
|
setIsSearchingProducts(true)
|
||||||
}, [open])
|
|
||||||
|
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) {
|
||||||
@@ -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,10 +370,7 @@ 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
|
|
||||||
? products
|
|
||||||
: (await listAllProducts()).map((item) => ({
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
nameFa: item.nameFa,
|
nameFa: item.nameFa,
|
||||||
@@ -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}>
|
||||||
@@ -248,9 +275,99 @@ export function InvoiceDraftFields({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.blockSection}>
|
||||||
|
<div className={styles.blockHeader}>
|
||||||
|
<h4 className={styles.blockTitle}>{t('invoiceDraft.paymentMethods')}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||||
|
onClick={() => onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('invoiceDraft.addPaymentMethod')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{paymentMethods.length === 0 ? (
|
||||||
|
<p className={styles.templateHint}>{t('invoiceDraft.noPaymentMethods')}</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.repeatStack}>
|
||||||
|
{paymentMethods.map((method, index) => (
|
||||||
|
<div key={method.key} className={styles.repeatRow}>
|
||||||
|
<div className={`${formStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
|
||||||
|
<input
|
||||||
|
data-payment-method-index={index}
|
||||||
|
value={method.text}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPaymentMethodsChange(
|
||||||
|
paymentMethods.map((m) =>
|
||||||
|
m.key === method.key ? { ...m, text: e.target.value } : m,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const next = index + 1
|
||||||
|
if (next < paymentMethods.length) {
|
||||||
|
focusPaymentMethodInput(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onPaymentMethodsChange([...paymentMethods, emptyDraftPaymentMethod()])
|
||||||
|
window.setTimeout(() => focusPaymentMethodInput(next), 0)
|
||||||
|
}}
|
||||||
|
placeholder={t('invoiceDraft.paymentMethodPlaceholder')}
|
||||||
|
aria-label={t('invoiceDraft.paymentMethodAria', { index: index + 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeFieldBtn}
|
||||||
|
onClick={() =>
|
||||||
|
onPaymentMethodsChange(paymentMethods.filter((m) => m.key !== method.key))
|
||||||
|
}
|
||||||
|
aria-label={t('invoiceDraft.removePaymentMethod')}
|
||||||
|
title={t('invoiceDraft.removePaymentMethod')}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.blockSection}>
|
<div className={styles.blockSection}>
|
||||||
<div className={styles.blockHeader}>
|
<div className={styles.blockHeader}>
|
||||||
<h4 className={styles.blockTitle}>{t('invoiceDraft.accounts')}</h4>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
className={`${formStyles.btn} ${formStyles.btnGhost}`}
|
||||||
@@ -260,6 +377,7 @@ export function InvoiceDraftFields({
|
|||||||
{t('invoiceDraft.addAccount')}
|
{t('invoiceDraft.addAccount')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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,10 +169,11 @@ 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
|
||||||
|
if (containerRef.current?.contains(target)) return
|
||||||
|
if (menuRef.current?.contains(target)) return
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleEscape(e: KeyboardEvent) {
|
function handleEscape(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') setOpen(false)
|
if (e.key === 'Escape') setOpen(false)
|
||||||
@@ -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,28 +201,20 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
onChange([...value, optionValue])
|
onChange([...value, optionValue])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const menu =
|
||||||
<div className={styles.wrapper} ref={containerRef}>
|
open && menuPos
|
||||||
<span ref={measureRef} className={styles.measureText} aria-hidden="true" />
|
? createPortal(
|
||||||
<button
|
<div
|
||||||
ref={triggerRef}
|
ref={menuRef}
|
||||||
id={id}
|
className={styles.dropdown}
|
||||||
type="button"
|
style={{
|
||||||
className={`${styles.trigger} ${open ? styles.triggerOpen : ''} ${
|
top: menuPos.top,
|
||||||
selectedLabels.length === 0 ? styles.triggerPlaceholder : ''
|
left: menuPos.left,
|
||||||
}`}
|
width: menuPos.width,
|
||||||
onClick={() => !disabled && setOpen((prev) => !prev)}
|
maxHeight: menuPos.maxHeight,
|
||||||
disabled={disabled}
|
}}
|
||||||
aria-haspopup="listbox"
|
role="presentation"
|
||||||
aria-expanded={open}
|
|
||||||
>
|
>
|
||||||
{searchable && <Search size={16} className={styles.triggerSearchIcon} aria-hidden="true" />}
|
|
||||||
<span className={styles.triggerText}>{triggerLabel}</span>
|
|
||||||
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className={styles.dropdown}>
|
|
||||||
{searchable && (
|
{searchable && (
|
||||||
<div className={styles.searchWrap}>
|
<div className={styles.searchWrap}>
|
||||||
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
|
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
|
||||||
@@ -196,7 +245,7 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
||||||
style={{ paddingLeft: `${10 + (option.depth ?? 0) * 18}px` }}
|
style={{ paddingInlineStart: `${10 + (option.depth ?? 0) * 18}px` }}
|
||||||
onClick={() => toggleOption(option.value)}
|
onClick={() => toggleOption(option.value)}
|
||||||
>
|
>
|
||||||
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
|
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
|
||||||
@@ -209,8 +258,31 @@ export function MultiSelectDropdown<T extends string | number = number>({
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>,
|
||||||
)}
|
document.body,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper} ref={containerRef}>
|
||||||
|
<span ref={measureRef} className={styles.measureText} aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.trigger} ${open ? styles.triggerOpen : ''} ${
|
||||||
|
selectedLabels.length === 0 ? styles.triggerPlaceholder : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => !disabled && setOpen((prev) => !prev)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{searchable && <Search size={16} className={styles.triggerSearchIcon} aria-hidden="true" />}
|
||||||
|
<span className={styles.triggerText}>{triggerLabel}</span>
|
||||||
|
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
||||||
|
</button>
|
||||||
|
{menu}
|
||||||
</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,13 +248,35 @@ 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}>
|
||||||
|
<span>{t('orderItems.subtotal')}</span>
|
||||||
|
<span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.totalsRow}>
|
||||||
|
<span>{t('orderItems.shipping')}</span>
|
||||||
|
<span className={styles.totalsValue}>
|
||||||
|
{order.shippingTotal > 0
|
||||||
|
? formatIrtPrice(order.shippingTotal)
|
||||||
|
: shippingInfo.hasPasKraye
|
||||||
|
? t('orderItems.pasKraye')
|
||||||
|
: t('orderItems.freeShipping')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{order.discountTotal > 0 ? (
|
||||||
|
<div className={styles.totalsRow}>
|
||||||
|
<span>{t('orderItems.discount')}</span>
|
||||||
|
<span className={styles.totalsValue}>{formatIrtPrice(order.discountTotal)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.totalsRow} ${styles.totalsRowGrand}`}>
|
||||||
|
<span>
|
||||||
{t('orderItems.summary', {
|
{t('orderItems.summary', {
|
||||||
count: itemCount,
|
count: itemCount,
|
||||||
itemsLabel:
|
itemsLabel:
|
||||||
@@ -154,6 +285,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
|||||||
</span>
|
</span>
|
||||||
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={modalStyles.actions}>
|
<div className={modalStyles.actions}>
|
||||||
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
||||||
|
|||||||
@@ -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,12 +128,39 @@ 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') {
|
||||||
|
if (transaction.gatewayType) {
|
||||||
rows.push({ label: t('orderTransactions.gateway'), value: 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>
|
|
||||||
<OpenOnWebsiteControl href={websiteUrl} />
|
|
||||||
{showTranslationControls ? (
|
|
||||||
<Tooltip label={t('products.card.translate')}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
onClick={() => onTranslate(product.id)}
|
onClick={action.onClick}
|
||||||
aria-label={
|
aria-label={
|
||||||
translationCount > 0
|
action.badge != null ? `${action.label} (${action.badge})` : action.label
|
||||||
? `${t('products.card.translate')} (${translationCount})`
|
|
||||||
: t('products.card.translate')
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Globe size={16} />
|
<Icon size={16} />
|
||||||
{translationCount > 0 && (
|
{action.badge != null ? (
|
||||||
<span className={styles.translateBadge}>{translationCount}</span>
|
<span className={styles.actionBadge}>{action.badge}</span>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
) : 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>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ProductCardOverflowMenu productName={primaryName} items={menuActions} />
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
{isSearching && open ? (
|
||||||
|
<Loader2 size={16} className={`${styles.chevron} ${styles.spin}`} />
|
||||||
|
) : (
|
||||||
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
|
<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,7 +253,10 @@ export function ProductTechnicalInfoModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canUseAi = Boolean(form && form.fields.length > 0)
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -222,26 +267,32 @@ export function ProductTechnicalInfoModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="product-technical-title"
|
aria-labelledby="product-technical-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
>
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h3 id="product-technical-title" className={styles.title}>
|
<h3 id="product-technical-title" className={styles.title}>
|
||||||
Technical Data
|
{t('products.technical.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}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className={styles.emptyText}>Loading technical data...</p>
|
<p className={styles.emptyText}>{t('products.technical.loading')}</p>
|
||||||
) : infoMessage && !form ? (
|
) : infoMessage && !form ? (
|
||||||
<p className={styles.emptyText}>{infoMessage}</p>
|
<p className={styles.emptyText}>{infoMessage}</p>
|
||||||
) : form && form.fields.length === 0 ? (
|
) : form && form.fields.length === 0 ? (
|
||||||
<p className={styles.emptyText}>This category form has no fields yet.</p>
|
<p className={styles.emptyText}>{t('products.technical.emptyFields')}</p>
|
||||||
) : (
|
) : (
|
||||||
form?.fields.map((field) => (
|
form?.fields.map((field) => (
|
||||||
<div key={field.id} className={styles.field}>
|
<div key={field.id} className={styles.field}>
|
||||||
@@ -254,25 +305,54 @@ export function ProductTechnicalInfoModal({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{infoMessage && form ? (
|
||||||
|
<p className={styles.emptyText}>{infoMessage}</p>
|
||||||
|
) : null}
|
||||||
{error && <p className={styles.errorText}>{error}</p>}
|
{error && <p className={styles.errorText}>{error}</p>}
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={fieldStyles.productTechnicalActions}>
|
||||||
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
<div className={fieldStyles.actionsMain}>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.submitBtn}
|
className={styles.submitBtn}
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
disabled={!form || !isFormValid() || isSaving || isLoading}
|
disabled={!form || !isFormValid() || isSaving || isLoading || isAiRunning}
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving…' : 'Save'}
|
{isSaving ? t('products.technical.saving') : t('products.technical.save')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
||||||
|
open={isAiOpen}
|
||||||
|
productName={productName}
|
||||||
|
fields={form?.fields ?? []}
|
||||||
|
isRunning={isAiRunning}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isAiRunning) setIsAiOpen(false)
|
||||||
|
}}
|
||||||
|
onRun={handleAiRun}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
document.body,
|
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) => {
|
||||||
|
const name = variationLabel(variation)
|
||||||
|
return (
|
||||||
<div key={variation.id} className={styles.field}>
|
<div key={variation.id} className={styles.field}>
|
||||||
<label htmlFor={`variation-${variation.id}`}>{variation.name}</label>
|
<label htmlFor={`variation-${variation.id}`}>{name}</label>
|
||||||
<MultiSelectDropdown
|
<MultiSelectDropdown
|
||||||
id={`variation-${variation.id}`}
|
id={`variation-${variation.id}`}
|
||||||
options={variation.options.map((option) => ({
|
options={variation.options.map((option) => ({
|
||||||
value: option.id,
|
value: option.id,
|
||||||
label: option.label,
|
label: localizeVariationOptionLabel(option.label, t),
|
||||||
}))}
|
}))}
|
||||||
value={variation.selectedOptionIds}
|
value={variation.selectedOptionIds}
|
||||||
onChange={(optionIds) => handleSelectionChange(variation.id, optionIds)}
|
onChange={(optionIds) =>
|
||||||
placeholder={`Select ${variation.name.toLowerCase()} values`}
|
handleSelectionChange(variation.id, optionIds)
|
||||||
|
}
|
||||||
|
placeholder={t('products.variants.selectValues', { name })}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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'
|
||||||
@@ -78,6 +80,9 @@ function isGroupActive(group: NavGroup, pathname: string) {
|
|||||||
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,
|
|
||||||
locale: 'fa' | 'en' = 'en',
|
|
||||||
) {
|
|
||||||
if (locale === 'fa') {
|
|
||||||
return `برای دستهبندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
|
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,7 +60,6 @@ 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
|
||||||
|
|
||||||
@@ -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,7 +62,6 @@ 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
|
||||||
|
|
||||||
@@ -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,7 +69,6 @@ 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
|
||||||
|
|
||||||
@@ -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,12 +403,15 @@ 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}>
|
||||||
|
<div className={styles.logosPair}>
|
||||||
|
<div className={`${formStyles.field} ${styles.logoField}`}>
|
||||||
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
|
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
|
||||||
<ImageCropper
|
<ImageCropper
|
||||||
compact
|
compact
|
||||||
value={logo}
|
value={logo}
|
||||||
onChange={setLogo}
|
onChange={setLogo}
|
||||||
|
aspect={selectedLogoAspect}
|
||||||
outputFormat="png"
|
outputFormat="png"
|
||||||
accept="image/png"
|
accept="image/png"
|
||||||
uploadLabel={t('bizProfile.logo.upload')}
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
@@ -392,12 +420,13 @@ export function BusinessProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
||||||
|
aspect={selectedLogoAspect}
|
||||||
outputFormat="png"
|
outputFormat="png"
|
||||||
accept="image/png"
|
accept="image/png"
|
||||||
uploadLabel={t('bizProfile.logo.upload')}
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
@@ -405,6 +434,30 @@ export function BusinessProfilePage() {
|
|||||||
changeLabel={t('bizProfile.logo.change')}
|
changeLabel={t('bizProfile.logo.change')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={formStyles.aspectSwitch}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('bizProfile.logo.aspectAria')}
|
||||||
|
>
|
||||||
|
{LOGO_ASPECTS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={logoAspect === option.id}
|
||||||
|
className={`${formStyles.aspectSwitchBtn} ${
|
||||||
|
logoAspect === option.id ? formStyles.aspectSwitchBtnSelected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleLogoAspectChange(option.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{option.id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.faviconCol}`}>
|
<div className={`${formStyles.field} ${styles.faviconCol}`}>
|
||||||
<label className={styles.mediaFieldLabel}>{t('bizProfile.favicon')}</label>
|
<label className={styles.mediaFieldLabel}>{t('bizProfile.favicon')}</label>
|
||||||
@@ -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)
|
||||||
@@ -548,10 +576,38 @@ export function CategoriesPage() {
|
|||||||
actionLabel={t('categories.add')}
|
actionLabel={t('categories.add')}
|
||||||
onAction={() => openCreateModal()}
|
onAction={() => openCreateModal()}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.listSection}>
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<Search size={20} className={styles.searchIcon} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className={styles.searchInput}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('categories.searchPlaceholder')}
|
||||||
|
aria-label={t('categories.search')}
|
||||||
|
/>
|
||||||
|
{searchQuery ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
aria-label={t('categories.searchClear')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredCategories.length === 0 ? (
|
||||||
|
<div className={styles.list}>
|
||||||
|
<p className={styles.empty}>{t('categories.searchEmpty')}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
<CategoryTree
|
<CategoryTree
|
||||||
categories={categories}
|
categories={filteredCategories}
|
||||||
expandedIds={expandedIds}
|
expandedIds={expandedIds}
|
||||||
variationCounts={variationCountsMap()}
|
variationCounts={variationCountsMap()}
|
||||||
technicalFieldCounts={technicalFieldCountsMap()}
|
technicalFieldCounts={technicalFieldCountsMap()}
|
||||||
@@ -575,6 +631,8 @@ export function CategoriesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<CategoryModal
|
<CategoryModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user