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>
345 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|