182 lines
8.6 KiB
TypeScript
182 lines
8.6 KiB
TypeScript
'use client'
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
import { ArrowRight, Minus, Package, Plus, ShoppingCart, Trash2, X } from 'lucide-react'
|
|
import { broadcastCartCount } from '@/hooks/useMedusaCart'
|
|
|
|
const CURRENCY_SYMBOLS: Record<string, string> = { zar: 'R', usd: '$', eur: '€', gbp: '£', kes: 'KSh', ngn: '₦', ghs: '₵' }
|
|
const formatPrice = (amount: number, code?: string) => {
|
|
const sym = CURRENCY_SYMBOLS[(code ?? '').toLowerCase()] ?? (code?.toUpperCase() ?? '')
|
|
return `${sym}${(amount / 100).toFixed(2)}`
|
|
}
|
|
const PUB_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || ''
|
|
|
|
async function medusaFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`/api/medusa${path}`, {
|
|
...init,
|
|
headers: { 'content-type': 'application/json', 'x-publishable-api-key': PUB_KEY, ...(init?.headers ?? {}) },
|
|
signal: AbortSignal.timeout(30000),
|
|
})
|
|
if (!res.ok) throw new Error(`Medusa ${res.status}`)
|
|
return res.json()
|
|
}
|
|
|
|
interface CartItem { id: string; title: string; product_title?: string; thumbnail: string | null; quantity: number; unit_price: number; subtotal: number; variant: { id: string; title: string } | null }
|
|
interface Cart { id: string; items: CartItem[]; subtotal: number; region: { currency_code: string } | null }
|
|
|
|
export default function CartDrawer() {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [cart, setCart] = useState<Cart | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const fetchCart = useCallback(async () => {
|
|
const cartId = localStorage.getItem('medusa_cart_id')
|
|
if (!cartId) { setCart(null); return }
|
|
setLoading(true)
|
|
try {
|
|
const { cart: c } = await medusaFetch<{ cart: Cart }>(`/store/carts/${cartId}`)
|
|
setCart(c)
|
|
} catch {
|
|
setCart(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const open = () => { setIsOpen(true); fetchCart() }
|
|
window.addEventListener('cart-drawer-open', open)
|
|
return () => window.removeEventListener('cart-drawer-open', open)
|
|
}, [fetchCart])
|
|
|
|
useEffect(() => {
|
|
const handler = () => { if (isOpen) fetchCart() }
|
|
window.addEventListener('medusa-cart-updated', handler)
|
|
return () => window.removeEventListener('medusa-cart-updated', handler)
|
|
}, [isOpen, fetchCart])
|
|
|
|
useEffect(() => {
|
|
document.body.style.overflow = isOpen ? 'hidden' : ''
|
|
return () => { document.body.style.overflow = '' }
|
|
}, [isOpen])
|
|
|
|
const close = () => setIsOpen(false)
|
|
|
|
const removeItem = async (itemId: string) => {
|
|
if (!cart) return
|
|
await medusaFetch(`/store/carts/${cart.id}/line-items/${itemId}`, { method: 'DELETE' })
|
|
const newItems = cart.items.filter(i => i.id !== itemId)
|
|
setCart(prev => prev ? { ...prev, items: newItems, subtotal: newItems.reduce((s, i) => s + i.subtotal, 0) } : null)
|
|
broadcastCartCount(newItems.reduce((s, i) => s + i.quantity, 0))
|
|
}
|
|
|
|
const updateQty = async (itemId: string, quantity: number) => {
|
|
if (!cart) return
|
|
if (quantity < 1) { removeItem(itemId); return }
|
|
const { cart: updated } = await medusaFetch<{ cart: Cart }>(
|
|
`/store/carts/${cart.id}/line-items/${itemId}`,
|
|
{ method: 'POST', body: JSON.stringify({ quantity }) }
|
|
)
|
|
setCart(updated)
|
|
broadcastCartCount(updated.items?.reduce((s, i) => s + i.quantity, 0) ?? 0)
|
|
}
|
|
|
|
const items = cart?.items ?? []
|
|
const currencyCode = cart?.region?.currency_code
|
|
|
|
return (
|
|
<>
|
|
{isOpen && (
|
|
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm" onClick={close} aria-hidden="true" />
|
|
)}
|
|
<div
|
|
className={`fixed right-0 top-0 z-50 h-full w-full max-w-md bg-background shadow-2xl transition-transform duration-300 ease-in-out flex flex-col ${isOpen ? 'translate-x-0' : 'translate-x-full'}`}
|
|
role="dialog" aria-label="Shopping cart"
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<ShoppingCart className="h-5 w-5 text-foreground" />
|
|
<h2 className="text-lg font-semibold text-foreground">Your Cart</h2>
|
|
{items.length > 0 && (
|
|
<span className="text-xs bg-primary text-primary-foreground rounded-full px-2 py-0.5 font-medium">
|
|
{items.reduce((s, i) => s + i.quantity, 0)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button onClick={close} className="p-2 rounded-lg hover:bg-muted transition-colors" aria-label="Close cart">
|
|
<X className="h-5 w-5 text-foreground" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<ShoppingCart className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
|
<p className="text-muted-foreground text-sm mb-4">Your cart is empty.</p>
|
|
<button onClick={close} className="text-sm text-primary hover:underline">Continue shopping</button>
|
|
</div>
|
|
) : (
|
|
<ul className="space-y-4">
|
|
{items.map(item => (
|
|
<li key={item.id} className="flex gap-3">
|
|
<div className="w-16 h-16 rounded-lg bg-muted overflow-hidden flex-shrink-0">
|
|
{item.thumbnail ? (
|
|
<img src={item.thumbnail} alt={item.product_title || item.title} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Package className="h-6 w-6 text-muted-foreground/40" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-foreground line-clamp-1">{item.product_title || item.title}</p>
|
|
{item.variant?.title && item.variant.title !== 'Default Title' && (
|
|
<p className="text-xs text-muted-foreground">{item.variant.title}</p>
|
|
)}
|
|
<p className="text-sm font-bold text-primary mt-0.5">{formatPrice(item.unit_price, currencyCode)}</p>
|
|
<div className="flex items-center gap-1 mt-1.5">
|
|
<button onClick={() => updateQty(item.id, item.quantity - 1)} className="w-6 h-6 rounded border border-border flex items-center justify-center hover:bg-muted transition-colors">
|
|
<Minus className="h-3 w-3" />
|
|
</button>
|
|
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
|
|
<button onClick={() => updateQty(item.id, item.quantity + 1)} className="w-6 h-6 rounded border border-border flex items-center justify-center hover:bg-muted transition-colors">
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => removeItem(item.id)} className="p-1 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0" aria-label="Remove item">
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{items.length > 0 && (
|
|
<div className="px-6 py-4 border-t border-border bg-muted/30 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Subtotal</span>
|
|
<span className="text-lg font-bold text-foreground">{formatPrice(cart?.subtotal ?? 0, currencyCode)}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">Shipping calculated at checkout</p>
|
|
<Link href="/checkout" onClick={close}
|
|
className="flex items-center justify-center gap-2 w-full bg-primary text-primary-foreground py-3 rounded-lg hover:bg-primary/90 transition-colors font-semibold text-sm">
|
|
Checkout
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
<Link href="/cart" onClick={close}
|
|
className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
View full cart
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|