Files
dashboards/apps/business/src/pages/StoreItemsPage.tsx
T

437 lines
15 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Plus, RotateCcw, Search } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { CreateStoreItemsModal } from '../components/CreateStoreItemsModal'
import { EditStoreItemsModal } from '../components/EditStoreItemsModal'
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,
listStoreItems,
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'
export function StoreItemsPage() {
return <StoreItemsPageContent />
}
function StoreItemsPageContent() {
const t = useT()
const { locale } = useLocale()
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,
)
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), [items])
const filteredListings = useMemo(
() => filterStoreListings(listings, appliedFilters),
[listings, appliedFilters],
)
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 applyFilters() {
setAppliedFilters({
name: draftName.trim(),
minPrice: parseIrtInput(draftMinPrice),
maxPrice: parseIrtInput(draftMaxPrice),
onlyDiscounted: draftOnlyDiscounted,
})
}
function clearFilters() {
setDraftName('')
setDraftMinPrice('')
setDraftMaxPrice('')
setDraftOnlyDiscounted(false)
setAppliedFilters(EMPTY_STORE_LISTING_FILTERS)
}
useEffect(() => {
const controller = new AbortController()
void loadItems(controller.signal)
return () => controller.abort()
}, [])
async function loadItems(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listStoreItems(1, 50, signal)
setItems(data.items)
} 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
})
}
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 confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deleteStoreItemsByProduct(deleteTarget.productId)
setItems((prev) => prev.filter((item) => item.productId !== deleteTarget.productId))
setDeleteTarget(null)
} 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>
<h2 className={pageStyles.pageTitle}>{t('title.storeItems')}</h2>
<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}>
<button
type="submit"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
disabled={isLoading}
aria-label={t('storeItems.search')}
title={t('storeItems.search')}
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={isLoading}
aria-label={t('storeItems.clearFilters')}
title={t('storeItems.clearFilters')}
>
<RotateCcw size={18} />
</button>
</div>
</form>
</div>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>{t('storeItems.loading')}</p>
) : listings.length === 0 ? (
<p className={styles.empty}>{t('storeItems.empty')}</p>
) : filteredListings.length === 0 ? (
<p className={styles.empty}>{t('storeItems.emptyFiltered')}</p>
) : (
<div className={styles.grid}>
{filteredListings.map((listing) => (
<StoreItemCard
key={listing.productId}
listing={listing}
onOpen={openEdit}
onEdit={openEdit}
onDiscount={setDiscountTarget}
onFestival={setFestivalTarget}
onRemove={setDeleteTarget}
onAddToCart={handleAddToCart}
/>
))}
</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 ?? ''}
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>
)
}