mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
437 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|