Rename ssr to website, website to spa, add spa nginx config

This commit is contained in:
Your Name committed 2026-06-03 09:38:15 +07:00
1 parent fc6bf0218e
commit 8514342c2f
103 files changed
+801 -768

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '../types'
import { useCartStore } from '../store/cartStore'
export default function ProductDetailPage() {
const { id } = useParams<{ id: string }>()
const [categories, setCategories] = useState<Category[]>([])
const [product, setProduct] = useState<Product | null>(null)
const [related, setRelated] = useState<Product[]>([])
const [selectedVariant, setSelectedVariant] = useState<number>(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { addToCart } = useCartStore()
useEffect(() => {
fetch(`${API_BASE}/categories`)
.then((r) => r.json())
.then(setCategories)
.catch(console.error)
}, [])
useEffect(() => {
if (!id) return
setLoading(true)
setError(null)
setSelectedVariant(0)
fetch(`${API_BASE}/products/${id}`)
.then(async (r) => {
if (!r.ok) throw new Error('Product not found')
return r.json()
})
.then(async (data: Product) => {
setProduct(data)
// Fetch related (same category, different product)
const relRes = await fetch(`${API_BASE}/products?categoryId=${data.category.id}`)
const relJson = await relRes.json()
const relData: Product[] = relJson.data || []
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6))
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [id])
if (loading) {
return (
<div className="min-h-screen">
<Navbar categories={categories} />
<div className="pt-32 px-6 max-w-7xl mx-auto animate-pulse space-y-8">
<div className="h-6 w-48 bg-white/5 rounded" />
<div className="grid md:grid-cols-2 gap-12">
<div className="h-96 bg-white/5 rounded-2xl" />
<div className="space-y-4">
<div className="h-8 w-3/4 bg-white/5 rounded" />
<div className="h-4 w-1/2 bg-white/5 rounded" />
<div className="h-12 w-1/3 bg-white/5 rounded" />
</div>
</div>
</div>
<Footer categories={categories} />
</div>
)
}
if (error || !product) {
return (
<div className="min-h-screen">
<Navbar categories={categories} />
<div className="pt-40 text-center space-y-4">
<p className="text-4xl">⚠️</p>
<p className="text-slate-400">{error ?? 'Product not found'}</p>
<Link to="/shop" className="text-cyan-glow hover:underline text-sm">
Back to Shop
</Link>
</div>
<Footer categories={categories} />
</div>
)
}
const currentVariant = product.variants[selectedVariant]
const handleAddToCart = () => {
if (!product || !currentVariant) return
addToCart({
productId: product.id,
variantId: currentVariant.id,
quantity: 1,
price: currentVariant.price || product.price || 0,
productName: product.scent.name,
variantSize: currentVariant.size,
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
})
}
return (
<div className="min-h-screen pb-24 md:pb-0">
<Navbar categories={categories} />
<div className="max-w-7xl mx-auto px-6 pt-32 pb-24 space-y-24">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-slate-400 uppercase tracking-widest">
<Link to="/" className="hover:text-white transition-colors">Home</Link>
<span>/</span>
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
<span>/</span>
<Link to={`/category/${product.category.id}`} className="hover:text-white transition-colors">
{product.category.name}
</Link>
<span>/</span>
<span className="text-cyan-glow">{product.scent.name}</span>
</div>
{/* Product Detail */}
<div className="grid md:grid-cols-2 gap-16 items-center">
{/* Image */}
<div className="relative rounded-2xl overflow-hidden h-[480px] group">
<img
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={product.scent.name}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-night-900/50 to-transparent" />
<div className="absolute top-6 left-6 glass px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-cyan-glow border border-cyan-glow/20">
{product.category.name}
</div>
</div>
{/* Info */}
<div className="space-y-8">
<div className="space-y-2">
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-80">
{product.category.name}
</p>
<h1 className="text-5xl font-bold leading-tight">
{product.scent.name}
</h1>
<p className="text-slate-400 text-sm uppercase tracking-widest">
Premium Collection
</p>
</div>
{product.description && (
<p className="text-slate-300 leading-relaxed">{product.description}</p>
)}
{/* Price */}
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-cyan-glow">
{formatPrice(currentVariant?.price)}
</span>
{currentVariant && (
<span className="text-slate-400 text-sm">/ {currentVariant.size}</span>
)}
</div>
{/* Variant Selector */}
<div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-slate-400 font-semibold">
Select Size
</p>
<div className="flex flex-wrap gap-3">
{product.variants.map((v, i) => (
<button
key={v.id}
onClick={() => setSelectedVariant(i)}
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 ${
selectedVariant === i
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.4)]'
: 'glass hover:bg-white/10'
}`}
>
{v.size}
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-night-800' : 'text-slate-400'}`}>
{formatPrice(v.price)}
</span>
</button>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-4">
<button
onClick={handleAddToCart}
className="flex-1 py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500"
>
Add to Cart
</button>
<button className="glass p-4 rounded-lg hover:bg-white/10 transition-all">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
</div>
{/* Meta */}
<div className="border-t border-white/5 pt-6 grid grid-cols-2 gap-4 text-sm text-slate-400">
<div>
<span className="text-xs uppercase tracking-widest block mb-1">Scent</span>
<span className="text-white font-medium">{product.scent.name}</span>
</div>
<div>
<span className="text-xs uppercase tracking-widest block mb-1">Category</span>
<Link
to={`/category/${product.category.id}`}
className="text-white font-medium hover:text-cyan-glow transition-colors"
>
{product.category.name}
</Link>
</div>
</div>
</div>
</div>
{/* Full Details Section */}
{product.details && (
<div className="pt-16 border-t border-white/5 space-y-6">
<h2 className="text-3xl font-bold">Product Details</h2>
<div
className="prose prose-invert prose-lg max-w-4xl product-editorial"
dangerouslySetInnerHTML={{ __html: product.details }}
/>
</div>
)}
{/* Related Products */}
{related.length > 0 && (
<div className="space-y-8 border-t border-white/5 pt-16">
<div className="flex justify-between items-end">
<div>
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70 mb-2">
More {product.category.name}
</p>
<h2 className="text-3xl font-bold">Related Products</h2>
</div>
<Link
to={`/category/${product.category.id}`}
className="text-cyan-glow text-sm font-bold uppercase tracking-widest hover:underline"
>
View All →
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
{related.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</div>
)}
</div>
<Footer categories={categories} />
{/* Mobile Sticky Add to Cart Bar */}
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 bg-night-950/95 backdrop-blur-xl border-t border-white/10 z-40 animate-fade-in flex flex-col gap-3 shadow-[0_-10px_30px_rgba(0,0,0,0.5)]">
{/* Mobile Variant Selector */}
{product.variants && product.variants.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
{product.variants.map((v, i) => (
<button
key={`mobile-v-${v.id}`}
onClick={() => setSelectedVariant(i)}
className={`flex-shrink-0 px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all border whitespace-nowrap ${
selectedVariant === i
? 'bg-cyan-glow/10 border-cyan-glow text-cyan-glow'
: 'bg-white/5 border-white/10 text-slate-400'
}`}
>
{v.size}
</button>
))}
</div>
)}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-slate-400">
{product.variants && product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
</span>
<span className="text-xl font-bold text-cyan-glow">
{formatPrice(currentVariant?.price)}
</span>
</div>
<button
onClick={handleAddToCart}
className="flex-1 py-3.5 bg-cyan-glow text-night-900 font-bold rounded-lg tracking-widest uppercase hover:brightness-110 shadow-[0_0_15px_rgba(102,252,241,0.3)] transition-all flex justify-center items-center gap-2"
>
Add to Cart
</button>
</div>
</div>
</div>
)
}