This commit is contained in:
Vula Builder
2026-07-27 18:06:18 +00:00
parent a3bc6bb777
commit 96b019bc38
+181
View File
@@ -0,0 +1,181 @@
'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>
</>
)
}