Deploy
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
'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 { useVulaCart, type VulaCartItem } from '@/hooks/useVulaCart'
|
||||||
|
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 VulaVariant { id: string; title: string; prices: Array<{ amount: number; currency_code: string }> }
|
||||||
|
interface VulaProductDetail {
|
||||||
|
id: string; title: string; description: string | null
|
||||||
|
thumbnail: string | null; images?: Array<{ url: string }>
|
||||||
|
variants: VulaVariant[]; collection?: { id: string; title: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailClient({ product: initialProduct, productId }: { product: VulaProductDetail | null; productId: string }) {
|
||||||
|
const [product, setProduct] = useState<VulaProductDetail | null>(initialProduct)
|
||||||
|
const [loading, setLoading] = useState(!initialProduct)
|
||||||
|
const [selectedVariant, setSelectedVariant] = useState<VulaVariant | null>(initialProduct?.variants?.[0] ?? null)
|
||||||
|
const [activeImage, setActiveImage] = useState(0)
|
||||||
|
const [quantity, setQuantity] = useState(1)
|
||||||
|
const [added, setAdded] = useState(false)
|
||||||
|
const { addToCart } = useVulaCart()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialProduct) return
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/products?id[]=${productId}`)
|
||||||
|
if (!res.ok) { setLoading(false); return }
|
||||||
|
const data = await res.json()
|
||||||
|
const p = data.product ?? (data.products?.[0] ?? null)
|
||||||
|
if (p) {
|
||||||
|
setProduct(p)
|
||||||
|
setSelectedVariant(p.variants?.[0] ?? null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setProduct(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
}, [productId, initialProduct])
|
||||||
|
|
||||||
|
const handleAddToCart = async () => {
|
||||||
|
if (!selectedVariant?.id || !product) return
|
||||||
|
const price = selectedVariant.prices?.[0]
|
||||||
|
const item: VulaCartItem = {
|
||||||
|
variantId: selectedVariant.id,
|
||||||
|
productId: product.id,
|
||||||
|
title: product.title,
|
||||||
|
variant_title: selectedVariant.title ?? '',
|
||||||
|
unit_price: price?.amount ?? 0,
|
||||||
|
quantity,
|
||||||
|
thumbnail: product.thumbnail ?? null,
|
||||||
|
}
|
||||||
|
for (let i = 0; i < quantity; i++) { addToCart({ ...item, quantity: 1 }) }
|
||||||
|
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 = [...new Set([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