Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Minus, Package, Plus, ShoppingBag, Trash2 } from 'lucide-react'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import {
|
||||
loadGuestCart,
|
||||
saveGuestCart,
|
||||
type GuestCartItem,
|
||||
} from '../../lib/guestCart'
|
||||
import {
|
||||
getCart,
|
||||
removeCartItem,
|
||||
updateCartItem,
|
||||
type Cart,
|
||||
} from '../../services/cartService'
|
||||
import { syncGuestCartToServer } from '../../services/syncGuestCart'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
type DisplayItem = {
|
||||
id: string
|
||||
title: string
|
||||
variant?: string
|
||||
image: string | null
|
||||
quantity: number
|
||||
lineTotal: number
|
||||
stockQuantity: number | null
|
||||
}
|
||||
|
||||
function guestToDisplay(items: GuestCartItem[]): DisplayItem[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
image: item.image,
|
||||
quantity: item.quantity,
|
||||
lineTotal: item.price * item.quantity,
|
||||
stockQuantity: null,
|
||||
}))
|
||||
}
|
||||
|
||||
function formatVariantLabel(item: Cart['items'][number]): string | undefined {
|
||||
if (item.selections.length > 0) {
|
||||
return item.selections.map((selection) => selection.value).join(' · ')
|
||||
}
|
||||
|
||||
const title = item.productNameFa?.trim() || item.productTitle
|
||||
if (item.label && item.label !== title && item.label !== item.productTitle) {
|
||||
return item.label
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function apiToDisplay(cart: Cart): DisplayItem[] {
|
||||
return cart.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.productNameFa?.trim() || item.productTitle,
|
||||
variant: formatVariantLabel(item),
|
||||
image: item.productImage,
|
||||
quantity: item.quantity,
|
||||
lineTotal: item.lineTotal,
|
||||
stockQuantity: item.stockQuantity,
|
||||
}))
|
||||
}
|
||||
|
||||
export function CheckoutCartStep() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { user } = useAuth()
|
||||
const isAuthenticated = Boolean(user)
|
||||
|
||||
const [cart, setCart] = useState<Cart | null>(null)
|
||||
const [guestItems, setGuestItems] = useState<GuestCartItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyItemId, setBusyItemId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const guest = loadGuestCart()
|
||||
setGuestItems(guest)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setCart(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let data = await getCart(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (data.cart.items.length === 0 && guest.length > 0) {
|
||||
const synced = await syncGuestCartToServer(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
data = { cart: synced }
|
||||
}
|
||||
|
||||
setCart(data.cart)
|
||||
if ((data.cart?.items?.length ?? 0) > 0) {
|
||||
setGuestItems([])
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری سبد خرید ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [isAuthenticated])
|
||||
|
||||
const apiHasItems = (cart?.items.length ?? 0) > 0
|
||||
const usingGuest = !isAuthenticated || !apiHasItems
|
||||
|
||||
const items = useMemo(
|
||||
() => (usingGuest ? guestToDisplay(guestItems) : cart ? apiToDisplay(cart) : []),
|
||||
[usingGuest, guestItems, cart],
|
||||
)
|
||||
|
||||
const subtotal = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.lineTotal, 0),
|
||||
[items],
|
||||
)
|
||||
|
||||
const totalQuantity = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items],
|
||||
)
|
||||
|
||||
function persistGuest(next: GuestCartItem[]) {
|
||||
setGuestItems(next)
|
||||
saveGuestCart(next)
|
||||
}
|
||||
|
||||
async function handleQuantityChange(itemId: string, nextQuantity: number) {
|
||||
if (usingGuest) {
|
||||
if (nextQuantity <= 0) {
|
||||
persistGuest(guestItems.filter((item) => item.id !== itemId))
|
||||
return
|
||||
}
|
||||
persistGuest(
|
||||
guestItems.map((item) =>
|
||||
item.id === itemId ? { ...item, quantity: nextQuantity } : item,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setBusyItemId(itemId)
|
||||
try {
|
||||
const data = await updateCartItem(itemId, nextQuantity)
|
||||
setCart(data.cart)
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'بهروزرسانی تعداد ممکن نشد.', 'error')
|
||||
} finally {
|
||||
setBusyItemId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(itemId: string) {
|
||||
if (usingGuest) {
|
||||
persistGuest(guestItems.filter((item) => item.id !== itemId))
|
||||
showToast('محصول از سبد حذف شد.', 'success')
|
||||
return
|
||||
}
|
||||
|
||||
setBusyItemId(itemId)
|
||||
try {
|
||||
const data = await removeCartItem(itemId)
|
||||
setCart(data.cart)
|
||||
showToast('محصول از سبد حذف شد.', 'success')
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'حذف محصول ممکن نشد.', 'error')
|
||||
} finally {
|
||||
setBusyItemId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
if (!isAuthenticated) {
|
||||
navigate(`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`, {
|
||||
state: { from: '/checkout/delivery' },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (usingGuest && guestItems.length > 0) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const cart = await syncGuestCartToServer()
|
||||
setCart(cart)
|
||||
setGuestItems([])
|
||||
if (cart.items.length === 0) {
|
||||
showToast('انتقال سبد خرید ممکن نشد. دوباره تلاش کنید.', 'error')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : 'انتقال سبد خرید ممکن نشد.',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
navigate('/checkout/delivery')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری سبد خرید...</div>
|
||||
}
|
||||
|
||||
if (error && items.length === 0) {
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
<button type="button" className={styles.secondaryBtn} onClick={() => window.location.reload()}>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>سبد خرید شما</h1>
|
||||
<p className={styles.stepDesc}>
|
||||
{items.length === 0
|
||||
? 'سبد خرید شما خالی است.'
|
||||
: 'آماده تکمیل سفارش هستید.'}
|
||||
{!isAuthenticated && items.length > 0
|
||||
? ' برای ادامه، در مرحله بعد وارد شوید.'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<ShoppingBag size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p>برای شروع خرید، از فروشگاه محصول اضافه کنید.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.cartList}>
|
||||
{items.map((item) => {
|
||||
const busy = busyItemId === item.id
|
||||
const atMax =
|
||||
item.stockQuantity !== null && item.quantity >= item.stockQuantity
|
||||
|
||||
return (
|
||||
<article key={item.id} className={styles.cartItem}>
|
||||
{item.image ? (
|
||||
<img src={item.image} alt="" className={styles.cartItemImage} />
|
||||
) : (
|
||||
<div className={`${styles.cartItemImage} ${styles.cartItemImagePlaceholder}`}>
|
||||
<Package size={22} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cartItemInfo}>
|
||||
<div className={styles.cartItemTitle}>{item.title}</div>
|
||||
{item.variant ? (
|
||||
<div className={styles.cartItemVariant}>{item.variant}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.cartItemFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => void handleRemove(item.id)}
|
||||
disabled={busy}
|
||||
aria-label="حذف محصول"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className={styles.qtyControls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() => void handleQuantityChange(item.id, item.quantity - 1)}
|
||||
disabled={busy || item.quantity <= 1}
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className={styles.qtyValue}>{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() => void handleQuantityChange(item.id, item.quantity + 1)}
|
||||
disabled={busy || atMax}
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.cartItemPrice}>{formatIrtPrice(item.lineTotal)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>تعداد کالا</span>
|
||||
<span>{totalQuantity.toLocaleString('fa-IR')} عدد</span>
|
||||
</div>
|
||||
<div className={`${styles.summaryRow} ${styles.summaryRowTotal}`}>
|
||||
<span>جمع کل</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel={isAuthenticated ? 'ادامه به ارسال' : 'ادامه به ورود'}
|
||||
onPrimary={handleContinue}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user