From 832ac32a093b5783d641e115f988d78a79218152 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 19 Aug 2026 01:02:43 +0330 Subject: [PATCH] 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 --- apps/business/src/App.tsx | 6 - .../src/components/AddCustomerModal.tsx | 2 + .../src/components/AddVariationModal.tsx | 65 +++++--- .../src/components/EditCustomerModal.tsx | 155 +++++++++++------ .../components/ListFiltersPanel.module.css | 1 + .../src/components/Sidebar.module.css | 2 - apps/business/src/components/Sidebar.tsx | 3 - .../src/components/VariationsModal.tsx | 68 +++++--- apps/business/src/i18n/messages.ts | 90 +++++++++- apps/business/src/lib/api.ts | 95 +++++++++++ .../src/pages/StoreItemsPage.module.css | 10 ++ apps/business/src/pages/StoreItemsPage.tsx | 156 +++++++++++++++--- apps/business/src/services/customerService.ts | 4 + .../business/src/services/storeItemService.ts | 46 +++++- apps/business/src/utils/storeProductGroups.ts | 88 +++++----- docs/PROJECT_CONTEXT.md | 7 +- 16 files changed, 617 insertions(+), 181 deletions(-) diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 314520a..74c72f2 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -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() { } /> } /> } /> - } /> - } /> - } /> } /> } /> } /> diff --git a/apps/business/src/components/AddCustomerModal.tsx b/apps/business/src/components/AddCustomerModal.tsx index d3bdcd5..bc76d59 100644 --- a/apps/business/src/components/AddCustomerModal.tsx +++ b/apps/business/src/components/AddCustomerModal.tsx @@ -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, diff --git a/apps/business/src/components/AddVariationModal.tsx b/apps/business/src/components/AddVariationModal.tsx index 3f51c2b..8ea3d26 100644 --- a/apps/business/src/components/AddVariationModal.tsx +++ b/apps/business/src/components/AddVariationModal.tsx @@ -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('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'} >

- Add Variation + {t('categories.variations.addTitle')}

-
- +
{type === 'color' && (
- +
{COLOR_OPTIONS.map((color) => ( ))}
@@ -170,7 +179,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal {type === 'size' && (
- +
{SIZE_OPTIONS.map((size) => ( + + + )}
))}
@@ -236,10 +247,10 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
diff --git a/apps/business/src/components/EditCustomerModal.tsx b/apps/business/src/components/EditCustomerModal.tsx index 2589ba6..af1a804 100644 --- a/apps/business/src/components/EditCustomerModal.tsx +++ b/apps/business/src/components/EditCustomerModal.tsx @@ -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} >
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="edit-customer-title" + lang={isFa ? 'fa' : 'en'} + dir={isFa ? 'rtl' : 'ltr'} >

- Edit customer + {t('customers.edit')}

-

Update customer contact details.

+

{t('customers.editModal.subtitle')}

-
@@ -124,46 +150,76 @@ export function EditCustomerModal({
{error &&

{error}

} -
- - setFirstName(e.target.value)} - disabled={isSubmitting} - /> -
- -
- - setLastName(e.target.value)} - disabled={isSubmitting} - /> -
- -
- - setCellNumber(e.target.value)} - autoComplete="off" - disabled={isSubmitting} - /> -
- -
- - setEmail(e.target.value)} - disabled={isSubmitting} - /> +
+
+ + setFirstName(e.target.value)} + disabled={isSubmitting} + dir="rtl" + lang="fa" + className="faText" + /> +
+
+ + setLastName(e.target.value)} + disabled={isSubmitting} + dir="rtl" + lang="fa" + className="faText" + /> +
+
+ + setFirstNameEn(e.target.value)} + disabled={isSubmitting} + dir="ltr" + lang="en" + /> +
+
+ + setLastNameEn(e.target.value)} + disabled={isSubmitting} + dir="ltr" + lang="en" + /> +
+
+ + setCellNumber(e.target.value)} + autoComplete="off" + disabled={isSubmitting} + inputMode="tel" + dir="ltr" + /> +
+
+ + setEmail(e.target.value)} + disabled={isSubmitting} + dir="ltr" + /> +
@@ -173,7 +229,7 @@ export function EditCustomerModal({ onClick={onClose} disabled={isSubmitting} > - Cancel + {t('customers.addModal.cancel')}
-
- , - document.body, + , + document.body, ) } diff --git a/apps/business/src/components/ListFiltersPanel.module.css b/apps/business/src/components/ListFiltersPanel.module.css index a6d7666..7176f09 100644 --- a/apps/business/src/components/ListFiltersPanel.module.css +++ b/apps/business/src/components/ListFiltersPanel.module.css @@ -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; diff --git a/apps/business/src/components/Sidebar.module.css b/apps/business/src/components/Sidebar.module.css index 5c37f94..01977f6 100644 --- a/apps/business/src/components/Sidebar.module.css +++ b/apps/business/src/components/Sidebar.module.css @@ -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 { diff --git a/apps/business/src/components/Sidebar.tsx b/apps/business/src/components/Sidebar.tsx index 11db143..28fb750 100644 --- a/apps/business/src/components/Sidebar.tsx +++ b/apps/business/src/components/Sidebar.tsx @@ -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' }, diff --git a/apps/business/src/components/VariationsModal.tsx b/apps/business/src/components/VariationsModal.tsx index 217278c..c1d79e9 100644 --- a/apps/business/src/components/VariationsModal.tsx +++ b/apps/business/src/components/VariationsModal.tsx @@ -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'} >

