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>
621 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|