Deploy
This commit is contained in:
@@ -0,0 +1,179 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Minus, Package, Plus, ShoppingCart, Trash2 } 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 || ''
|
||||||
|
const BASE = '/api/medusa'
|
||||||
|
|
||||||
|
async function medusaFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${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
|
||||||
|
variant: { id: string; title: string } | null
|
||||||
|
}
|
||||||
|
interface Cart {
|
||||||
|
id: string
|
||||||
|
items: CartItem[]
|
||||||
|
subtotal: number
|
||||||
|
region: { currency_code: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CartPage() {
|
||||||
|
const [cart, setCart] = useState<Cart | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const cartId = typeof window !== 'undefined' ? localStorage.getItem('medusa_cart_id') : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cartId) { setLoading(false); return }
|
||||||
|
medusaFetch<{ cart: Cart }>(`/store/carts/${cartId}`)
|
||||||
|
.then(({ cart: c }) => setCart(c))
|
||||||
|
.catch(() => setCart(null))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [cartId])
|
||||||
|
|
||||||
|
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 } : null)
|
||||||
|
broadcastCartCount(newItems.reduce((s, i) => s + i.quantity, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuantity = async (itemId: string, quantity: number) => {
|
||||||
|
if (!cart) return
|
||||||
|
if (quantity < 1) return removeItem(itemId)
|
||||||
|
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 currency = cart?.region?.currency_code?.toUpperCase() ?? 'USD'
|
||||||
|
const items = cart?.items ?? []
|
||||||
|
const subtotal = cart?.subtotal ?? 0
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background">
|
||||||
|
<div className="container mx-auto px-4 py-12 max-w-5xl">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Link href="/products" className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors text-sm">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Continue Shopping
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||||
|
<ShoppingCart className="h-8 w-8" />
|
||||||
|
Your Cart
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-muted-foreground/40" />
|
||||||
|
<p className="text-muted-foreground text-lg mb-6">Your cart is empty.</p>
|
||||||
|
<Link href="/products">
|
||||||
|
<button className="bg-primary text-primary-foreground py-3 px-8 rounded-lg hover:bg-primary/90 transition-colors font-medium">
|
||||||
|
Browse Products
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex gap-4 bg-card border border-border rounded-xl p-4">
|
||||||
|
{item.thumbnail ? (
|
||||||
|
<img src={item.thumbnail} alt={item.product_title || item.title} className="w-20 h-20 object-cover rounded-lg flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 rounded-lg flex-shrink-0 bg-muted flex items-center justify-center">
|
||||||
|
<Package className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-foreground line-clamp-1">{item.product_title || item.title}</h3>
|
||||||
|
{item.variant?.title && item.variant.title !== 'Default Title' && (
|
||||||
|
<p className="text-sm text-muted-foreground">{item.variant.title}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-primary font-bold mt-1">
|
||||||
|
{formatPrice(item.unit_price ?? 0, cart?.region?.currency_code)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||||
|
<button onClick={() => removeItem(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1 border border-border rounded-lg overflow-hidden">
|
||||||
|
<button onClick={() => updateQuantity(item.id, item.quantity - 1)} className="px-2 py-1 hover:bg-muted transition-colors">
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-1 text-sm font-medium border-x border-border">{item.quantity}</span>
|
||||||
|
<button onClick={() => updateQuantity(item.id, item.quantity + 1)} className="px-2 py-1 hover:bg-muted transition-colors">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6 sticky top-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4 text-foreground">Order Summary</h2>
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground mb-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Subtotal ({items.length} items)</span>
|
||||||
|
<span className="text-foreground font-medium">{formatPrice(subtotal, cart?.region?.currency_code)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Shipping</span>
|
||||||
|
<span>Calculated at checkout</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-4 flex justify-between font-bold text-lg mb-6">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="text-primary">{formatPrice(subtotal, cart?.region?.currency_code)}</span>
|
||||||
|
</div>
|
||||||
|
<Link href="/checkout" className="block w-full bg-primary text-primary-foreground py-3 rounded-lg hover:bg-primary/90 transition-colors font-semibold text-center">
|
||||||
|
Proceed to Checkout
|
||||||
|
</Link>
|
||||||
|
<Link href="/products" className="block text-center mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Continue Shopping
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user