Initial commit for homaroma
This commit is contained in:
commit
f2179cfe57
269 files changed
+22635
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
icon: '🌱',
|
||||
title: 'Pure Origins',
|
||||
desc: 'Every extract is sourced directly from certified organic farms in Northern Thailand, Vietnam, and Southern France. We work exclusively with small-batch artisan growers.',
|
||||
},
|
||||
{
|
||||
icon: '⚗️',
|
||||
title: 'Artisan Craft',
|
||||
desc: 'Each formula is cold-extracted and hand-blended in our Bangkok studio. No heat processing means every aromatic molecule remains intact and therapeutically potent.',
|
||||
},
|
||||
{
|
||||
icon: '♾️',
|
||||
title: 'Zero Compromise',
|
||||
desc: 'No synthetic fragrance. No parabens. No animal testing. Our commitment to clean formulation is absolute — what touches your senses should honour your wellbeing.',
|
||||
},
|
||||
]
|
||||
|
||||
const STATS = [
|
||||
{ value: '45+', label: 'Unique Scents' },
|
||||
{ value: '5', label: 'Product Lines' },
|
||||
{ value: '2019', label: 'Founded' },
|
||||
{ value: '100%', label: 'Natural Extracts' },
|
||||
]
|
||||
|
||||
export const metadata = {
|
||||
title: 'About Us | iRelax Aroma',
|
||||
description: 'Born from a passion for stillness. iRelax began in 2019 as a Bangkok studio crafting botanical aroma blends.',
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero */}
|
||||
<section className="relative min-h-[60vh] flex items-center justify-center overflow-hidden pt-24">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-night-800/60 via-night-900 to-night-900" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{ backgroundImage: 'radial-gradient(circle at 30% 50%, rgba(102,252,241,0.3) 0%, transparent 60%), radial-gradient(circle at 80% 20%, rgba(69,162,158,0.2) 0%, transparent 50%)' }}
|
||||
/>
|
||||
<div className="relative z-10 text-center px-6 max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Our Story</p>
|
||||
<h1 className="text-6xl font-bold leading-tight">
|
||||
Born from a passion for <span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-glow to-night-700 italic font-serif">stillness</span>
|
||||
</h1>
|
||||
<p className="text-slate-300 text-lg leading-relaxed">
|
||||
iRelax began in 2019 as a one-person studio in Bangkok, crafting small batches of botanical aroma blends for friends and family. What started as a personal journey toward calm became a philosophy — that our environment shapes how we feel, think, and rest.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Band */}
|
||||
<section className="py-12 px-6 bg-white/3 border-y border-white/5">
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
||||
{STATS.map(s => (
|
||||
<div key={s.label} className="space-y-1">
|
||||
<div className="text-4xl font-bold text-cyan-glow">{s.value}</div>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-400">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission Section */}
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-16 items-center">
|
||||
<div className="space-y-6">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70">Our Mission</p>
|
||||
<h2 className="text-4xl font-bold leading-tight">Transforming spaces into sanctuaries of peace</h2>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
We believe that scent is the fastest pathway to emotional transformation. A single breath of the right blend can dissolve anxiety, sharpen focus, or unlock creativity. Our mission is to make that transformation accessible — through products that are as beautiful as they are effective.
|
||||
</p>
|
||||
<p className="text-slate-400 leading-relaxed text-sm">
|
||||
Every iRelax product is designed with intention: the glass bottles are reusable, the packaging is recycled, and the formulas are written to be understood by anyone — not hidden behind chemical jargon.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-80 rounded-2xl overflow-hidden glass border border-white/5">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-900/30 via-night-800/30 to-purple-900/20" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-7xl">🌿</div>
|
||||
<p className="text-white/30 text-sm uppercase tracking-widest">iRelax Studio, Bangkok</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Three Pillars */}
|
||||
<section className="py-24 px-6 bg-night-800/20 border-y border-white/5">
|
||||
<div className="max-w-6xl mx-auto space-y-12">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70">What We Stand For</p>
|
||||
<h2 className="text-4xl font-bold">Three Pillars</h2>
|
||||
<div className="w-12 h-px bg-cyan-glow mx-auto" />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{PILLARS.map(p => (
|
||||
<div key={p.title} className="glass rounded-2xl p-8 border border-white/5 hover:border-cyan-glow/20 transition-all duration-500 space-y-4">
|
||||
<div className="text-4xl">{p.icon}</div>
|
||||
<h3 className="text-xl font-bold">{p.title}</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">{p.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-24 px-6 text-center space-y-6">
|
||||
<h2 className="text-4xl font-bold">Ready to find your scent?</h2>
|
||||
<p className="text-slate-400 max-w-md mx-auto">Let our Scent Guide help you discover the perfect blend for your moment.</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
|
||||
<Link href="/scent-guide" className="px-10 py-4 bg-white text-night-900 font-bold rounded-sm tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_30px_rgba(102,252,241,0.4)] transition-all duration-500">
|
||||
Take the Scent Guide
|
||||
</Link>
|
||||
<Link href="/shop" className="px-10 py-4 glass text-white font-bold rounded-sm tracking-widest uppercase hover:bg-white/10 transition-all duration-300">
|
||||
Shop All Products
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { Article } from '@irelax/types'
|
||||
|
||||
import { API_BASE } from '../../../lib/api'
|
||||
|
||||
async function getArticle(slug: string): Promise<Article | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/articles/${slug}`, { next: { revalidate: 300 } })
|
||||
return res.ok ? res.json() : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const article = await getArticle(slug)
|
||||
if (!article) return { title: 'Article Not Found | iRelax' }
|
||||
return {
|
||||
title: `${article.title} | iRelax Scent Guide`,
|
||||
description: article.content.replace(/<[^>]+>/g, '').slice(0, 160),
|
||||
openGraph: {
|
||||
title: article.title,
|
||||
images: article.coverImage ? [article.coverImage] : [],
|
||||
type: 'article',
|
||||
publishedTime: article.createdAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ArticleReaderPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const article = await getArticle(slug)
|
||||
|
||||
if (!article) notFound()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-night-950">
|
||||
<main className="flex-1 pt-24 pb-20">
|
||||
<article className="max-w-4xl mx-auto w-full animate-fade-in relative mt-8">
|
||||
|
||||
{/* Desktop back button */}
|
||||
<Link
|
||||
href="/scent-guide"
|
||||
className="absolute -left-16 xl:-left-24 top-0 mt-8 hidden lg:flex p-4 glass border border-white/5 rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-slate-300" />
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center px-6 py-12 space-y-6">
|
||||
<Link href="/scent-guide" className="lg:hidden text-slate-400 text-sm hover:text-white inline-flex items-center gap-2 mb-4">
|
||||
<ArrowLeft className="w-4 h-4" /> Go Back
|
||||
</Link>
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] font-bold text-xs">
|
||||
{new Date(article.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight max-w-3xl mx-auto font-serif">
|
||||
{article.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Cover Image */}
|
||||
{article.coverImage && (
|
||||
<div className="w-full aspect-[16/7] bg-night-900 md:rounded-[2rem] overflow-hidden shadow-2xl mb-16 border border-white/5">
|
||||
<img src={article.coverImage} alt={article.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="px-6 md:px-12 prose prose-invert prose-lg prose-cyan mx-auto max-w-3xl pb-16">
|
||||
<div dangerouslySetInnerHTML={{ __html: article.content }} className="space-y-6" />
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import ProductCard from '../../../components/ProductCard'
|
||||
import { getCategoryImage, type Category, type Product } from '@irelax/types'
|
||||
|
||||
import { API_BASE } from '../../../lib/api'
|
||||
|
||||
export default function CategoryPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const id = params.id
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [category, setCategory] = useState<Category | null>(null)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [displayCount, setDisplayCount] = useState(24)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
setDisplayCount(24)
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/categories/${id}/products?limit=1000`),
|
||||
fetch(`${API_BASE}/categories`),
|
||||
])
|
||||
.then(async ([catRes, allCatsRes]) => {
|
||||
if (!catRes.ok) throw new Error('Category not found')
|
||||
const catPaginated = await catRes.json()
|
||||
const allCats: Category[] = await allCatsRes.json()
|
||||
setCategory(catPaginated.category)
|
||||
setProducts(catPaginated.data || [])
|
||||
setCategories(allCats)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && products.length > displayCount)
|
||||
setDisplayCount((prev) => Math.min(prev + 12, products.length))
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '200px' }
|
||||
)
|
||||
if (loaderRef.current) observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [products.length, displayCount, loading])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Banner */}
|
||||
<div className="relative h-64 md:h-80 overflow-hidden">
|
||||
<img
|
||||
src={category?.imageUrl || getCategoryImage(category?.name ?? '')}
|
||||
className="absolute inset-0 w-full h-full object-cover brightness-30 scale-105"
|
||||
alt={category?.name}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900 via-night-900/60 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 px-6 pb-10 pt-20">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 uppercase tracking-widest mb-4">
|
||||
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||
<span>/</span>
|
||||
<Link href="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||
<span>/</span>
|
||||
<span className="text-cyan-glow">{category?.name ?? '...'}</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold">
|
||||
{loading ? <div className="h-10 w-48 bg-white/10 animate-pulse rounded-lg" /> : category?.name}
|
||||
</h1>
|
||||
{!loading && category?._count && (
|
||||
<p className="text-slate-400 mt-2">{category._count.products} products available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/category/${cat.id}`}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
cat.id === category?.id
|
||||
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
|
||||
: 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="text-center py-24 space-y-3">
|
||||
<p className="text-4xl">⚠️</p>
|
||||
<p className="text-slate-400">{error}</p>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Back to Shop</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-3">
|
||||
<p className="text-4xl">🌿</p>
|
||||
<p>No products in this category yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{products.slice(0, displayCount).map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
{displayCount < products.length && (
|
||||
<div ref={loaderRef} className="py-12 flex justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us | iRelax Aroma',
|
||||
description: 'Get in touch with iRelax. We respond to all inquiries within 2 hours.',
|
||||
}
|
||||
|
||||
export default function ContactLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
|
||||
type FormState = { name: string; email: string; subject: string; message: string }
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error'
|
||||
const SUBJECTS = ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other']
|
||||
|
||||
|
||||
|
||||
export default function ContactPage() {
|
||||
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus('sending')
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
setStatus('sent')
|
||||
setForm({ name: '', email: '', subject: '', message: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/20">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Get in Touch</p>
|
||||
<h1 className="text-5xl font-bold">Contact Us</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">Have a question, custom request, or partnership idea? We'd love to hear from you.</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-16 grid md:grid-cols-5 gap-16">
|
||||
{/* Contact Info */}
|
||||
<div className="md:col-span-2 space-y-8">
|
||||
<div>
|
||||
<h2 className="font-bold text-xl mb-6">Reach Us Directly</h2>
|
||||
<div className="space-y-5">
|
||||
{[
|
||||
{ icon: '📧', label: 'Email', value: 'hello@irelax.co.th', href: 'mailto:hello@irelax.co.th' },
|
||||
{ icon: '📱', label: 'LINE Official', value: '@irelaxessential', href: '#' },
|
||||
{ icon: '📸', label: 'Instagram', value: '@irelax.essential', href: '#' },
|
||||
{ icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' },
|
||||
].map(item => (
|
||||
<a key={item.label} href={item.href} className="flex items-start gap-4 group">
|
||||
<div className="glass w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0 group-hover:bg-cyan-glow/10 transition-colors">{item.icon}</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">{item.label}</p>
|
||||
<p className="text-white text-sm font-medium group-hover:text-cyan-glow transition-colors">{item.value}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass rounded-2xl p-6 border border-white/5 space-y-3">
|
||||
<h3 className="font-bold text-sm uppercase tracking-widest">Response Time</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{[['Email', '~2 hours'], ['LINE', '~30 minutes'], ['Instagram DM', '~4 hours']].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span className="text-slate-400">{k}</span>
|
||||
<span className="text-cyan-glow font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 pt-2 uppercase tracking-wider">Mon–Sat, 9:00–18:00 (ICT)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="md:col-span-3">
|
||||
{status === 'sent' ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center space-y-5 py-16">
|
||||
<div className="text-6xl animate-bounce">✉️</div>
|
||||
<h2 className="text-2xl font-bold">Message Sent!</h2>
|
||||
<p className="text-slate-400">Thank you for reaching out. We'll get back to you within 2 hours.</p>
|
||||
<button onClick={() => setStatus('idle')} className="mt-4 px-8 py-3 glass border border-white/10 hover:border-cyan-glow/30 font-bold uppercase tracking-widest text-xs transition-all rounded-lg">
|
||||
Send Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Full Name *</label>
|
||||
<input required type="text" name="name" value={form.name} onChange={handleChange} placeholder="Your name"
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Email *</label>
|
||||
<input required type="email" name="email" value={form.email} onChange={handleChange} placeholder="your@email.com"
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Subject *</label>
|
||||
<select required name="subject" value={form.subject} onChange={handleChange}
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-night-900 outline-none text-sm text-white transition-colors">
|
||||
<option value="" disabled>Select a subject</option>
|
||||
{SUBJECTS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Message *</label>
|
||||
<textarea required name="message" value={form.message} onChange={handleChange} rows={6} placeholder="Tell us about your inquiry..."
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 resize-none transition-colors" />
|
||||
</div>
|
||||
<button type="submit" disabled={status === 'sending'}
|
||||
className="w-full py-4 bg-white text-night-900 font-bold uppercase tracking-widest text-sm rounded-xl hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3">
|
||||
{status === 'sending' ? (
|
||||
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>Sending...</>
|
||||
) : 'Send Message →'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import Link from 'next/link'
|
||||
import { Ticket, ArrowRight } from 'lucide-react'
|
||||
|
||||
// Fetch active coupons with ISR (revalidate every 60s)
|
||||
async function getActiveCoupons() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/coupons/active`, { next: { revalidate: 60 } })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
// Filter out ones that hit usageLimit
|
||||
return data.filter((c: any) => c.usageLimit === null || c.usedCount < c.usageLimit)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch coupons:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiscount(type: string, value: number) {
|
||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
|
||||
if (type === 'FREE_SHIPPING') return `ส่งฟรี`
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'โปรโมชั่นและคูปองส่วนลด | iRelax',
|
||||
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก iRelax สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
|
||||
}
|
||||
|
||||
export default async function CouponsPage() {
|
||||
const coupons = await getActiveCoupons()
|
||||
|
||||
return (
|
||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 min-h-[70vh]">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-white via-cyan-glow to-blue-500 bg-clip-text text-transparent">
|
||||
Exclusive Offers
|
||||
</h1>
|
||||
<p className="text-slate-400 max-w-2xl mx-auto">
|
||||
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{coupons.length === 0 ? (
|
||||
<div className="text-center py-20 bg-night-900/50 rounded-3xl border border-white/5">
|
||||
<Ticket className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">ยังไม่มีคูปองส่วนลดในขณะนี้</h2>
|
||||
<p className="text-slate-400 mb-6">กรุณากลับมาตรวจสอบโปรโมชั่นใหม่ในภายหลัง</p>
|
||||
<a
|
||||
href="https://irelaxaroma.shop/shop"
|
||||
className="inline-flex px-8 py-3 bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow hover:text-night-900 font-bold rounded-xl transition-all"
|
||||
>
|
||||
ไปช้อปปิ้งกันก่อน
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{coupons.map((coupon: any) => (
|
||||
<div
|
||||
key={coupon.id}
|
||||
className="group relative bg-night-900 border border-white/10 rounded-3xl overflow-hidden hover:border-cyan-glow/50 transition-all duration-500"
|
||||
>
|
||||
{/* Decoration */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-glow/10 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
|
||||
<div className="p-8 space-y-6 relative z-10">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="w-12 h-12 rounded-xl bg-cyan-glow/10 text-cyan-glow flex items-center justify-center border border-cyan-glow/20">
|
||||
<Ticket className="w-6 h-6" />
|
||||
</div>
|
||||
{coupon.expiresAt && (
|
||||
<span className="text-[10px] font-bold text-amber-400 bg-amber-500/10 px-2 py-1 rounded-md uppercase tracking-wider">
|
||||
หมดเขต {new Date(coupon.expiresAt).toLocaleDateString('th-TH')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-white mb-1">
|
||||
{formatDiscount(coupon.type, coupon.value)}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
เมื่อซื้อครบ {coupon.minPurchase} บาท
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-night-950 p-3 rounded-xl border border-white/5 justify-center">
|
||||
<span className="font-mono text-cyan-glow font-bold tracking-widest uppercase">
|
||||
{coupon.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`https://irelaxaroma.shop/shop?coupon=${coupon.code}`}
|
||||
className="w-full py-4 bg-white/5 hover:bg-cyan-glow text-white hover:text-night-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-all duration-300"
|
||||
>
|
||||
ช้อปพร้อมใช้คูปองเลย
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Scalloped edge effect */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-r border-white/10" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-l border-white/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FAQ | iRelax Aroma',
|
||||
description: 'Everything you need to know about iRelax products, shipping, and care.',
|
||||
}
|
||||
|
||||
export default function FaqLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
category: 'Products', icon: '🌿',
|
||||
items: [
|
||||
{ q: 'What are the ingredients in your aroma products?', a: 'All iRelax products are crafted from 100% natural botanical extracts, pure essential oils, and pharmaceutical-grade carrier bases. We never use synthetic fragrances, parabens, or harmful chemicals. Each product label lists full ingredient transparency.' },
|
||||
{ q: 'How long does each product last?', a: 'Our Reed Diffusers last 4–8 weeks depending on room size and airflow. Aroma Oils (30ml) last approximately 3–4 months with normal use. Room Sprays (100ml) provide 60–80 uses. Car Diffusers cartridges last up to 45 days.' },
|
||||
{ q: 'Are your products suitable for sensitive skin or allergies?', a: 'We recommend patch testing before topical use. Our Aroma Oils are formulated for diffuser use primarily. If you have known fragrance allergies, consult our scent guide or contact us with your specific concerns.' },
|
||||
{ q: 'What is the difference between Aroma Oil and Oil Ozone?', a: 'Aroma Oils are pure essential oil blends designed for aromatherapy diffusers and therapeutic use. Oil Ozone is our premium line featuring ozone-infused botanical extracts — designed for air purification and deeper space transformation.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Usage', icon: '💡',
|
||||
items: [
|
||||
{ q: 'How do I use a Reed Diffuser?', a: 'Remove the stopper, place the reeds into the bottle, and let them absorb the oil for 1 hour before flipping. For optimal fragrance, flip the reeds every 2–3 days. Keep away from direct sunlight and air conditioning vents.' },
|
||||
{ q: 'Can I mix different scents?', a: 'Yes! Blending scents is an art form we encourage. A general rule: combine no more than 3 scents at a time, using one dominant (60%), one supporting (30%), and one accent (10%). Our Scent Guide can help you find complementary combinations.' },
|
||||
{ q: 'How long should I run my aroma diffuser?', a: 'For best results, we recommend 30–60 minute sessions with 30-minute breaks in between. This also extends product life and prevents overwhelming fragrance build-up in small spaces.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Shipping & Orders', icon: '📦',
|
||||
items: [
|
||||
{ q: 'How long does shipping take?', a: 'Standard delivery within Bangkok takes 1–2 business days. Provincial orders take 2–4 business days. Express same-day delivery is available in Bangkok for orders placed before 12:00 PM.' },
|
||||
{ q: 'Do you ship internationally?', a: 'Currently we ship within Thailand only. International shipping is planned for late 2025. Sign up for our newsletter to be notified when we expand to your region.' },
|
||||
{ q: 'What is your return policy?', a: 'We accept returns within 14 days of purchase for unopened, undamaged products in original packaging. For defective or damaged products, we offer full replacement at no cost.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Scent Guide', icon: '🧪',
|
||||
items: [
|
||||
{ q: 'How do I choose the right scent for my space?', a: 'Consider the function of the space first. Bedrooms benefit from calming scents like lavender and chamomile. Living rooms work well with warm notes like sandalwood and vanilla. Home offices pair best with focus-enhancing eucalyptus and citrus.' },
|
||||
{ q: 'Can I request custom scent blending?', a: 'Yes! Our bespoke blending service is available for orders of 6 units or more. Contact us at least 10 business days before your desired delivery date. Custom blending fees start at ฿500 per unique formula.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function FaqPage() {
|
||||
const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
|
||||
const [activeCategory, setActiveCategory] = useState<string>('Products')
|
||||
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
const activeFaqs = FAQS.find(f => f.category === activeCategory)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/20">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Support</p>
|
||||
<h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">Everything you need to know about iRelax products, shipping, and care.</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-16 space-y-10">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{FAQS.map(({ category, icon }) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 flex items-center gap-2 ${
|
||||
activeCategory === category ? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]' : 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span> {category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{activeFaqs?.items.map((item, i) => {
|
||||
const key = `${activeCategory}-${i}`
|
||||
const isOpen = openItems[key]
|
||||
return (
|
||||
<div key={key} className={`glass rounded-xl overflow-hidden border transition-all duration-300 ${isOpen ? 'border-cyan-glow/20' : 'border-white/5'}`}>
|
||||
<button onClick={() => toggle(key)} className="w-full text-left px-6 py-5 flex justify-between items-start gap-4">
|
||||
<span className="font-semibold text-sm md:text-base leading-snug">{item.q}</span>
|
||||
<svg className={`w-5 h-5 text-cyan-glow shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-45' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="w-full h-px bg-white/5 mb-4" />
|
||||
<p className="text-slate-300 leading-relaxed text-sm">{item.a}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-8 text-center space-y-4 border border-white/5 mt-12">
|
||||
<div className="text-3xl">💬</div>
|
||||
<h3 className="font-bold text-xl">Still have questions?</h3>
|
||||
<p className="text-slate-400 text-sm">Our team is here to help — usually responds within 2 hours.</p>
|
||||
<a href="/contact" className="inline-block px-8 py-3 bg-white text-night-900 font-bold rounded-lg uppercase tracking-widest text-xs hover:bg-cyan-glow transition-colors duration-300">
|
||||
Contact Us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Night Luxury color palette */
|
||||
--color-night-900: #0B0C10;
|
||||
--color-night-800: #1F2833;
|
||||
--color-night-700: #45A29E;
|
||||
--color-cyan-glow: #66FCF1;
|
||||
--color-silver-metallic: #C5C6C7;
|
||||
|
||||
/* Custom animations */
|
||||
--animate-fade-in: fade-in 1.2s ease-out forwards;
|
||||
--animate-float: float 3s ease-in-out infinite;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; transform: translateY(10px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-night-900 text-silver-metallic font-sans antialiased;
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(31, 40, 51, 0.5) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 100%, rgba(11, 12, 16, 1) 0px, transparent 50%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
@apply text-white tracking-tight leading-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply bg-white/5 backdrop-blur-lg border border-white/10;
|
||||
}
|
||||
|
||||
.glass-hover {
|
||||
@apply transition-all duration-300 hover:bg-white/10 hover:border-white/20;
|
||||
}
|
||||
|
||||
.text-glow {
|
||||
text-shadow: 0 0 15px rgba(102, 252, 241, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-night-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-night-800 rounded-full border-2 border-night-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-night-700;
|
||||
}
|
||||
|
||||
/* Smooth Scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Product Editorial Image Overrides (for ReactQuill Content) */
|
||||
.product-editorial img {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 2rem auto;
|
||||
display: block;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import Navbar from '../components/Navbar';
|
||||
import Footer from '../components/Footer';
|
||||
import ClientProviders from '../components/ClientProviders';
|
||||
import { API_BASE } from '../lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'iRelax Aroma',
|
||||
description: 'Pure Origins, Artisan Craft, Zero Compromise',
|
||||
};
|
||||
|
||||
// Fetch categories for Navbar and Footer on the server
|
||||
async function getCategories() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/categories`, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories in layout', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const categories = await getCategories();
|
||||
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth">
|
||||
<body className="bg-night-900 text-silver-metallic font-sans antialiased min-h-screen">
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} />
|
||||
<main className="pt-[72px]">
|
||||
{children}
|
||||
</main>
|
||||
<Footer categories={categories} />
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import Link from 'next/link'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { getCategoryImage, type Category, type Product, type Hero } from '@irelax/types'
|
||||
import { API_BASE } from '../lib/api'
|
||||
|
||||
// Cache settings for Next.js fetch
|
||||
const revalidate = 60; // Revalidate cache every 60 seconds
|
||||
|
||||
async function getCategories(): Promise<Category[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/categories`, { next: { revalidate } })
|
||||
if (!res.ok) return []
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : []
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
async function getFeaturedProducts(): Promise<Product[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products?categoryId=1`, { next: { revalidate } })
|
||||
if (!res.ok) return []
|
||||
const text = await res.text()
|
||||
const json = text ? JSON.parse(text) : { data: [] }
|
||||
return json?.data?.slice(0, 3) || []
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
async function getHero(): Promise<Hero | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/hero`, { next: { revalidate } })
|
||||
if (!res.ok) return null
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : null
|
||||
} catch (e) { return null }
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const [categories, featuredProducts, hero] = await Promise.all([
|
||||
getCategories(),
|
||||
getFeaturedProducts(),
|
||||
getHero(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-video flex items-center justify-center overflow-hidden bg-night-950">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src={hero?.imageUrl || "/images/hero.png"}
|
||||
className="w-full h-full object-cover scale-[1.02] brightness-50"
|
||||
alt="Hero Background"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900 via-transparent to-night-900/50" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center space-y-6 px-4 animate-fade-in">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-sm font-semibold opacity-80">
|
||||
{hero?.tagline || "A New Paradigm of Calm"}
|
||||
</p>
|
||||
<h1 className="text-6xl md:text-8xl font-bold max-w-4xl text-glow leading-[1.1]">
|
||||
{hero ? (
|
||||
hero.title.split('[').map((part, i) => {
|
||||
if (part.includes(']')) {
|
||||
const [accent, rest] = part.split(']')
|
||||
return (
|
||||
<span key={i}>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-white via-silver-metallic to-white/50 italic font-serif">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span key={i}>{part}</span>
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
Inhale{' '}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-white via-silver-metallic to-white/50 italic font-serif">
|
||||
Serenity
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-slate-400 max-w-xl mx-auto text-lg md:text-xl font-light">
|
||||
{hero?.description || "Crafting premium sensory experiences that transform your space into a sanctuary of peace."}
|
||||
</p>
|
||||
<div className="pt-8 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href={hero?.buttonLink || "/shop"}
|
||||
className="px-10 py-4 bg-white text-night-900 font-bold rounded-sm tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_30px_rgba(102,252,241,0.5)] transition-all duration-500"
|
||||
>
|
||||
{hero?.buttonText || "Explore Collection"}
|
||||
</Link>
|
||||
<Link
|
||||
href="#categories"
|
||||
className="px-10 py-4 glass text-white font-bold rounded-sm tracking-widest uppercase hover:bg-white/10 transition-all duration-300"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 animate-bounce opacity-40">
|
||||
<div className="w-px h-16 bg-gradient-to-b from-white/0 via-white to-white/0" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories Showcase */}
|
||||
<section id="categories" className="bg-night-900 py-24 px-6">
|
||||
<div className="max-w-7xl mx-auto space-y-12">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70">
|
||||
Our Range
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold tracking-tight uppercase">The Categories</h2>
|
||||
<div className="w-12 h-px bg-cyan-glow mx-auto mt-3" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/category/${cat.id}`}
|
||||
className="relative h-56 rounded-xl overflow-hidden group border-2 border-white/5 hover:border-cyan-glow/50 hover:shadow-[0_0_20px_rgba(102,252,241,0.15)] transition-all duration-500"
|
||||
>
|
||||
<img
|
||||
src={cat.imageUrl || getCategoryImage(cat.name)}
|
||||
className="absolute inset-0 w-full h-full object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:scale-110 group-hover:opacity-70 transition-all duration-700"
|
||||
alt={cat.name}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/20 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<span className="text-white font-bold tracking-wider uppercase text-xs block">
|
||||
{cat.name}
|
||||
</span>
|
||||
{cat._count && (
|
||||
<span className="text-cyan-glow/60 text-[10px] tracking-wider">
|
||||
{cat._count.products} products
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<svg className="w-5 h-5 text-cyan-glow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Products */}
|
||||
<section className="bg-night-900 py-24 px-6 border-t border-white/5">
|
||||
<div className="max-w-7xl mx-auto space-y-12">
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="space-y-2">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70">
|
||||
Top Picks
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold">Featured Products</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="text-cyan-glow text-sm font-bold uppercase tracking-widest hover:underline decoration-cyan-glow/30"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{featuredProducts.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@irelax/types'
|
||||
import AddToCartSection from '../../../components/AddToCartSection'
|
||||
import ProductCard from '../../../components/ProductCard'
|
||||
|
||||
import { API_BASE } from '../../../lib/api'
|
||||
|
||||
async function getProduct(id: string): Promise<Product | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products/${id}`, { next: { revalidate: 60 } })
|
||||
return res.ok ? res.json() : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products?categoryId=${categoryId}`, { next: { revalidate: 60 } })
|
||||
const json = res.ok ? await res.json() : { data: [] }
|
||||
return (json.data || []).filter((p: Product) => p.id !== excludeId).slice(0, 6)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
// Generate dynamic SEO metadata per product
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
const product = await getProduct(id)
|
||||
if (!product) return { title: 'Product Not Found | iRelax' }
|
||||
return {
|
||||
title: `${product.scent.name} ${product.category.name} | iRelax Aroma`,
|
||||
description: product.description ?? `Premium ${product.category.name} — ${product.scent.name}. Shop iRelax Aroma for high-quality botanical scents.`,
|
||||
openGraph: {
|
||||
title: product.scent.name,
|
||||
description: product.description ?? '',
|
||||
images: product.images?.[0]?.url ? [product.images[0].url] : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const [product, related] = await Promise.all([
|
||||
getProduct(id),
|
||||
getProduct(id).then(p => p ? getRelated(p.category.id, p.id) : []),
|
||||
])
|
||||
|
||||
if (!product) notFound()
|
||||
|
||||
const firstVariant = product.variants[0]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-0">
|
||||
<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 href="/" className="hover:text-white transition-colors">Home</Link>
|
||||
<span>/</span>
|
||||
<Link href="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/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 || product.category.imageUrl || getCategoryImage(product.category.name)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={product.scent.name}
|
||||
/>
|
||||
<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 — Interactive (Add to Cart, Variant select) must be Client */}
|
||||
<AddToCartSection product={product} />
|
||||
</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
|
||||
href={`/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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from 'next/link'
|
||||
import type { Article } from '@irelax/types'
|
||||
import { API_BASE } from '../../lib/api'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Scent Guide | iRelax Aroma',
|
||||
description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.',
|
||||
}
|
||||
|
||||
async function getArticles(): Promise<Article[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/articles?publishedOnly=true`, { next: { revalidate: 60 } })
|
||||
return res.ok ? res.json() : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export default async function ScentGuidePage() {
|
||||
const articles = await getArticles()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/20">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Journal</p>
|
||||
<h1 className="text-5xl font-bold">Scent Guide</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">Explore our collection of articles, guides, and sensory knowledge.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-7xl mx-auto px-6 py-16 w-full">
|
||||
{articles.length === 0 ? (
|
||||
<div className="text-center text-slate-500 italic py-20">
|
||||
Articles coming soon. Check back later!
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{articles.map(article => (
|
||||
<Link
|
||||
key={article.id}
|
||||
href={`/article/${article.slug}`}
|
||||
className="group block bg-night-950 rounded-2xl overflow-hidden border border-white/5 hover:border-cyan-glow/30 transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="aspect-[4/3] bg-night-900 overflow-hidden shrink-0">
|
||||
{article.coverImage ? (
|
||||
<img src={article.coverImage} alt={article.title} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-500" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-white/5 flex items-center justify-center">
|
||||
<span className="text-slate-600 text-sm tracking-widest uppercase">No Image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 space-y-3 flex-1 flex flex-col">
|
||||
<p className="text-[10px] text-cyan-glow font-bold tracking-widest uppercase">
|
||||
{new Date(article.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
<h2 className="text-xl font-bold leading-tight group-hover:text-cyan-glow transition-colors">{article.title}</h2>
|
||||
<div className="text-slate-400 text-sm line-clamp-3" dangerouslySetInnerHTML={{ __html: article.content }} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Search Products | iRelax Aroma',
|
||||
description: 'Search our collection of premium aroma products by scent or product name.',
|
||||
}
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
import { useState, useEffect, useRef, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import ProductCard from '../../components/ProductCard'
|
||||
import { type Product } from '@irelax/types'
|
||||
import { API_BASE } from '../../lib/api'
|
||||
|
||||
function SearchResults() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const q = searchParams.get('q') || ''
|
||||
|
||||
const [query, setQuery] = useState(q)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [displayCount, setDisplayCount] = useState(24)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Sync input → URL
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (query.trim()) router.replace(`/search?q=${encodeURIComponent(query.trim())}`)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, router])
|
||||
|
||||
// Fetch on URL change
|
||||
useEffect(() => {
|
||||
if (!q.trim()) { setProducts([]); return }
|
||||
setLoading(true)
|
||||
setDisplayCount(24)
|
||||
fetch(`${API_BASE}/products?search=${encodeURIComponent(q)}&limit=1000`)
|
||||
.then(r => r.json())
|
||||
.then(data => setProducts(data.data || []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [q])
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && products.length > displayCount)
|
||||
setDisplayCount(prev => Math.min(prev + 12, products.length))
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '200px' }
|
||||
)
|
||||
if (loaderRef.current) observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [products.length, displayCount])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/30">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Search</p>
|
||||
<h1 className="text-5xl font-bold tracking-tight">Find Your Scent</h1>
|
||||
|
||||
<div className="max-w-md mx-auto mt-6">
|
||||
<div className="flex glass border border-white/10 rounded-xl overflow-hidden">
|
||||
<svg className="w-4 h-4 text-slate-400 m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Search by scent or product name..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-500"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); router.replace('/search') }}
|
||||
className="px-3 text-slate-400 hover:text-white transition-colors"
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && q && (
|
||||
<p className="text-slate-500 text-sm">
|
||||
{products.length > 0
|
||||
? `Found ${products.length} result${products.length !== 1 ? 's' : ''} for "${q}"`
|
||||
: `No results for "${q}"`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{!q.trim() ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-3">
|
||||
<p className="text-4xl">🔍</p>
|
||||
<p className="text-lg">Type something to search</p>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Browse All Products →</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-4">
|
||||
<p className="text-4xl">🌿</p>
|
||||
<p className="text-lg font-semibold">No products found</p>
|
||||
<p className="text-sm">Try a different scent name or keyword</p>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Browse All Products →</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{products.slice(0, displayCount).map(p => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
{displayCount < products.length && (
|
||||
<div ref={loaderRef} className="py-12 flex justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchResults />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Shop All Products | iRelax Aroma',
|
||||
description: 'Browse our full range of premium aroma products — crafted to elevate every space.',
|
||||
}
|
||||
|
||||
export default function ShopLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import ProductCard from '../../components/ProductCard'
|
||||
import { type Category, type Product } from '@irelax/types'
|
||||
|
||||
import { API_BASE } from '../../lib/api'
|
||||
|
||||
export default function ShopPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [activeCategory, setActiveCategory] = useState<number | null>(null)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [displayCount, setDisplayCount] = useState(24)
|
||||
const [totalProducts, setTotalProducts] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [productsLoading, setProductsLoading] = useState(false)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
.then((r) => r.json())
|
||||
.then((data: Category[]) => { setCategories(data); setLoading(false) })
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearchQuery(searchInput), 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
setDisplayCount(24)
|
||||
}, [activeCategory, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
setProductsLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
params.append('limit', '1000')
|
||||
if (activeCategory) params.append('categoryId', activeCategory.toString())
|
||||
if (searchQuery.trim()) params.append('search', searchQuery.trim())
|
||||
fetch(`${API_BASE}/products?${params.toString()}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { data: Product[], meta?: any }) => {
|
||||
setProducts(data.data || [])
|
||||
setTotalProducts(data.meta?.total || 0)
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setProductsLoading(false))
|
||||
}, [activeCategory, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && products.length > displayCount)
|
||||
setDisplayCount((prev) => Math.min(prev + 12, products.length))
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '200px' }
|
||||
)
|
||||
if (loaderRef.current) observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [products.length, displayCount, productsLoading])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Page Header */}
|
||||
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/30">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">All Products</p>
|
||||
<h1 className="text-5xl font-bold tracking-tight">The Collection</h1>
|
||||
<p className="text-slate-400 max-w-md mx-auto">Browse our full range of premium aroma products — crafted to elevate every space.</p>
|
||||
<div className="max-w-md mx-auto mt-6">
|
||||
<div className="flex glass border border-white/10 rounded-xl overflow-hidden">
|
||||
<svg className="w-4 h-4 text-slate-400 m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by scent or category..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12 space-y-10">
|
||||
{/* Category Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
activeCategory === null ? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]' : 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>All</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
activeCategory === cat.id ? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]' : 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
{cat._count && <span className="ml-2 opacity-60">({cat._count.products})</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-slate-500 text-sm">{productsLoading ? 'Loading...' : `${totalProducts} products`}</p>
|
||||
{activeCategory && (
|
||||
<Link href={`/category/${activeCategory}`} className="text-cyan-glow text-xs font-bold uppercase tracking-widest hover:underline">
|
||||
View Category Page →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading || productsLoading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-3">
|
||||
<p className="text-4xl">🌿</p>
|
||||
<p className="text-lg">No products found</p>
|
||||
<p className="text-sm">Try adjusting your search or filter</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{products.slice(0, displayCount).map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
{displayCount < products.length && (
|
||||
<div ref={loaderRef} className="py-12 flex justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function TrackPage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@irelax/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!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 || product.category.imageUrl || getCategoryImage(product.category.name),
|
||||
})
|
||||
toggleCart(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Info Panel */}
|
||||
<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 href={`/category/${product.category.id}`} className="text-white font-medium hover:text-cyan-glow transition-colors">
|
||||
{product.category.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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)]">
|
||||
{product.variants.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: '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.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"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
import { X, ShoppingBag, Plus, Minus, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { formatPrice } from '@irelax/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function CartDrawer() {
|
||||
const { t } = useTranslation()
|
||||
const { isCartOpen, toggleCart, items, updateQuantity, removeFromCart, getCartTotal } = useCartStore()
|
||||
|
||||
if (!isCartOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => toggleCart(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="relative w-full max-w-md h-full bg-night-900 shadow-2xl flex flex-col border-l border-white/10 animate-slide-left">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-night-950/50">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<ShoppingBag className="w-5 h-5 text-cyan-glow" />
|
||||
{t('cart.title')}
|
||||
<span className="text-sm font-normal text-slate-400 bg-white/5 px-2 py-0.5 rounded-full ml-2">
|
||||
{t('cart.items', { count: items.length })}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => toggleCart(false)}
|
||||
className="p-2 hover:bg-white/5 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400 hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-500 space-y-4">
|
||||
<ShoppingBag className="w-16 h-16 opacity-20" />
|
||||
<p>{t('cart.empty')}</p>
|
||||
<button
|
||||
onClick={() => toggleCart(false)}
|
||||
className="text-cyan-glow hover:underline tracking-widest uppercase text-xs font-bold"
|
||||
>
|
||||
{t('cart.continueShopping')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-white/5 border border-white/5 group relative">
|
||||
<div className="w-20 h-24 rounded-lg bg-night-950 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={item.productImage || '/images/oil_ozone.png'}
|
||||
alt={item.productName}
|
||||
className="w-full h-full object-cover opacity-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col pt-1 space-y-1 relative">
|
||||
<h3 className="font-bold text-sm leading-tight pr-6">{item.productName}</h3>
|
||||
{item.variantSize && (
|
||||
<p className="text-xs text-slate-400">{item.variantSize}</p>
|
||||
)}
|
||||
<p className="text-cyan-glow text-sm font-bold mt-auto">{formatPrice(item.price)}</p>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 bg-night-950/50 rounded-lg w-max border border-white/5">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
||||
className="p-1 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-4 text-center text-xs font-bold">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
|
||||
className="p-1 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeFromCart(item.cartItemId)}
|
||||
className="absolute top-1 right-0 p-1 text-slate-500 hover:text-rose-500 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{items.length > 0 && (
|
||||
<div className="p-6 border-t border-white/10 bg-night-950/50 space-y-6">
|
||||
<div className="flex items-center justify-between text-lg font-bold">
|
||||
<span>{t('cart.subtotal')}</span>
|
||||
<span className="text-cyan-glow">{formatPrice(getCartTotal())}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 text-center">{t('cart.shippingNote')}</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
onClick={() => toggleCart(false)}
|
||||
className="btn-primary w-full py-4 text-center block uppercase tracking-widest text-sm"
|
||||
>
|
||||
{t('cart.proceed')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
import '../lib/i18n';
|
||||
import CartDrawer from './CartDrawer';
|
||||
import ScrollToTop from './ScrollToTop';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
{children}
|
||||
<CartDrawer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
import Link from 'next/link'
|
||||
import type { Category } from '@irelax/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface FooterProps {
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
export default function Footer({ categories = [] }: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<footer className="bg-black py-20 px-6 border-t border-white/5">
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||
<div className="space-y-6">
|
||||
<div className="text-2xl font-bold tracking-widest">
|
||||
iRelax <span className="text-cyan-glow text-sm font-light italic">Essential</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm leading-relaxed">
|
||||
{t('footer.catchphrase')}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{['IG', 'FB', 'TW', 'YT'].map((s) => (
|
||||
<a key={s} href="#" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">
|
||||
{s}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
<Link href="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
|
||||
<Link href="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link>
|
||||
<Link href="/scent-guide" className="hover:text-white transition-colors">{t('nav.scentGuide')}</Link>
|
||||
<Link href="/about" className="hover:text-white transition-colors">{t('nav.about')}</Link>
|
||||
<Link href="/contact" className="hover:text-white transition-colors">{t('nav.contact')}</Link>
|
||||
<Link href="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('nav.collections')}</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
{categories.map((c) => (
|
||||
<Link key={c.id} href={`/category/${c.id}`} className="hover:text-white transition-colors">
|
||||
{c.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">Newsletter</h4>
|
||||
<p className="text-xs text-slate-500">Subscribe for exclusive releases and sensory wisdom.</p>
|
||||
<div className="flex bg-white/5 border border-white/10 p-1 rounded-sm">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600"
|
||||
/>
|
||||
<button className="bg-white text-black px-4 py-2 font-bold text-xs uppercase hover:bg-cyan-glow transition-colors shrink-0">
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
||||
© {new Date().getFullYear()} iRelax Essential. {t('footer.rights')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
import Link from 'next/link'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Category } from '@irelax/types'
|
||||
import { ShoppingBag, MapPin } from 'lucide-react'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface NavbarProps {
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [localOrdersCount, setLocalOrdersCount] = useState(0)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const { toggleCart, getCartCount } = useCartStore()
|
||||
const cartCount = getCartCount()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language.startsWith('en') ? 'th' : 'en'
|
||||
i18n.changeLanguage(newLang)
|
||||
localStorage.setItem('irelax_lang', newLang)
|
||||
}
|
||||
|
||||
// Detect scroll for navbar bg change
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
// Check local orders periodically (or just on mount)
|
||||
const stored = localStorage.getItem('irelax_local_orders')
|
||||
if (stored) {
|
||||
try { setLocalOrdersCount(JSON.parse(stored).length) } catch (e) {}
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => { setMobileOpen(false) }, [pathname])
|
||||
// Prevent body scroll when overlays open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [searchOpen, mobileOpen])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
setSearchOpen(false)
|
||||
setMobileOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
const navLinkClass = (path: string) =>
|
||||
pathname === path ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── NAVBAR BAR ── */}
|
||||
<nav
|
||||
className={`fixed top-0 w-full z-50 transition-all duration-300 px-4 md:px-6 py-3.5 md:py-4 flex justify-between items-center ${
|
||||
scrolled ? 'bg-night-900/95 backdrop-blur-xl border-b border-white/10 shadow-xl' : 'glass backdrop-blur-xl border-b border-white/5'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link href="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-white uppercase italic shrink-0">
|
||||
iRelax <span className="text-cyan-glow text-xs md:text-sm not-italic font-light opacity-60">Essential</span>
|
||||
</Link>
|
||||
|
||||
{/* ── DESKTOP NAV ── */}
|
||||
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
|
||||
<Link href="/" className={navLinkClass('/')}>{t('nav.home')}</Link>
|
||||
<Link href="/shop" className={navLinkClass('/shop')}>{t('nav.shop')}</Link>
|
||||
<Link href="/coupons" className={navLinkClass('/coupons')}>🔥 {t('nav.promotions') || 'Promotions'}</Link>
|
||||
|
||||
{/* Collections dropdown */}
|
||||
{categories.length > 0 && (
|
||||
<div className="relative group">
|
||||
<button className="text-slate-300 hover:text-white transition-colors flex items-center gap-1 uppercase text-sm font-medium tracking-wide">
|
||||
{t('nav.collections')}
|
||||
<svg className="w-3 h-3 opacity-50 transition-transform duration-300 group-hover:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-4 w-52 bg-night-900/95 backdrop-blur-xl border border-white/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-2xl">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/category/${cat.id}`}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-xs uppercase tracking-widest rounded-lg hover:bg-white/10 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
{cat.name}
|
||||
<svg className="w-3 h-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/scent-guide" className={navLinkClass('/scent-guide')}>{t('nav.scentGuide')}</Link>
|
||||
<Link href="/about" className={navLinkClass('/about')}>{t('nav.about')}</Link>
|
||||
</div>
|
||||
|
||||
{/* ── DESKTOP RIGHT ACTIONS ── */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<Link
|
||||
href="/track"
|
||||
className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
|
||||
aria-label="Track Orders"
|
||||
title="Track Orders"
|
||||
>
|
||||
<MapPin className="w-4 h-4 group-hover:text-cyan-glow transition-colors" />
|
||||
{localOrdersCount > 0 && (
|
||||
<span className="absolute top-0 right-0 w-2 h-2 bg-rose-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:text-cyan-glow transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => toggleCart(true)}
|
||||
className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-cyan-glow transition-colors" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-cyan-glow text-night-900 text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="glass px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow hover:text-night-900 transition-all duration-300"
|
||||
>
|
||||
{t('nav.shopNow')}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className="glass px-3 py-2 rounded-full text-xs font-bold hover:bg-white/10 transition-all text-cyan-glow border border-cyan-glow/20"
|
||||
>
|
||||
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── MOBILE RIGHT ACTIONS ── */}
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
<Link
|
||||
href="/track"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="relative p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label="Track Orders"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
{localOrdersCount > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-rose-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false) }}
|
||||
className="p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false) }}
|
||||
className="relative p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-cyan-glow text-night-900 text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center translate-x-1/4 -translate-y-1/4">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(prev => !prev)}
|
||||
className="p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
<div className="w-5 h-5 flex flex-col justify-center gap-[5px] relative">
|
||||
<span className={`block w-5 h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} />
|
||||
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'opacity-0 w-0' : 'w-4'}`} />
|
||||
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute -rotate-45 w-5' : 'w-5'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── MOBILE DRAWER ── */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 md:hidden transition-all duration-300 ${mobileOpen ? 'visible' : 'invisible'}`}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-night-900/80 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={`absolute top-0 right-0 h-full w-72 bg-night-900/98 backdrop-blur-2xl border-l border-white/10 flex flex-col transition-transform duration-300 ${mobileOpen ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
>
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5">
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-slate-300">{t('nav.menu')}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={toggleLanguage} className="glass px-2.5 py-1 rounded-md text-[10px] font-bold text-cyan-glow border border-cyan-glow/20 uppercase">
|
||||
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
|
||||
</button>
|
||||
<button onClick={() => setMobileOpen(false)} className="p-1.5 rounded-full glass hover:bg-white/10 transition-all">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drawer Nav Links */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1">
|
||||
{[
|
||||
{ to: '/', label: t('nav.home') },
|
||||
{ to: '/shop', label: t('nav.shop') },
|
||||
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
|
||||
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
|
||||
{ to: '/about', label: t('nav.about') },
|
||||
{ to: '/faq', label: t('nav.faq') },
|
||||
{ to: '/contact', label: t('nav.contact') },
|
||||
].map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
href={item.to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={
|
||||
`block px-4 py-3 rounded-xl text-sm font-medium tracking-wide transition-all duration-200 ${
|
||||
pathname === item.to ? 'bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20' : 'hover:bg-white/5 text-slate-300 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Collections sub-section */}
|
||||
{categories.length > 0 && (
|
||||
<div className="pt-4">
|
||||
<p className="text-[10px] uppercase tracking-widest text-slate-600 px-4 pb-2">{t('nav.collections')}</p>
|
||||
{categories.map(cat => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/category/${cat.id}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center justify-between px-4 py-2.5 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
{cat.name}
|
||||
<svg className="w-3 h-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drawer Footer CTA */}
|
||||
<div className="px-4 pb-8 pt-4 border-t border-white/5 space-y-3">
|
||||
<Link
|
||||
href="/shop"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block w-full py-3 bg-white text-night-900 font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-cyan-glow transition-colors duration-300"
|
||||
>
|
||||
{t('nav.shopNow')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/scent-guide"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-white/10 transition-colors border border-white/10"
|
||||
>
|
||||
{t('nav.findMyScent')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SEARCH OVERLAY ── */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-night-900/95 backdrop-blur-xl flex flex-col items-center pt-24 md:pt-32 px-4 md:px-6"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setSearchOpen(false) }}
|
||||
>
|
||||
<div className="w-full max-w-2xl space-y-6 animate-fade-in">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-widest">{t('nav.search.title')}</p>
|
||||
<button onClick={() => setSearchOpen(false)} className="glass p-2 rounded-full hover:bg-white/10 transition-all">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="flex glass border border-white/20 focus-within:border-cyan-glow/50 rounded-2xl overflow-hidden transition-colors">
|
||||
<svg className="w-5 h-5 text-slate-400 m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search scents, categories..."
|
||||
className="bg-transparent border-none outline-none text-lg md:text-xl p-4 pl-0 w-full text-white placeholder-slate-600"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="bg-cyan-glow text-night-900 px-4 md:px-6 font-bold text-xs uppercase tracking-widest shrink-0 hover:bg-white transition-colors">
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest mb-3">Quick Links</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
...categories.map(c => ({ label: c.name, to: `/category/${c.id}` })),
|
||||
{ label: '✨ Scent Guide', to: '/scent-guide' },
|
||||
{ label: 'All Products', to: '/shop' },
|
||||
].map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
href={item.to}
|
||||
onClick={() => setSearchOpen(false)}
|
||||
className="glass px-4 py-1.5 rounded-full text-xs font-medium hover:bg-white/10 hover:text-cyan-glow transition-all border border-white/5"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Link from 'next/link'
|
||||
import type { Product } from '@irelax/types'
|
||||
import { getCategoryImage, formatPrice } from '@irelax/types'
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
return (
|
||||
<div className="glass glass-hover rounded-2xl overflow-hidden flex flex-col group p-1.5 md:p-2">
|
||||
<div className="h-40 md:h-64 overflow-hidden rounded-xl relative">
|
||||
<img
|
||||
src={product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(product.category.name)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={product.scent.name}
|
||||
/>
|
||||
{/* Category badge */}
|
||||
<div className="absolute top-4 left-4 glass px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest text-cyan-glow">
|
||||
{product.category.name}
|
||||
</div>
|
||||
{/* Available badge */}
|
||||
<div className="hidden md:block absolute top-4 right-4 glass px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest">
|
||||
Available
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 md:p-6 space-y-2 md:space-y-4 flex flex-col flex-1">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-1">
|
||||
<div>
|
||||
<h3 className="text-sm md:text-xl font-bold leading-tight line-clamp-1">
|
||||
{product.scent.name}
|
||||
</h3>
|
||||
<p className="text-[10px] md:text-xs text-slate-400 mt-0.5 md:mt-1 uppercase tracking-widest line-clamp-1">
|
||||
{product.category.name}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-cyan-glow font-bold text-xs md:text-lg">
|
||||
{formatPrice(product.variants[0]?.price)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Variants (sizes) */}
|
||||
<div className="hidden md:flex flex-wrap gap-1.5 md:gap-2">
|
||||
{product.variants.map((v) => (
|
||||
<div key={v.id} className="glass px-1.5 py-0.5 md:px-2.5 md:py-1 rounded-lg text-[8px] md:text-[10px] uppercase tracking-wider">
|
||||
{v.size}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Link
|
||||
href={`/products/${product.id}`}
|
||||
className="block w-full py-2 md:py-3 text-center bg-white/10 hover:bg-white text-white hover:text-night-900 rounded-lg font-bold uppercase tracking-widest text-[10px] md:text-xs transition-all duration-300"
|
||||
>
|
||||
Discover
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
import { useEffect } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Scrolls window to top on every route change (Next.js version).
|
||||
*/
|
||||
export default function ScrollToTop() {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Central API base URL resolver.
|
||||
*
|
||||
* Priority:
|
||||
* 1. NEXT_PUBLIC_API_URL — ตั้งค่าใน .env / .env.production (Client + Server)
|
||||
* 2. INTERNAL_API_URL — ตั้งค่าใน .env สำหรับ Server-side เท่านั้น
|
||||
* ใช้เมื่อ Server fetch ภายใน network เดียวกัน
|
||||
* 3. Fallback dev — http://localhost:3000/api
|
||||
*
|
||||
* Dev: NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
* Production: NEXT_PUBLIC_API_URL=https://api.irelaxaroma.shop/api
|
||||
*/
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
process.env.INTERNAL_API_URL ||
|
||||
'http://localhost:3000/api'
|
||||
@@ -0,0 +1,27 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import enTranslation from '../locales/en/translation.json'
|
||||
import thTranslation from '../locales/th/translation.json'
|
||||
|
||||
// Ensure we only access localStorage on the client side
|
||||
const savedLang = typeof window !== 'undefined' ? (localStorage.getItem('irelax_lang') || 'th') : 'th'
|
||||
|
||||
// Check if it's already initialized to prevent re-initialization on HMR
|
||||
if (!i18n.isInitialized) {
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: enTranslation },
|
||||
th: { translation: thTranslation },
|
||||
},
|
||||
lng: savedLang, // default language
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"shop": "Shop",
|
||||
"collections": "Collections",
|
||||
"scentGuide": "Scent Guide",
|
||||
"about": "About",
|
||||
"menu": "Menu",
|
||||
"faq": "FAQ",
|
||||
"contact": "Contact",
|
||||
"shopNow": "Shop Now",
|
||||
"findMyScent": "Find My Scent ✨",
|
||||
"search": {
|
||||
"title": "Search Collection",
|
||||
"placeholder": "Search scents, categories...",
|
||||
"quickLinks": "Quick Links",
|
||||
"allProducts": "All Products"
|
||||
}
|
||||
},
|
||||
"cart": {
|
||||
"title": "Your Sanctuary",
|
||||
"items": "{{count}} items",
|
||||
"empty": "Your cart is empty.",
|
||||
"continueShopping": "Continue Shopping",
|
||||
"subtotal": "Subtotal",
|
||||
"shippingNote": "Shipping and taxes calculated at checkout.",
|
||||
"proceed": "Proceed to Checkout"
|
||||
},
|
||||
"footer": {
|
||||
"catchphrase": "Elevate your space with premium fragrances.",
|
||||
"explore": "Explore",
|
||||
"support": "Support",
|
||||
"followUs": "Follow Us",
|
||||
"contactUs": "Contact Us",
|
||||
"rights": "All rights reserved."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "หน้าหลัก",
|
||||
"shop": "ร้านค้า",
|
||||
"collections": "คอลเลกชัน",
|
||||
"scentGuide": "คลาสแนะนำกลิ่น",
|
||||
"about": "เกี่ยวกับเรา",
|
||||
"menu": "เมนู",
|
||||
"faq": "คำถามที่พบบ่อย",
|
||||
"contact": "ติดต่อเรา",
|
||||
"shopNow": "ช้อปเลย",
|
||||
"findMyScent": "ค้นหากลิ่นของคุณ ✨",
|
||||
"search": {
|
||||
"title": "ค้นหาสินค้า",
|
||||
"placeholder": "ค้นหากลิ่น, คอลเลกชัน...",
|
||||
"quickLinks": "ลิงก์ด่วน",
|
||||
"allProducts": "สินค้าทั้งหมด"
|
||||
}
|
||||
},
|
||||
"cart": {
|
||||
"title": "ตะกร้าสินค้า",
|
||||
"items": "{{count}} รายการ",
|
||||
"empty": "ตะกร้าสินค้าของคุณว่างเปล่า",
|
||||
"continueShopping": "เลือกซื้อสินค้าต่อ",
|
||||
"subtotal": "ยอดรวมยอด",
|
||||
"shippingNote": "ค่าจัดส่งและภาษีจะถูกคำนวณท้ายสุด",
|
||||
"proceed": "ดำเนินการชำระเงิน"
|
||||
},
|
||||
"footer": {
|
||||
"catchphrase": "ยกระดับพื้นที่ของคุณด้วยน้ำหอมระดับพรีเมียม",
|
||||
"explore": "สำรวจ",
|
||||
"support": "ช่วยเหลือ",
|
||||
"followUs": "ติดตามเรา",
|
||||
"contactUs": "ติดต่อสอบถาม",
|
||||
"rights": "สงวนลิขสิทธิ์."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { CartItem } from '@irelax/types';
|
||||
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
isCartOpen: boolean;
|
||||
addToCart: (item: Omit<CartItem, 'cartItemId'>) => void;
|
||||
removeFromCart: (cartItemId: string) => void;
|
||||
updateQuantity: (cartItemId: string, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
toggleCart: (isOpen?: boolean) => void;
|
||||
getCartTotal: () => number;
|
||||
getCartCount: () => number;
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
items: [],
|
||||
isCartOpen: false,
|
||||
|
||||
addToCart: (item) => set((state) => {
|
||||
// Build a unique ID for the item. If variantId exists, use it. Otherwise, use productId.
|
||||
const exactIdMatch = state.items.find(i =>
|
||||
i.productId === item.productId && i.variantId === item.variantId
|
||||
);
|
||||
|
||||
if (exactIdMatch) {
|
||||
// Increment quantity if exact match exists
|
||||
return {
|
||||
items: state.items.map(i =>
|
||||
i.cartItemId === exactIdMatch.cartItemId
|
||||
? { ...i, quantity: i.quantity + item.quantity }
|
||||
: i
|
||||
),
|
||||
isCartOpen: true
|
||||
};
|
||||
}
|
||||
|
||||
// Add new item with a generated cartItemId
|
||||
const cartItemId = crypto.randomUUID();
|
||||
return {
|
||||
items: [...state.items, { ...item, cartItemId }],
|
||||
isCartOpen: true
|
||||
};
|
||||
}),
|
||||
|
||||
removeFromCart: (cartItemId) => set((state) => ({
|
||||
items: state.items.filter(i => i.cartItemId !== cartItemId)
|
||||
})),
|
||||
|
||||
updateQuantity: (cartItemId, quantity) => set((state) => ({
|
||||
items: state.items.map(i =>
|
||||
i.cartItemId === cartItemId ? { ...i, quantity: Math.max(1, quantity) } : i
|
||||
)
|
||||
})),
|
||||
|
||||
clearCart: () => set({ items: [] }),
|
||||
|
||||
toggleCart: (isOpen) => set((state) => ({
|
||||
isCartOpen: isOpen !== undefined ? isOpen : !state.isCartOpen
|
||||
})),
|
||||
|
||||
getCartTotal: () => {
|
||||
return get().items.reduce((total, item) => total + (item.price * item.quantity), 0);
|
||||
},
|
||||
|
||||
getCartCount: () => {
|
||||
return get().items.reduce((count, item) => count + item.quantity, 0);
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'irelax-cart-storage',
|
||||
partialize: (state) => ({ items: state.items }), // Only persist items, not UI state
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in new issue
Block a user