Files
dashboards/apps/customer/src/pages/checkout/CheckoutCartStep.tsx
T
Alireza HassaniandCursor c59ca4444f Add shipping cost management and checkout shipping fees.
Business store shipping groups/static rates UI, and customer checkout that quotes and applies shipping on delivery and payment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 21:52:47 +03:30

345 lines
10 KiB
TypeScript

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(`/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>
)
}