Add store-product Excel bulk edit and polish the business dashboard.

Export/import price and stock from Store Products, keep card order after saves, localize variations, and drop unused website nav items.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 01:02:43 +03:30
co-authored by Cursor
parent 7b51dda82a
commit 832ac32a09
16 changed files with 617 additions and 181 deletions
-6
View File
@@ -67,10 +67,7 @@ import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage'
import { WebsiteFaqPage } from './pages/WebsiteFaqPage'
import { WebsiteBadgesPage } from './pages/WebsiteBadgesPage'
import { SettingsPage } from './pages/SettingsPage'
import { WebsiteSlidersPage } from './pages/WebsiteSlidersPage'
import { WebsiteStaticImagesPage } from './pages/WebsiteStaticImagesPage'
import { WebsiteSpecialCategoriesPage } from './pages/WebsiteSpecialCategoriesPage'
import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage'
function App() {
return (
@@ -150,9 +147,6 @@ function App() {
<Route path="instructions/settings" element={<InstructionSettingsPage />} />
<Route path="website" element={<WebsitePage />} />
<Route path="website/static-images" element={<WebsiteStaticImagesPage />} />
<Route path="website/sliders" element={<WebsiteSlidersPage />} />
<Route path="website/special-categories" element={<WebsiteSpecialCategoriesPage />} />
<Route path="website/special-brands" element={<WebsiteSpecialBrandsPage />} />
<Route path="website/special-items" element={<StoreSpecialsPage />} />
<Route path="website/contact" element={<WebsiteContactPage />} />
<Route path="website/subscriptions" element={<WebsiteSubscriptionsPage />} />
@@ -116,6 +116,8 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
cellNumber: created.cellNumber,
firstName: created.firstName,
lastName: created.lastName,
firstNameEn: created.firstNameEn,
lastNameEn: created.lastNameEn,
email: created.email,
label: created.label,
createdAt: created.createdAt,
@@ -1,8 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import type { Variation, VariationType } from '../types/variation'
import { COLOR_OPTIONS, SIZE_OPTIONS } from '../types/variation'
import { Tooltip } from './Tooltip'
import styles from './VariationsModal.module.css'
interface AddVariationModalProps {
@@ -14,6 +18,9 @@ interface AddVariationModalProps {
const ANIMATION_MS = 220
export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [type, setType] = useState<VariationType>('color')
@@ -98,11 +105,11 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
e.preventDefault()
if (type === 'color') {
onSubmit({ name: 'Color', type: 'color', values: selectedColors })
onSubmit({ name: t('categories.variations.type.color'), type: 'color', values: selectedColors })
} else if (type === 'size') {
onSubmit({ name: 'Size', type: 'size', values: selectedSizes })
onSubmit({ name: t('categories.variations.type.size'), type: 'size', values: selectedSizes })
} else {
const name = customName.trim() || 'Custom'
const name = customName.trim() || t('categories.variations.type.custom')
const values = customValues.map((v) => v.trim()).filter(Boolean)
onSubmit({ name, type: 'custom', values })
}
@@ -126,33 +133,35 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
role="dialog"
aria-modal="true"
aria-labelledby="add-variation-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="add-variation-title" className={styles.title}>
Add Variation
{t('categories.variations.addTitle')}
</h3>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<button className={styles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
<X size={20} />
</button>
</div>
<form className={styles.body} onSubmit={handleSubmit}>
<div className={styles.field}>
<label htmlFor="variationType">Type</label>
<label htmlFor="variationType">{t('categories.variations.type')}</label>
<select
id="variationType"
value={type}
onChange={(e) => setType(e.target.value as VariationType)}
>
<option value="color">Color</option>
<option value="size">Size</option>
<option value="custom">Your own</option>
<option value="color">{t('categories.variations.type.color')}</option>
<option value="size">{t('categories.variations.type.size')}</option>
<option value="custom">{t('categories.variations.type.custom')}</option>
</select>
</div>
{type === 'color' && (
<div className={styles.field}>
<label>Select colors</label>
<label>{t('categories.variations.selectColors')}</label>
<div className={styles.chipGrid}>
{COLOR_OPTIONS.map((color) => (
<button
@@ -161,7 +170,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
className={`${styles.chip} ${selectedColors.includes(color) ? styles.chipSelected : ''}`}
onClick={() => toggleOption(color, selectedColors, setSelectedColors)}
>
{color}
{t(`categories.variations.color.${color}` as BusinessMessageKey)}
</button>
))}
</div>
@@ -170,7 +179,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
{type === 'size' && (
<div className={styles.field}>
<label>Select sizes</label>
<label>{t('categories.variations.selectSizes')}</label>
<div className={styles.chipGrid}>
{SIZE_OPTIONS.map((size) => (
<button
@@ -189,17 +198,17 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
{type === 'custom' && (
<>
<div className={styles.field}>
<label htmlFor="customName">Variation name</label>
<label htmlFor="customName">{t('categories.variations.name')}</label>
<input
id="customName"
type="text"
placeholder="e.g. Guarantee, Material..."
placeholder={t('categories.variations.namePlaceholder')}
value={customName}
onChange={(e) => setCustomName(e.target.value)}
/>
</div>
<div className={styles.field}>
<label>Values</label>
<label>{t('categories.variations.values')}</label>
<div className={styles.customValues}>
{customValues.map((value, index) => (
<div key={index} className={styles.customRow}>
@@ -208,26 +217,28 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
valueInputRefs.current[index] = el
}}
type="text"
placeholder={`Value ${index + 1}`}
placeholder={t('categories.variations.valueN', { n: index + 1 })}
value={value}
onChange={(e) => updateCustomValue(index, e.target.value)}
onKeyDown={(e) => handleValueKeyDown(index, e)}
/>
{customValues.length > 1 && (
<button
type="button"
className={styles.removeValueBtn}
onClick={() => removeCustomRow(index)}
aria-label="Remove value"
>
<X size={16} />
</button>
<Tooltip label={t('categories.variations.removeValue')}>
<button
type="button"
className={styles.removeValueBtn}
onClick={() => removeCustomRow(index)}
aria-label={t('categories.variations.removeValue')}
>
<X size={16} />
</button>
</Tooltip>
)}
</div>
))}
<button type="button" className={styles.addValueBtn} onClick={addCustomRow}>
<Plus size={14} />
Add value
{t('categories.variations.addValue')}
</button>
</div>
</div>
@@ -236,10 +247,10 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Cancel
{t('categories.modal.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={!canSubmit}>
Add
{t('categories.variations.submit')}
</button>
</div>
</form>
@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
import { updateCustomer, type BusinessCustomerListItem } from '../services/customerService'
import formStyles from './AddCustomerModal.module.css'
import modalStyles from './VariationsModal.module.css'
interface EditCustomerModalProps {
@@ -21,10 +24,15 @@ export function EditCustomerModal({
onClose,
onSaved,
}: EditCustomerModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [firstNameEn, setFirstNameEn] = useState('')
const [lastNameEn, setLastNameEn] = useState('')
const [cellNumber, setCellNumber] = useState('')
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -36,6 +44,8 @@ export function EditCustomerModal({
setClosing(false)
setFirstName(customer.firstName ?? '')
setLastName(customer.lastName ?? '')
setFirstNameEn(customer.firstNameEn ?? '')
setLastNameEn(customer.lastNameEn ?? '')
setCellNumber(formatCellForDisplay(customer.cellNumber))
setEmail(customer.email ?? '')
setError('')
@@ -71,12 +81,24 @@ export function EditCustomerModal({
const normalizedCell = toE164CellNumber(cellNumber.trim())
if (!normalizedCell) return
const trimmedFirstEn = firstNameEn.trim()
const trimmedLastEn = lastNameEn.trim()
if (
(trimmedFirstEn && trimmedFirstEn.length < 2) ||
(trimmedLastEn && trimmedLastEn.length < 2)
) {
setError(t('customers.addModal.errorNameEnLength'))
return
}
setIsSubmitting(true)
setError('')
try {
const updated = await updateCustomer(customer.id, {
firstName: firstName.trim(),
lastName: lastName.trim(),
firstNameEn: trimmedFirstEn,
lastNameEn: trimmedLastEn,
cellNumber: normalizedCell,
email: email.trim(),
})
@@ -84,6 +106,8 @@ export function EditCustomerModal({
...customer,
firstName: updated.firstName,
lastName: updated.lastName,
firstNameEn: updated.firstNameEn,
lastNameEn: updated.lastNameEn,
cellNumber: updated.cellNumber,
email: updated.email,
label: updated.label,
@@ -91,7 +115,7 @@ export function EditCustomerModal({
})
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update customer.')
setError(err instanceof ApiError ? err.message : t('customers.editModal.error'))
} finally {
setIsSubmitting(false)
}
@@ -103,20 +127,22 @@ export function EditCustomerModal({
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
className={`${modalStyles.modal} ${formStyles.wideModal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="edit-customer-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={modalStyles.header}>
<div>
<h3 id="edit-customer-title" className={modalStyles.title}>
Edit customer
{t('customers.edit')}
</h3>
<p className={modalStyles.subtitle}>Update customer contact details.</p>
<p className={modalStyles.subtitle}>{t('customers.editModal.subtitle')}</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
<X size={18} />
</button>
</div>
@@ -124,46 +150,76 @@ export function EditCustomerModal({
<div className={modalStyles.body}>
{error && <p className={modalStyles.errorText}>{error}</p>}
<div className={modalStyles.field}>
<label htmlFor="edit-customer-first-name">First name</label>
<input
id="edit-customer-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-last-name">Last name</label>
<input
id="edit-customer-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-cell">Cell number</label>
<input
id="edit-customer-cell"
value={cellNumber}
onChange={(e) => setCellNumber(e.target.value)}
autoComplete="off"
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-email">Email (optional)</label>
<input
id="edit-customer-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
/>
<div className={formStyles.formGrid}>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-first-name">{t('customers.addModal.firstNameFa')}</label>
<input
id="edit-customer-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isSubmitting}
dir="rtl"
lang="fa"
className="faText"
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-last-name">{t('customers.addModal.lastNameFa')}</label>
<input
id="edit-customer-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isSubmitting}
dir="rtl"
lang="fa"
className="faText"
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-first-name-en">{t('customers.addModal.firstNameEn')}</label>
<input
id="edit-customer-first-name-en"
value={firstNameEn}
onChange={(e) => setFirstNameEn(e.target.value)}
disabled={isSubmitting}
dir="ltr"
lang="en"
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-last-name-en">{t('customers.addModal.lastNameEn')}</label>
<input
id="edit-customer-last-name-en"
value={lastNameEn}
onChange={(e) => setLastNameEn(e.target.value)}
disabled={isSubmitting}
dir="ltr"
lang="en"
/>
</div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
<label htmlFor="edit-customer-cell">{t('customers.addModal.cell')}</label>
<input
id="edit-customer-cell"
value={cellNumber}
onChange={(e) => setCellNumber(e.target.value)}
autoComplete="off"
disabled={isSubmitting}
inputMode="tel"
dir="ltr"
/>
</div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
<label htmlFor="edit-customer-email">{t('customers.editModal.email')}</label>
<input
id="edit-customer-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
dir="ltr"
/>
</div>
</div>
<div className={modalStyles.actions}>
@@ -173,7 +229,7 @@ export function EditCustomerModal({
onClick={onClose}
disabled={isSubmitting}
>
Cancel
{t('customers.addModal.cancel')}
</button>
<button
type="button"
@@ -181,13 +237,12 @@ export function EditCustomerModal({
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSave}
>
Save
{t('customers.editModal.save')}
</button>
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -35,6 +35,7 @@
grid-column: span 2;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
@@ -121,7 +121,6 @@
.navItem.active {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
border-color: var(--primary);
}
.navItem.active:hover {
@@ -180,7 +179,6 @@
.subNavActive {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
border-color: var(--primary);
}
.subNavActive:hover {
-3
View File
@@ -209,9 +209,6 @@ export function Sidebar({ open = false }: SidebarProps) {
children: [
{ labelKey: 'nav.website.overview', to: '/website' },
{ labelKey: 'nav.website.staticImages', to: '/website/static-images' },
{ labelKey: 'nav.website.sliders', to: '/website/sliders' },
{ labelKey: 'nav.website.specialCategories', to: '/website/special-categories' },
{ labelKey: 'nav.website.specialBrands', to: '/website/special-brands' },
{ labelKey: 'nav.website.specialItems', to: '/website/special-items' },
{ labelKey: 'nav.website.contact', to: '/website/contact' },
{ labelKey: 'nav.website.subscriptions', to: '/website/subscriptions' },
@@ -1,10 +1,15 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import type { Variation, VariationType } from '../types/variation'
import { COLOR_OPTIONS } from '../types/variation'
import { createId } from '../utils/id'
import { textLocaleAttrs } from '../utils/textLocale'
import { AddVariationModal } from './AddVariationModal'
import { Tooltip } from './Tooltip'
import styles from './VariationsModal.module.css'
interface VariationsModalProps {
@@ -28,6 +33,9 @@ export function VariationsModal({
onChange,
onSave,
}: VariationsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [addOpen, setAddOpen] = useState(false)
@@ -74,9 +82,23 @@ export function VariationsModal({
}
function typeLabel(type: VariationType) {
if (type === 'color') return 'Color'
if (type === 'size') return 'Size'
return 'Custom'
if (type === 'color') return t('categories.variations.type.color')
if (type === 'size') return t('categories.variations.type.size')
return t('categories.variations.type.custom')
}
function variationTitle(variation: Variation) {
if (variation.type === 'color' || variation.type === 'size') {
return typeLabel(variation.type)
}
return variation.name
}
function localizeValue(value: string) {
if ((COLOR_OPTIONS as string[]).includes(value)) {
return t(`categories.variations.color.${value}` as BusinessMessageKey)
}
return value
}
return createPortal(
@@ -91,11 +113,13 @@ export function VariationsModal({
role="dialog"
aria-modal="true"
aria-labelledby="variations-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div>
<h3 id="variations-modal-title" className={styles.title}>
Variations
{t('categories.variations.title')}
</h3>
{categoryName && (
<p
@@ -107,7 +131,7 @@ export function VariationsModal({
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<button className={styles.closeBtn} onClick={onClose} aria-label={t('common.close')}>
<X size={20} />
</button>
</div>
@@ -115,8 +139,10 @@ export function VariationsModal({
<div className={styles.body}>
<div className={styles.list}>
{list.map((variation) => {
const nameLocale = textLocaleAttrs(variation.name)
const valuesText = variation.values.join(' · ') || 'No values'
const title = variationTitle(variation)
const nameLocale = textLocaleAttrs(title)
const valuesText =
variation.values.map(localizeValue).join(' · ') || t('categories.variations.noValues')
const valuesLocale = textLocaleAttrs(valuesText)
return (
<div key={variation.id} className={styles.listItem}>
@@ -126,7 +152,7 @@ export function VariationsModal({
lang={nameLocale.lang}
dir={nameLocale.dir}
>
{variation.name}
{title}
</div>
<div
className={[styles.listItemValues, valuesLocale.className].filter(Boolean).join(' ')}
@@ -138,34 +164,36 @@ export function VariationsModal({
</div>
<div className={styles.listItemEnd}>
<span className={styles.listItemType}>{typeLabel(variation.type)}</span>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => handleRemove(variation.id)}
aria-label={`Remove ${variation.name}`}
>
<X size={16} />
</button>
<Tooltip label={t('categories.variations.remove')}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => handleRemove(variation.id)}
aria-label={t('categories.variations.remove')}
>
<X size={16} />
</button>
</Tooltip>
</div>
</div>
)
})}
{!list.length && (
<p className={styles.emptyText}>No variations yet. Add color, size, or custom options.</p>
<p className={styles.emptyText}>{t('categories.variations.empty')}</p>
)}
</div>
<button type="button" className={styles.addBtn} onClick={() => setAddOpen(true)}>
<Plus size={18} />
Add Variation
{t('categories.variations.add')}
</button>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSaving}>
Cancel
{t('categories.modal.cancel')}
</button>
<button type="button" className={styles.submitBtn} onClick={onSave} disabled={isSaving}>
{isSaving ? 'Saving…' : 'Save variations'}
{isSaving ? t('categories.variations.saving') : t('categories.variations.save')}
</button>
</div>
</div>
+84 -6
View File
@@ -575,6 +575,36 @@ const en = {
'categories.select.empty': 'No categories found',
'categories.select.clear': 'Clear selection',
'categories.variations.title': 'Variations',
'categories.variations.empty': 'No variations yet. Add color, size, or custom options.',
'categories.variations.add': 'Add variation',
'categories.variations.save': 'Save variations',
'categories.variations.saving': 'Saving…',
'categories.variations.noValues': 'No values',
'categories.variations.remove': 'Remove variation',
'categories.variations.addTitle': 'Add variation',
'categories.variations.type': 'Type',
'categories.variations.type.color': 'Color',
'categories.variations.type.size': 'Size',
'categories.variations.type.custom': 'Custom',
'categories.variations.selectColors': 'Select colors',
'categories.variations.selectSizes': 'Select sizes',
'categories.variations.name': 'Variation name',
'categories.variations.namePlaceholder': 'e.g. Guarantee, Material',
'categories.variations.values': 'Values',
'categories.variations.valueN': 'Value {n}',
'categories.variations.addValue': 'Add value',
'categories.variations.removeValue': 'Remove value',
'categories.variations.submit': 'Add',
'categories.variations.color.Red': 'Red',
'categories.variations.color.Blue': 'Blue',
'categories.variations.color.Green': 'Green',
'categories.variations.color.Black': 'Black',
'categories.variations.color.White': 'White',
'categories.variations.color.Silver': 'Silver',
'categories.variations.color.Gold': 'Gold',
'categories.variations.color.Navy': 'Navy',
'categories.technical.title': 'Technical Data Form',
'categories.technical.loading': 'Loading form...',
'categories.technical.empty':
@@ -665,6 +695,14 @@ const en = {
'storeItems.onlyDiscounted': 'Only discounted',
'storeItems.search': 'Search',
'storeItems.clearFilters': 'Clear filters',
'storeItems.excel.export': 'Export Excel',
'storeItems.excel.import': 'Import Excel',
'storeItems.excel.exporting': 'Exporting Excel…',
'storeItems.excel.importing': 'Updating prices and stock…',
'storeItems.excel.exportError': 'Unable to export Excel.',
'storeItems.excel.importError': 'Unable to import Excel.',
'storeItems.excel.importSuccess': 'Updated {updated} variants. Unchanged: {unchanged}.',
'storeItems.excel.importEmpty': 'Choose an .xlsx file exported from this page.',
'storeItems.loading': 'Loading store items...',
'storeItems.empty': "You don't have any products for sale yet.",
'storeItems.emptyHint': 'Add products from your catalog to start selling.',
@@ -859,6 +897,10 @@ const en = {
'customers.enableAria': 'Enable {name}',
'customers.disableAria': 'Disable {name}',
'customers.edit': 'Edit customer',
'customers.editModal.subtitle': 'Update customer contact details.',
'customers.editModal.email': 'Email (optional)',
'customers.editModal.save': 'Save',
'customers.editModal.error': 'Unable to update customer.',
'customers.sms': 'Send SMS',
'customers.tickets': 'Tickets',
'customers.invoices': 'View invoices',
@@ -2514,6 +2556,36 @@ const fa: Record<MessageKey, string> = {
'categories.select.empty': 'دسته‌بندی‌ای یافت نشد',
'categories.select.clear': 'پاک کردن انتخاب',
'categories.variations.title': 'تنوع‌ها',
'categories.variations.empty': 'هنوز تنوعی نیست. رنگ، سایز یا گزینه دلخواه اضافه کنید.',
'categories.variations.add': 'افزودن تنوع',
'categories.variations.save': 'ذخیره تنوع‌ها',
'categories.variations.saving': 'در حال ذخیره…',
'categories.variations.noValues': 'بدون مقدار',
'categories.variations.remove': 'حذف تنوع',
'categories.variations.addTitle': 'افزودن تنوع',
'categories.variations.type': 'نوع',
'categories.variations.type.color': 'رنگ',
'categories.variations.type.size': 'سایز',
'categories.variations.type.custom': 'دلخواه',
'categories.variations.selectColors': 'انتخاب رنگ‌ها',
'categories.variations.selectSizes': 'انتخاب سایزها',
'categories.variations.name': 'نام تنوع',
'categories.variations.namePlaceholder': 'مثلاً گارانتی، جنس',
'categories.variations.values': 'مقادیر',
'categories.variations.valueN': 'مقدار {n}',
'categories.variations.addValue': 'افزودن مقدار',
'categories.variations.removeValue': 'حذف مقدار',
'categories.variations.submit': 'افزودن',
'categories.variations.color.Red': 'قرمز',
'categories.variations.color.Blue': 'آبی',
'categories.variations.color.Green': 'سبز',
'categories.variations.color.Black': 'مشکی',
'categories.variations.color.White': 'سفید',
'categories.variations.color.Silver': 'نقره‌ای',
'categories.variations.color.Gold': 'طلایی',
'categories.variations.color.Navy': 'سرمه‌ای',
'categories.technical.title': 'فرم اطلاعات فنی',
'categories.technical.loading': 'در حال بارگذاری فرم...',
'categories.technical.empty':
@@ -2604,6 +2676,14 @@ const fa: Record<MessageKey, string> = {
'storeItems.onlyDiscounted': 'فقط تخفیف‌دار',
'storeItems.search': 'جستجو',
'storeItems.clearFilters': 'پاک کردن فیلترها',
'storeItems.excel.export': 'خروجی اکسل',
'storeItems.excel.import': 'ورود اکسل',
'storeItems.excel.exporting': 'در حال خروجی اکسل…',
'storeItems.excel.importing': 'در حال به‌روزرسانی قیمت و موجودی…',
'storeItems.excel.exportError': 'خروجی اکسل ممکن نشد.',
'storeItems.excel.importError': 'ورود اکسل ممکن نشد.',
'storeItems.excel.importSuccess': '{updated} تنوع به‌روز شد. بدون تغییر: {unchanged}.',
'storeItems.excel.importEmpty': 'یک فایل xlsx که از این صفحه گرفته‌اید انتخاب کنید.',
'storeItems.loading': 'در حال بارگذاری محصولات فروشگاه...',
'storeItems.empty': 'هنوز هیچ محصولی برای فروش ندارید',
'storeItems.emptyHint': 'از کاتالوگ محصول اضافه کنید.',
@@ -2798,6 +2878,10 @@ const fa: Record<MessageKey, string> = {
'customers.enableAria': 'فعال‌سازی {name}',
'customers.disableAria': 'غیرفعال‌سازی {name}',
'customers.edit': 'ویرایش مشتری',
'customers.editModal.subtitle': 'اطلاعات تماس مشتری را به‌روزرسانی کنید.',
'customers.editModal.email': 'ایمیل (اختیاری)',
'customers.editModal.save': 'ذخیره',
'customers.editModal.error': 'به‌روزرسانی مشتری ممکن نشد.',
'customers.sms': 'ارسال پیامک',
'customers.tickets': 'تیکت‌ها',
'customers.invoices': 'مشاهده فاکتورها',
@@ -4045,12 +4129,6 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/portfolios/settings', labels: [t('title.portfolios'), t('title.settings')] },
{ match: '/portfolios', labels: [t('title.portfolios')] },
{ match: '/website/static-images', labels: [t('title.website'), t('title.staticImages')] },
{ match: '/website/sliders', labels: [t('title.website'), t('title.sliders')] },
{
match: '/website/special-categories',
labels: [t('title.website'), t('title.specialCategories')],
},
{ match: '/website/special-brands', labels: [t('title.website'), t('title.specialBrands')] },
{ match: '/website/special-items', labels: [t('title.website'), t('title.specialItems')] },
{ match: '/website/contact', labels: [t('title.website'), t('title.contactForm')] },
{ match: '/website/subscriptions', labels: [t('title.website'), t('title.subscriptions')] },
+95
View File
@@ -120,3 +120,98 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
return payload as T
}
function filenameFromDisposition(header: string | null) {
if (!header) return null
const utf = header.match(/filename\*=UTF-8''([^;]+)/i)
if (utf?.[1]) {
try {
return decodeURIComponent(utf[1])
} catch {
return utf[1]
}
}
const plain = header.match(/filename="?([^"]+)"?/i)
return plain?.[1] ?? null
}
export async function apiRequestBlob(
path: string,
options: { auth?: boolean; signal?: AbortSignal } = {},
): Promise<{ blob: Blob; filename: string | null }> {
const { auth = false, signal } = options
const requestHeaders = new Headers()
if (auth) {
const accessToken = getAccessToken()
if (accessToken) {
requestHeaders.set('Authorization', `Bearer ${accessToken}`)
}
}
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: requestHeaders,
signal,
})
if (response.status === 401 && auth) {
const refreshed = await refreshAccessToken()
if (refreshed) {
return apiRequestBlob(path, options)
}
}
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new ApiError(
parseErrorMessage(payload, `Request failed with status ${response.status}`),
response.status,
)
}
return {
blob: await response.blob(),
filename: filenameFromDisposition(response.headers.get('Content-Disposition')),
}
}
export async function apiRequestForm<T>(
path: string,
formData: FormData,
options: { method?: string; auth?: boolean; signal?: AbortSignal } = {},
): Promise<T> {
const { method = 'POST', auth = false, signal } = options
const requestHeaders = new Headers()
if (auth) {
const accessToken = getAccessToken()
if (accessToken) {
requestHeaders.set('Authorization', `Bearer ${accessToken}`)
}
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: requestHeaders,
body: formData,
signal,
})
if (response.status === 401 && auth) {
const refreshed = await refreshAccessToken()
if (refreshed) {
return apiRequestForm<T>(path, formData, options)
}
}
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new ApiError(
parseErrorMessage(payload, `Request failed with status ${response.status}`),
response.status,
)
}
return payload as T
}
@@ -100,6 +100,16 @@ button.emptyCta {
border-radius: var(--radius-sm);
}
.spin {
animation: storeItemsSpin 0.8s linear infinite;
}
@keyframes storeItemsSpin {
to {
transform: rotate(360deg);
}
}
.pagination {
margin-top: 20px;
padding: 0 4px;
+134 -22
View File
@@ -1,12 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Package, Plus, RotateCcw, Search } from 'lucide-react'
import { Download, Loader2, Package, Plus, RotateCcw, Search, Upload } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { CreateStoreItemsModal } from '../components/CreateStoreItemsModal'
import { EditStoreItemsModal } from '../components/EditStoreItemsModal'
import { Pagination } from '../components/Pagination'
import { Tooltip } from '../components/Tooltip'
import { PickStoreVariantModal } from '../components/PickStoreVariantModal'
import { ShoppingCartModal } from '../components/ShoppingCartModal'
import { StoreItemCard } from '../components/StoreItemCard'
@@ -18,6 +19,8 @@ import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
deleteStoreItemsByProduct,
downloadStoreItemsExcel,
importStoreItemsExcel,
listAllStoreItems,
STORE_ITEMS_PER_PAGE,
type StoreItem,
@@ -75,6 +78,9 @@ function StoreItemsPageContent() {
const [appliedFilters, setAppliedFilters] = useState<StoreListingFilters>(
EMPTY_STORE_LISTING_FILTERS,
)
const [excelBusy, setExcelBusy] = useState<'export' | 'import' | null>(null)
const [productOrder, setProductOrder] = useState<string[]>([])
const importInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (hasItems) {
@@ -102,7 +108,10 @@ function StoreItemsPageContent() {
}
}, [hasItems, loadShoppingCard, showToast])
const listings = useMemo(() => groupStoreItemsByProduct(items), [items])
const listings = useMemo(
() => groupStoreItemsByProduct(items, productOrder),
[items, productOrder],
)
const filteredListings = useMemo(
() => filterStoreListings(listings, appliedFilters),
[listings, appliedFilters],
@@ -177,6 +186,7 @@ function StoreItemsPageContent() {
try {
const data = await listAllStoreItems(signal)
setItems(data)
setProductOrder(groupStoreItemsByProduct(data).map((listing) => listing.productId))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
@@ -197,6 +207,10 @@ function StoreItemsPageContent() {
const withoutProduct = prev.filter((item) => item.productId !== productId)
return updated.length > 0 ? [...withoutProduct, ...updated] : withoutProduct
})
if (updated.length === 0) {
setProductOrder((prev) => prev.filter((id) => id !== productId))
}
}
function mergeUpdatedItems(updated: StoreItem[]) {
@@ -244,6 +258,64 @@ function StoreItemsPageContent() {
showToast(t('storeItems.addedToCart'), 'success')
}
async function handleExportExcel() {
if (excelBusy) return
setExcelBusy('export')
setError('')
try {
await downloadStoreItemsExcel()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('storeItems.excel.exportError'))
}
} finally {
setExcelBusy(null)
}
}
function openImportPicker() {
if (excelBusy) return
importInputRef.current?.click()
}
async function handleImportExcel(fileList: FileList | null) {
const file = fileList?.[0]
if (importInputRef.current) {
importInputRef.current.value = ''
}
if (!file) return
const name = file.name.toLowerCase()
if (!name.endsWith('.xlsx')) {
setError(t('storeItems.excel.importEmpty'))
return
}
setExcelBusy('import')
setError('')
try {
const result = await importStoreItemsExcel(file)
showToast(
t('storeItems.excel.importSuccess', {
updated: result.updated,
unchanged: result.unchanged,
}),
'success',
)
await loadItems()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('storeItems.excel.importError'))
}
} finally {
setExcelBusy(null)
}
}
async function confirmDelete() {
if (!deleteTarget) return
@@ -253,6 +325,7 @@ function StoreItemsPageContent() {
try {
await deleteStoreItemsByProduct(deleteTarget.productId)
setItems((prev) => prev.filter((item) => item.productId !== deleteTarget.productId))
setProductOrder((prev) => prev.filter((id) => id !== deleteTarget.productId))
setDeleteTarget(null)
const nextTotal = Math.max(0, totalListings - 1)
@@ -342,25 +415,64 @@ function StoreItemsPageContent() {
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
disabled={isLoading}
aria-label={t('storeItems.search')}
title={t('storeItems.search')}
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={isLoading}
aria-label={t('storeItems.clearFilters')}
title={t('storeItems.clearFilters')}
>
<RotateCcw size={18} />
</button>
<input
ref={importInputRef}
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
onChange={(e) => void handleImportExcel(e.target.files)}
/>
<Tooltip label={t('storeItems.excel.export')}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={() => void handleExportExcel()}
disabled={isLoading || !!excelBusy || listings.length === 0}
aria-label={t('storeItems.excel.export')}
>
{excelBusy === 'export' ? (
<Loader2 size={18} className={styles.spin} />
) : (
<Download size={18} />
)}
</button>
</Tooltip>
<Tooltip label={t('storeItems.excel.import')}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={openImportPicker}
disabled={isLoading || !!excelBusy}
aria-label={t('storeItems.excel.import')}
>
{excelBusy === 'import' ? (
<Loader2 size={18} className={styles.spin} />
) : (
<Upload size={18} />
)}
</button>
</Tooltip>
<Tooltip label={t('storeItems.search')}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
disabled={isLoading || !!excelBusy}
aria-label={t('storeItems.search')}
>
<Search size={18} />
</button>
</Tooltip>
<Tooltip label={t('storeItems.clearFilters')}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={isLoading || !!excelBusy}
aria-label={t('storeItems.clearFilters')}
>
<RotateCcw size={18} />
</button>
</Tooltip>
</div>
</form>
</div>
@@ -6,6 +6,8 @@ export interface BusinessCustomer {
cellNumber: string
firstName: string | null
lastName: string | null
firstNameEn?: string | null
lastNameEn?: string | null
email: string | null
label: string
}
@@ -68,6 +70,8 @@ export interface UpdateCustomerPayload {
isEnabled?: boolean
firstName?: string
lastName?: string
firstNameEn?: string
lastNameEn?: string
email?: string
cellNumber?: string
}
+45 -1
View File
@@ -1,4 +1,4 @@
import { apiRequest } from '../lib/api'
import { apiRequest, apiRequestBlob, apiRequestForm } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
/** Product cards per page on My Store Items (grid). */
@@ -167,3 +167,47 @@ export async function deleteStoreItemsByProduct(productId: string) {
auth: true,
})
}
export async function downloadStoreItemsExcel() {
const { blob } = await apiRequestBlob(businessPath('/export'), { auth: true })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = storeItemsExcelFilename()
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function storeItemsExcelFilename(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const pick = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? ''
return `store items - ${pick('year')}-${pick('month')}-${pick('day')} - ${pick('hour')}-${pick('minute')}.xlsx`
}
export interface StoreItemsExcelImportResult {
message: string
total: number
updated: number
unchanged: number
}
export async function importStoreItemsExcel(file: File) {
const formData = new FormData()
formData.append('file', file)
return apiRequestForm<StoreItemsExcelImportResult>(businessPath('/import'), formData, {
auth: true,
})
}
+48 -40
View File
@@ -63,7 +63,10 @@ function isVariantInStock(variant: StoreItem) {
return variant.stockQuantity === null || variant.stockQuantity > 0
}
export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListing[] {
export function groupStoreItemsByProduct(
items: StoreItem[],
productOrder?: string[],
): StoreProductListing[] {
const byProduct = new Map<string, StoreItem[]>()
for (const item of items) {
@@ -72,48 +75,53 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
byProduct.set(item.productId, variants)
}
return [...byProduct.values()]
.map((variants) => {
const sorted = [...variants].sort((a, b) => {
const aInStock = isVariantInStock(a)
const bInStock = isVariantInStock(b)
if (aInStock !== bInStock) return aInStock ? -1 : 1
const listings = [...byProduct.values()].map((variants) => {
const sorted = [...variants].sort((a, b) => {
const aInStock = isVariantInStock(a)
const bInStock = isVariantInStock(b)
if (aInStock !== bInStock) return aInStock ? -1 : 1
const updatedDiff = parseDateMs(b.updatedAt) - parseDateMs(a.updatedAt)
if (updatedDiff !== 0) return updatedDiff
return parseDateMs(b.createdAt) - parseDateMs(a.createdAt)
})
const representative = sorted[0]!
const { displayPrice, displayDiscountedPrice } = pickDisplayPrice(sorted)
return {
productId: representative.productId,
productTitle: representative.productTitle,
productNameFa: representative.productNameFa,
productImage: representative.productImage,
productTotalStock: representative.productTotalStock,
variantCount: sorted.length,
variants: sorted,
representative,
displayPrice,
displayDiscountedPrice,
showFestival: sorted.some(
(variant) => variant.isFestival || (variant.rewardPoints ?? 0) > 0,
),
}
const updatedDiff = parseDateMs(b.updatedAt) - parseDateMs(a.updatedAt)
if (updatedDiff !== 0) return updatedDiff
return parseDateMs(b.createdAt) - parseDateMs(a.createdAt)
})
.sort(
(a, b) => {
const aInStock = a.variants.some(isVariantInStock)
const bInStock = b.variants.some(isVariantInStock)
if (aInStock !== bInStock) return aInStock ? -1 : 1
return (
parseDateMs(b.representative.updatedAt) - parseDateMs(a.representative.updatedAt)
)
},
)
const representative = sorted[0]!
const { displayPrice, displayDiscountedPrice } = pickDisplayPrice(sorted)
return {
productId: representative.productId,
productTitle: representative.productTitle,
productNameFa: representative.productNameFa,
productImage: representative.productImage,
productTotalStock: representative.productTotalStock,
variantCount: sorted.length,
variants: sorted,
representative,
displayPrice,
displayDiscountedPrice,
showFestival: sorted.some(
(variant) => variant.isFestival || (variant.rewardPoints ?? 0) > 0,
),
}
})
if (productOrder && productOrder.length > 0) {
const indexById = new Map(productOrder.map((id, index) => [id, index]))
return listings.sort((a, b) => {
const ai = indexById.get(a.productId) ?? Number.MAX_SAFE_INTEGER
const bi = indexById.get(b.productId) ?? Number.MAX_SAFE_INTEGER
return ai - bi
})
}
return listings.sort((a, b) => {
const aInStock = a.variants.some(isVariantInStock)
const bInStock = b.variants.some(isVariantInStock)
if (aInStock !== bInStock) return aInStock ? -1 : 1
return parseDateMs(b.representative.updatedAt) - parseDateMs(a.representative.updatedAt)
})
}
export function formatVariantCount(count: number, locale: 'en' | 'fa' = 'en'): string {
+3 -4
View File
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes.
Last updated: August 16, 2026
Last updated: August 18, 2026
---
@@ -215,9 +215,6 @@ Add to `/etc/hosts` (one line per tenant):
| `/portfolios/settings` | Comment moderation | Yes |
| `/website` | Website hub | Placeholder |
| `/website/static-images` | Named website image slots, grouped by page | Yes |
| `/website/sliders` | Homepage sliders | Yes |
| `/website/special-categories` | Featured category groups | Yes |
| `/website/special-brands` | Featured brand groups | Yes |
| `/website/special-items` | Special item carousels | Yes |
| `/website/contact` | Contact us submissions list | Yes |
| `/website/subscriptions` | Subscriptions | Placeholder |
@@ -288,6 +285,8 @@ Products accept optional `brandId` on create/update.
### Store items (sellable product variants)
- `GET /businesses/:businessId/store-items` — paginated list
- `POST /businesses/:businessId/store-items` — batch create with price + stock
- `GET /businesses/:businessId/store-items/export` — Excel of every variant (id, names, variation, price, stock)
- `POST /businesses/:businessId/store-items/import` — upload that Excel to bulk-update price + stock
### Store specials
- `GET/POST/PATCH/DELETE /businesses/:businessId/store-specials`