- Variations + {t('categories.variations.title')}

{categoryName && (

)}

-
@@ -115,8 +139,10 @@ export function VariationsModal({
{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 (
@@ -126,7 +152,7 @@ export function VariationsModal({ lang={nameLocale.lang} dir={nameLocale.dir} > - {variation.name} + {title}
{typeLabel(variation.type)} - + + +
) })} {!list.length && ( -

No variations yet. Add color, size, or custom options.

+

{t('categories.variations.empty')}

)}
diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 7950c1c..e23ba9b 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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 = { '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 = { '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')] }, diff --git a/apps/business/src/lib/api.ts b/apps/business/src/lib/api.ts index 7bec715..1642b43 100644 --- a/apps/business/src/lib/api.ts +++ b/apps/business/src/lib/api.ts @@ -120,3 +120,98 @@ export async function apiRequest(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( + path: string, + formData: FormData, + options: { method?: string; auth?: boolean; signal?: AbortSignal } = {}, +): Promise { + 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(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 +} diff --git a/apps/business/src/pages/StoreItemsPage.module.css b/apps/business/src/pages/StoreItemsPage.module.css index 645718b..0571160 100644 --- a/apps/business/src/pages/StoreItemsPage.module.css +++ b/apps/business/src/pages/StoreItemsPage.module.css @@ -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; diff --git a/apps/business/src/pages/StoreItemsPage.tsx b/apps/business/src/pages/StoreItemsPage.tsx index 5f06479..5d22fd3 100644 --- a/apps/business/src/pages/StoreItemsPage.tsx +++ b/apps/business/src/pages/StoreItemsPage.tsx @@ -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( EMPTY_STORE_LISTING_FILTERS, ) + const [excelBusy, setExcelBusy] = useState<'export' | 'import' | null>(null) + const [productOrder, setProductOrder] = useState([]) + const importInputRef = useRef(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() {
- - + void handleImportExcel(e.target.files)} + /> + + + + + + + + + + + +
diff --git a/apps/business/src/services/customerService.ts b/apps/business/src/services/customerService.ts index c4974fe..e064db5 100644 --- a/apps/business/src/services/customerService.ts +++ b/apps/business/src/services/customerService.ts @@ -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 } diff --git a/apps/business/src/services/storeItemService.ts b/apps/business/src/services/storeItemService.ts index 232be7f..fdf3b6f 100644 --- a/apps/business/src/services/storeItemService.ts +++ b/apps/business/src/services/storeItemService.ts @@ -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(businessPath('/import'), formData, { + auth: true, + }) +} diff --git a/apps/business/src/utils/storeProductGroups.ts b/apps/business/src/utils/storeProductGroups.ts index 5bb946a..e8f13f8 100644 --- a/apps/business/src/utils/storeProductGroups.ts +++ b/apps/business/src/utils/storeProductGroups.ts @@ -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() 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 { diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index edfbb9f..9e8e17d 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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`