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:
co-authored by
Cursor
parent
7b51dda82a
commit
832ac32a09
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')] },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,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`
|
||||
|
||||
Reference in New Issue
Block a user