Deploy
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Check, Minus, Plus, ShoppingBag } from 'lucide-react'
|
||||
import { useMedusaCart } from '@/hooks/useMedusaCart'
|
||||
import YouMayAlsoLike from '@/components/shop/YouMayAlsoLike'
|
||||
|
||||
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)}`
|
||||
}
|
||||
|
||||
interface MedusaVariant { id: string; title: string; prices: Array<{ amount: number; currency_code: string }> }
|
||||
interface MedusaProduct {
|
||||
id: string; title: string; description: string | null
|
||||
thumbnail: string | null; images?: Array<{ url: string }>
|
||||
variants: MedusaVariant[]; collection?: { id: string; title: string }
|
||||
}
|
||||
|
||||
export default function ProductDetailClient({ product: initialProduct, productId }: { product: MedusaProduct | null; productId: string }) {
|
||||
const [product, setProduct] = useState<MedusaProduct | null>(initialProduct)
|
||||
const [loading, setLoading] = useState(!initialProduct)
|
||||
const [selectedVariant, setSelectedVariant] = useState<MedusaVariant | null>(initialProduct?.variants?.[0] ?? null)
|
||||
const [activeImage, setActiveImage] = useState(0)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [added, setAdded] = useState(false)
|
||||
const { addToCart } = useMedusaCart()
|
||||
|
||||
useEffect(() => {
|
||||
if (initialProduct) return
|
||||
const run = async () => {
|
||||
try {
|
||||
const regionRes = await fetch('/api/medusa/store/regions?limit=1')
|
||||
const regionData = regionRes.ok ? await regionRes.json() : {}
|
||||
const regionId = regionData?.regions?.[0]?.id
|
||||
const params = new URLSearchParams({ fields: '+variants.prices.amount,+variants.prices.currency_code,+collection,+categories,+images' })
|
||||
if (regionId) params.set('region_id', regionId)
|
||||
const res = await fetch(`/api/medusa/store/products/${productId}?${params}`)
|
||||
if (!res.ok) { setLoading(false); return }
|
||||
const { product: p } = await res.json()
|
||||
p.variants = (p.variants ?? []).map((v: any) => ({
|
||||
...v,
|
||||
prices: v.prices?.length ? v.prices : v.calculated_price
|
||||
? [{ amount: v.calculated_price.calculated_amount, currency_code: v.calculated_price.currency_code }]
|
||||
: [],
|
||||
}))
|
||||
setProduct(p)
|
||||
setSelectedVariant(p.variants?.[0] ?? null)
|
||||
} catch {
|
||||
setProduct(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
run()
|
||||
}, [productId, initialProduct])
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (!selectedVariant?.id) return
|
||||
await addToCart(selectedVariant.id, quantity)
|
||||
setAdded(true)
|
||||
setTimeout(() => setAdded(false), 2500)
|
||||
window.dispatchEvent(new CustomEvent('cart-drawer-open'))
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!product) return (
|
||||
<div className="text-center py-20">
|
||||
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground mb-4">Product not found.</p>
|
||||
<Link href="/products" className="text-primary hover:underline">Back to Products</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
const images = [product.thumbnail, ...(product.images?.map(i => i.url) ?? [])].filter(Boolean) as string[]
|
||||
const price = selectedVariant?.prices?.[0]
|
||||
const mainImage = images[activeImage] ?? images[0]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20">
|
||||
<div className="space-y-3">
|
||||
<div className="relative aspect-square overflow-hidden rounded-2xl bg-muted">
|
||||
{mainImage ? (
|
||||
<Image src={mainImage} alt={product.title} fill sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover" priority />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ShoppingBag className="h-20 w-20 text-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{images.map((url, i) => (
|
||||
<button key={i} onClick={() => setActiveImage(i)}
|
||||
className={`relative flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${activeImage === i ? 'border-primary' : 'border-transparent hover:border-border'}`}>
|
||||
<Image src={url} alt={`${product.title} view ${i + 1}`} fill sizes="64px" className="object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{product.collection?.title && (
|
||||
<span className="text-sm text-primary font-medium mb-2">{product.collection.title}</span>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-foreground mb-4">{product.title}</h1>
|
||||
{price && (
|
||||
<p className="text-3xl font-bold text-primary mb-6">
|
||||
{formatPrice(price.amount, price.currency_code)}
|
||||
</p>
|
||||
)}
|
||||
{product.description && (
|
||||
<p className="text-muted-foreground mb-8 leading-relaxed">{product.description}</p>
|
||||
)}
|
||||
{product.variants.length > 1 && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-semibold text-foreground mb-3">Option</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.variants.map(v => (
|
||||
<button key={v.id} onClick={() => setSelectedVariant(v)}
|
||||
className={`px-4 py-2 rounded-lg border text-sm font-medium transition-colors ${selectedVariant?.id === v.id ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-foreground hover:border-primary/50'}`}>
|
||||
{v.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 mb-4 mt-auto">
|
||||
<div className="flex items-center border border-border rounded-xl overflow-hidden">
|
||||
<button onClick={() => setQuantity(q => Math.max(1, q - 1))} className="px-3 py-3 hover:bg-muted transition-colors" aria-label="Decrease quantity">
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="w-10 text-center text-sm font-semibold">{quantity}</span>
|
||||
<button onClick={() => setQuantity(q => Math.min(10, q + 1))} className="px-3 py-3 hover:bg-muted transition-colors" aria-label="Increase quantity">
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleAddToCart} disabled={!selectedVariant?.id || added}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-semibold text-base transition-all ${added ? 'bg-green-600 text-white' : 'bg-primary text-primary-foreground hover:bg-primary/90'} disabled:opacity-60`}>
|
||||
{added ? (<><Check className="h-5 w-5" /> Added!</>) : 'Add to Cart'}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => window.dispatchEvent(new CustomEvent('cart-drawer-open'))} className="text-center text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
View Cart →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<YouMayAlsoLike currentProductId={product.id} collectionId={product.collection?.id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user