@@ -170,7 +179,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
{type === 'size' && (
-
+
{SIZE_OPTIONS.map((size) => (
,
+ 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)}
- handleRemove(variation.id)}
- aria-label={`Remove ${variation.name}`}
- >
-
-
+
+ handleRemove(variation.id)}
+ aria-label={t('categories.variations.remove')}
+ >
+
+
+
)
})}
{!list.length && (
-
No variations yet. Add color, size, or custom options.
+
{t('categories.variations.empty')}
)}
setAddOpen(true)}>
- Add Variation
+ {t('categories.variations.add')}
- Cancel
+ {t('categories.modal.cancel')}
- {isSaving ? 'Saving…' : 'Save variations'}
+ {isSaving ? t('categories.variations.saving') : t('categories.variations.save')}
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() {