Files
dashboards/apps/business/src/pages/StoreItemsPage.tsx
T
Alireza HassaniandCursor f8a9a28346 Add shared empty states, customer product filters, and SSO handoff across dashboards.
Standardize list empty UI, searchable country fields, customer-products filter row, password/login polish, and one-time business dashboard handoff from the customer app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:46:57 +03:30

621 lines
21 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Download, Loader2, Package, Plus, RotateCcw, Search, Upload } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { EmptyState } from '../components/EmptyState'
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'
import { StoreItemDiscountModal } from '../components/StoreItemDiscountModal'
import { StoreItemFestivalModal } from '../components/StoreItemFestivalModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { useDraftCart } from '../context/DraftCartContext'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
deleteStoreItemsByProduct,
downloadStoreItemsExcel,
importStoreItemsExcel,
listAllStoreItems,
STORE_ITEMS_PER_PAGE,
type StoreItem,
} from '../services/storeItemService'
import type { ShoppingCard } from '../services/shoppingCardService'
import { RESUME_SHOPPING_CARD_KEY, shoppingCardToDraftItems } from '../types/draftCart'
import {
EMPTY_STORE_LISTING_FILTERS,
filterStoreListings,
type StoreListingFilters,
} from '../utils/filterStoreListings'
import {
formatVariantCount,
groupStoreItemsByProduct,
type StoreProductListing,
} from '../utils/storeProductGroups'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './StoreItemsPage.module.css'
function readPage(searchParams: URLSearchParams) {
const pageRaw = Number(searchParams.get('page'))
return Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1
}
export function StoreItemsPage() {
return <StoreItemsPageContent />
}
function StoreItemsPageContent() {
const t = useT()
const { locale } = useLocale()
const [searchParams, setSearchParams] = useSearchParams()
const currentPage = readPage(searchParams)
const { itemCount, hasItems, addVariant, loadShoppingCard } = useDraftCart()
const { showToast } = useToast()
const [items, setItems] = useState<StoreItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<StoreProductListing | null>(null)
const [discountTarget, setDiscountTarget] = useState<StoreProductListing | null>(null)
const [festivalTarget, setFestivalTarget] = useState<StoreProductListing | null>(null)
const [deleteTarget, setDeleteTarget] = useState<StoreProductListing | null>(null)
const [pickVariantTarget, setPickVariantTarget] = useState<StoreProductListing | null>(null)
const [cartOpen, setCartOpen] = useState(false)
const [draftName, setDraftName] = useState('')
const [draftMinPrice, setDraftMinPrice] = useState('')
const [draftMaxPrice, setDraftMaxPrice] = useState('')
const [draftOnlyDiscounted, setDraftOnlyDiscounted] = useState(false)
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) {
sessionStorage.removeItem(RESUME_SHOPPING_CARD_KEY)
return
}
const raw = sessionStorage.getItem(RESUME_SHOPPING_CARD_KEY)
if (!raw) return
try {
const card = JSON.parse(raw) as ShoppingCard
const draftItems = shoppingCardToDraftItems(card)
if (draftItems.length === 0) {
sessionStorage.removeItem(RESUME_SHOPPING_CARD_KEY)
showToast('Shopping card has no valid items to load.', 'error')
return
}
loadShoppingCard(card)
showToast('Shopping card loaded. Open the cart to continue.', 'success')
} catch {
sessionStorage.removeItem(RESUME_SHOPPING_CARD_KEY)
showToast('Unable to load shopping card.', 'error')
}
}, [hasItems, loadShoppingCard, showToast])
const listings = useMemo(
() => groupStoreItemsByProduct(items, productOrder),
[items, productOrder],
)
const filteredListings = useMemo(
() => filterStoreListings(listings, appliedFilters),
[listings, appliedFilters],
)
const totalListings = filteredListings.length
const totalPages = Math.max(1, Math.ceil(totalListings / STORE_ITEMS_PER_PAGE))
const safePage = Math.min(currentPage, totalPages)
const pagedListings = useMemo(() => {
const start = (safePage - 1) * STORE_ITEMS_PER_PAGE
return filteredListings.slice(start, start + STORE_ITEMS_PER_PAGE)
}, [filteredListings, safePage])
useEffect(() => {
if (!isLoading && currentPage > totalPages) {
writePage(totalPages)
}
}, [isLoading, currentPage, totalPages])
const productItems = useMemo(() => {
const target = editTarget ?? discountTarget ?? festivalTarget
if (!target) return []
return items.filter((item) => item.productId === target.productId)
}, [editTarget, discountTarget, festivalTarget, items])
const existingProductIds = useMemo(
() => listings.map((listing) => listing.productId),
[listings],
)
function writePage(page: number) {
const next = new URLSearchParams(searchParams)
if (page > 1) next.set('page', String(page))
else next.delete('page')
setSearchParams(next, { replace: true })
}
function applyFilters() {
setAppliedFilters({
name: draftName.trim(),
minPrice: parseIrtInput(draftMinPrice),
maxPrice: parseIrtInput(draftMaxPrice),
onlyDiscounted: draftOnlyDiscounted,
})
writePage(1)
}
function clearFilters() {
setDraftName('')
setDraftMinPrice('')
setDraftMaxPrice('')
setDraftOnlyDiscounted(false)
setAppliedFilters(EMPTY_STORE_LISTING_FILTERS)
writePage(1)
}
function handlePageChange(page: number) {
writePage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
useEffect(() => {
const controller = new AbortController()
void loadItems(controller.signal)
return () => controller.abort()
}, [])
async function loadItems(signal?: AbortSignal) {
setIsLoading(true)
setError('')
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) {
setError(err.message)
} else {
setError(t('storeItems.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function replaceProductItems(updated: StoreItem[]) {
const productId = updated[0]?.productId ?? editTarget?.productId
if (!productId) return
setItems((prev) => {
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[]) {
const byId = new Map(updated.map((item) => [item.id, item]))
setItems((prev) => prev.map((item) => byId.get(item.id) ?? item))
}
function openEdit(listing: StoreProductListing) {
setEditTarget(listing)
}
function openEditForProduct(productId: string) {
const listing = listings.find((entry) => entry.productId === productId)
if (listing) {
setCreateOpen(false)
setEditTarget(listing)
}
}
function handleAddToCart(listing: StoreProductListing) {
if (listing.variantCount > 1) {
setPickVariantTarget(listing)
return
}
const variant = listing.variants[0]
if (!variant) return
const feedback = addVariant(variant)
if (feedback) {
showToast(feedback, 'error')
return
}
showToast(t('storeItems.addedToCart'), 'success')
}
function handleVariantPicked(variant: StoreItem) {
const feedback = addVariant(variant)
setPickVariantTarget(null)
if (feedback) {
showToast(feedback, 'error')
return
}
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
setIsDeleting(true)
setError('')
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)
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / STORE_ITEMS_PER_PAGE))
if (safePage > nextTotalPages) {
writePage(nextTotalPages)
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('storeItems.errorRemove'))
}
} finally {
setIsDeleting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Store', href: '/store' },
{ label: 'My Store Items' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="STORE PRODUCTS">{t('title.storeItems')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('storeItems.subtitle')}</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>{t('storeItems.filters')}</div>
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
<div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<input
id="filter-store-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder={t('storeItems.filterNamePlaceholder')}
aria-label={t('storeItems.filterName')}
disabled={isLoading}
/>
</div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}>
<input
id="filter-store-min-price"
inputMode="numeric"
value={draftMinPrice}
onChange={(e) => setDraftMinPrice(formatIrtInput(e.target.value))}
placeholder={t('storeItems.minPrice')}
aria-label={t('storeItems.minPrice')}
disabled={isLoading}
/>
</div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}>
<input
id="filter-store-max-price"
inputMode="numeric"
value={draftMaxPrice}
onChange={(e) => setDraftMaxPrice(formatIrtInput(e.target.value))}
placeholder={t('storeItems.maxPrice')}
aria-label={t('storeItems.maxPrice')}
disabled={isLoading}
/>
</div>
<div className={`${filterStyles.switchField} ${filterStyles.fieldCol3}`}>
<div className={filterStyles.switchInline}>
<ToggleSwitch
checked={draftOnlyDiscounted}
onChange={setDraftOnlyDiscounted}
disabled={isLoading}
ariaLabel={t('storeItems.onlyDiscounted')}
/>
<span
className={filterStyles.switchLabel}
onClick={() => !isLoading && setDraftOnlyDiscounted(!draftOnlyDiscounted)}
>
{t('storeItems.onlyDiscounted')}
</span>
</div>
</div>
</div>
<div className={filterStyles.filterActions}>
<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>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>{t('storeItems.loading')}</p>
) : listings.length === 0 ? (
<EmptyState
icon={Package}
message={t('storeItems.empty')}
actionLabel={t('storeItems.add')}
onAction={() => setCreateOpen(true)}
/>
) : filteredListings.length === 0 ? (
<EmptyState icon={Search} message={t('storeItems.emptyFiltered')} />
) : (
<>
<div className={styles.grid}>
{pagedListings.map((listing) => (
<StoreItemCard
key={listing.productId}
listing={listing}
onOpen={openEdit}
onEdit={openEdit}
onDiscount={setDiscountTarget}
onFestival={setFestivalTarget}
onRemove={setDeleteTarget}
onAddToCart={handleAddToCart}
/>
))}
</div>
<div className={styles.pagination}>
<div className={styles.paginationMeta}>
{t('storeItems.pagination', {
page: safePage,
totalPages,
perPage: STORE_ITEMS_PER_PAGE,
total: totalListings,
})}
</div>
<Pagination
currentPage={safePage}
totalPages={totalPages}
onPageChange={handlePageChange}
disabled={isLoading}
variant="inline"
/>
</div>
</>
)}
<div className={styles.fabDock}>
{hasItems && (
<button
type="button"
className={styles.cartFabStrip}
onClick={() => setCartOpen(true)}
aria-label={t('storeItems.cartOpen', { count: itemCount })}
>
<span className={styles.cartFabCount}>{itemCount}</span>
<span className={styles.cartFabLabel}>
{itemCount === 1 ? t('storeItems.cartOne') : t('storeItems.cartMany')}
</span>
</button>
)}
<button
type="button"
className={styles.addFab}
onClick={() => setCreateOpen(true)}
aria-label={t('storeItems.add')}
>
<Plus size={24} />
</button>
</div>
<CreateStoreItemsModal
open={createOpen}
existingProductIds={existingProductIds}
onClose={() => setCreateOpen(false)}
onCreated={() => void loadItems()}
onEditExisting={openEditForProduct}
/>
<EditStoreItemsModal
open={!!editTarget}
productTitle={editTarget?.productTitle ?? ''}
productNameFa={editTarget?.productNameFa ?? ''}
items={productItems}
onClose={() => setEditTarget(null)}
onSaved={replaceProductItems}
/>
<StoreItemDiscountModal
open={!!discountTarget}
productTitle={discountTarget?.productTitle ?? ''}
productNameFa={discountTarget?.productNameFa ?? ''}
items={productItems}
onClose={() => setDiscountTarget(null)}
onSaved={mergeUpdatedItems}
/>
<StoreItemFestivalModal
open={!!festivalTarget}
productTitle={festivalTarget?.productTitle ?? ''}
items={productItems}
onClose={() => setFestivalTarget(null)}
onSaved={mergeUpdatedItems}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title={t('storeItems.deleteTitle')}
message={
deleteTarget
? t('storeItems.deleteMessage', {
name: deleteTarget.productNameFa || deleteTarget.productTitle,
variants: formatVariantCount(deleteTarget.variantCount, locale),
})
: ''
}
onConfirm={() => void confirmDelete()}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
<PickStoreVariantModal
open={!!pickVariantTarget}
productTitle={pickVariantTarget?.productTitle ?? ''}
productNameFa={pickVariantTarget?.productNameFa ?? ''}
variants={pickVariantTarget?.variants ?? []}
onClose={() => setPickVariantTarget(null)}
onSelect={handleVariantPicked}
/>
<ShoppingCartModal
open={cartOpen}
onClose={() => setCartOpen(false)}
onOrderCreated={() => void loadItems()}
/>
</main>
)
}