Deploy
This commit is contained in:
@@ -0,0 +1,207 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, CircleCheck, Loader2 } from 'lucide-react'
|
||||||
|
import { medusa } from '@/lib/medusa'
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'details' | 'review' | 'success'
|
||||||
|
interface Addr { first_name: string; last_name: string; address_1: string; city: string; country_code: string; postal_code: string; phone: string }
|
||||||
|
interface CartItem { id: string; title: string; quantity: number; unit_price: number; thumbnail: string | null; variant: { title: string } | null }
|
||||||
|
interface Cart { id: string; items: CartItem[]; subtotal: number; region: { currency_code: string } | null }
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [step, setStep] = useState<Step>('details')
|
||||||
|
const [cart, setCart] = useState<Cart | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [placing, setPlacing] = useState(false)
|
||||||
|
const [orderError, setOrderError] = useState<string | null>(null)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [addr, setAddr] = useState<Addr>({ first_name: '', last_name: '', address_1: '', city: '', country_code: 'za', postal_code: '', phone: '' })
|
||||||
|
|
||||||
|
const cartId = typeof window !== 'undefined' ? localStorage.getItem('medusa_cart_id') : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cartId) { setLoading(false); return }
|
||||||
|
medusa.store.cart.retrieve(cartId)
|
||||||
|
.then(({ cart: c }) => setCart(c as Cart))
|
||||||
|
.catch(() => setCart(null))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [cartId])
|
||||||
|
|
||||||
|
const handleDetailsSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!cartId) return
|
||||||
|
try {
|
||||||
|
await (medusa.store.cart.update as Function)(cartId, { email, shipping_address: addr })
|
||||||
|
} catch {}
|
||||||
|
setStep('review')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlaceOrder = async () => {
|
||||||
|
setPlacing(true)
|
||||||
|
setOrderError(null)
|
||||||
|
try {
|
||||||
|
if (!cartId) return
|
||||||
|
|
||||||
|
const pubKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || ''
|
||||||
|
const h = { 'Content-Type': 'application/json', 'x-publishable-api-key': pubKey }
|
||||||
|
|
||||||
|
// Step 1: find first available payment provider for this cart's region.
|
||||||
|
// Default to pp_system_default (Medusa v2 built-in manual provider) — always
|
||||||
|
// available when provisioning has run. pp_test_test does not exist.
|
||||||
|
let providerId = 'pp_system_default'
|
||||||
|
try {
|
||||||
|
const cartRes = await fetch(`/api/medusa/store/carts/${cartId}`, { headers: h })
|
||||||
|
const { cart: cartData } = await cartRes.json()
|
||||||
|
if (cartData?.region_id) {
|
||||||
|
const provRes = await fetch(`/api/medusa/store/payment-providers?region_id=${cartData.region_id}`, { headers: h })
|
||||||
|
const provData = await provRes.json()
|
||||||
|
const first = provData?.payment_providers?.[0]
|
||||||
|
if (first?.id) providerId = first.id
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Step 2: create payment collection (Medusa v2 — must come before session)
|
||||||
|
const pcRes = await fetch('/api/medusa/store/payment-collections', {
|
||||||
|
method: 'POST', headers: h, body: JSON.stringify({ cart_id: cartId }),
|
||||||
|
})
|
||||||
|
if (!pcRes.ok) throw new Error(`Payment setup failed (${pcRes.status})`)
|
||||||
|
const { payment_collection } = await pcRes.json()
|
||||||
|
|
||||||
|
// Step 3: create payment session on the collection
|
||||||
|
const psRes = await fetch(`/api/medusa/store/payment-collections/${payment_collection.id}/payment-sessions`, {
|
||||||
|
method: 'POST', headers: h, body: JSON.stringify({ provider_id: providerId }),
|
||||||
|
})
|
||||||
|
if (!psRes.ok) throw new Error(`Payment session failed (${psRes.status})`)
|
||||||
|
|
||||||
|
// Step 4: complete cart — creates the order in Medusa
|
||||||
|
const result = await (medusa.store.cart.complete as Function)(cartId)
|
||||||
|
const order = result?.order ?? result
|
||||||
|
localStorage.removeItem('medusa_cart_id')
|
||||||
|
localStorage.removeItem('medusa_cart_count')
|
||||||
|
broadcastCartCount(0)
|
||||||
|
if (order?.id) {
|
||||||
|
router.push(`/order/${order.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Completed without an order id — treat as success (e.g. pending payment)
|
||||||
|
setStep('success')
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Checkout error:', err)
|
||||||
|
setOrderError(err?.message || 'Something went wrong. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setPlacing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = cart?.region?.currency_code?.toUpperCase() ?? 'ZAR'
|
||||||
|
const items = cart?.items ?? []
|
||||||
|
const subtotal = cart?.subtotal ?? 0
|
||||||
|
|
||||||
|
if (loading) return <div className="min-h-screen flex items-center justify-center bg-background"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||||
|
|
||||||
|
if (step === 'success') return (
|
||||||
|
<main className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="max-w-md w-full mx-auto p-8 text-center">
|
||||||
|
<CircleCheck className="h-16 w-16 text-green-500 mx-auto mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold mb-2 text-foreground">Order Placed!</h1>
|
||||||
|
<p className="text-muted-foreground mb-6">Thank you{email ? ', ' + email.split('@')[0] : ''}. Your order has been received.</p>
|
||||||
|
<Link href="/products" className="inline-block bg-primary text-primary-foreground px-6 py-3 rounded-lg hover:bg-primary/90 transition-colors font-medium">Continue Shopping</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
|
||||||
|
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-6">
|
||||||
|
<Link href="/cart" className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors text-sm"><ArrowLeft className="h-4 w-4" />Back to Cart</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Checkout</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mb-8 text-sm">
|
||||||
|
<span className={step === 'details' ? 'text-primary font-semibold' : 'text-muted-foreground'}>1. Your Details</span>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<span className={step === 'review' ? 'text-primary font-semibold' : 'text-muted-foreground'}>2. Review {'&'} Confirm</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{step === 'details' && (
|
||||||
|
<form onSubmit={handleDetailsSubmit} className="space-y-6">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 text-foreground">Contact</h2>
|
||||||
|
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} placeholder="Email address"
|
||||||
|
className="w-full border border-border rounded-lg px-3 py-2 text-sm bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 text-foreground">Shipping Address</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{([['first_name','First name',false],['last_name','Last name',false],['address_1','Street address',true],['city','City',false],['postal_code','Postal code',false],['phone','Phone (optional)',true]] as [keyof Addr, string, boolean][]).map(([key, label, full]) => (
|
||||||
|
<input key={key} value={addr[key]} onChange={e => setAddr(p => ({ ...p, [key]: e.target.value }))} placeholder={label}
|
||||||
|
required={key !== 'phone'} className={(full ? 'col-span-2 ' : '') + 'border border-border rounded-lg px-3 py-2 text-sm bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="w-full bg-primary text-primary-foreground py-3 rounded-lg hover:bg-primary/90 transition-colors font-semibold">Continue to Review</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{step === 'review' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Delivery Details</h2>
|
||||||
|
<button onClick={() => setStep('details')} className="text-sm text-primary hover:underline">Edit</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{email}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{addr.first_name} {addr.last_name}, {addr.address_1}, {addr.city} {addr.postal_code}</p>
|
||||||
|
</div>
|
||||||
|
{orderError && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{orderError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={handlePlaceOrder} disabled={placing}
|
||||||
|
className="w-full bg-primary text-primary-foreground py-3 rounded-lg hover:bg-primary/90 transition-colors font-semibold disabled:opacity-60 flex items-center justify-center gap-2">
|
||||||
|
{placing ? <><Loader2 className="h-4 w-4 animate-spin" />Placing Order...</> : 'Place Order'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-center text-muted-foreground">Your order is confirmed and payment processed securely.</p>
|
||||||
|
</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-lg font-bold mb-4 text-foreground">Order Summary</h2>
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{items.map(item => (
|
||||||
|
<div key={item.id} className="flex gap-3 items-start">
|
||||||
|
{item.thumbnail && <img src={item.thumbnail} alt={(item as any).product_title || item.title} className="w-12 h-12 object-cover rounded-md flex-shrink-0" />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground line-clamp-1">{(item as any).product_title || item.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Qty: {item.quantity}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">{formatPrice(item.unit_price * item.quantity, cart?.region?.currency_code)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-4 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex justify-between"><span>Subtotal</span><span className="text-foreground font-medium">{formatPrice(subtotal, cart?.region?.currency_code)}</span></div>
|
||||||
|
<div className="flex justify-between"><span>Shipping</span><span>TBD</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border mt-3 pt-3 flex justify-between font-bold text-foreground">
|
||||||
|
<span>Total</span><span className="text-primary">{formatPrice(subtotal, cart?.region?.currency_code)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user