Full website

This commit is contained in:
Your Name committed 2026-06-17 07:42:59 -04:00
1 parent 807c43ece5
commit ea9f33f60e
40 files changed
+4457 -1156

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
const lang = 'th'
const reservedSlugs = new Set([
'about',
'article',
'category',
'contact',
'coupons',
'faq',
'order',
'products',
'profile',
'search',
'scent-guide',
'shop',
'track',
'wishlist',
])
function normalizeSlug(slug: string) {
return slug.trim().replace(/^\/+|\/+$/g, '').toLowerCase()
}
function isReservedSlug(slug: string) {
return reservedSlugs.has(normalizeSlug(slug))
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params
if (isReservedSlug(slug)) return { title: 'Page Not Found | HomAroma' }
const pageResult = await getCmsPageResult(slug, lang)
if (!pageResult.ok || !pageResult.data.isPublished) return { title: 'Page Not Found | HomAroma' }
return {
title: `${pageResult.data.title} | HomAroma`,
}
}
export default async function CmsCustomPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
if (isReservedSlug(slug)) notFound()
const [pageResult, categoriesResult] = await Promise.all([
getCmsPageResult(slug, lang),
getCategoriesResult(lang),
])
if (!pageResult.ok) {
if (pageResult.status === 404) notFound()
return (
<main className="min-h-[70vh] bg-pop-bg px-6 py-24">
<div className="mx-auto max-w-3xl rounded-3xl border border-rose-100 bg-white px-6 py-16 text-center shadow-brutal">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-rose-500">CMS Page Error</p>
<h1 className="mt-4 text-3xl font-semibold text-pop-black md:text-4xl">
ไม่สามารถโหลดหน้านี้ได้
</h1>
<p className="mt-4 text-sm text-rose-500">{pageResult.error}</p>
</div>
</main>
)
}
const page = pageResult.data
if (!page.isPublished) notFound()
const categories = categoriesResult.ok ? categoriesResult.data : []
const sections = page.sections?.filter((section) => section.isActive) ?? []
if (!categoriesResult.ok) {
console.error(`[website-cms] categories unavailable for CMS page "${slug}": ${categoriesResult.error}`)
}
return (
<main className="min-h-[70vh] bg-pop-bg">
{sections.length > 0 ? (
<DynamicPageRenderer sections={sections} categories={categories} lang={lang} />
) : (
<div className="mx-auto max-w-4xl px-6 py-24 text-center">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">HomAroma</p>
<h1 className="mt-4 text-4xl font-semibold text-pop-black md:text-5xl">{page.title}</h1>
<p className="mt-5 text-gray-600">
หน้านี้ถูกเผยแพร่แล้ว แต่ยังไม่มี section ที่เปิดใช้งานใน Page Builder
</p>
</div>
)}
</main>
)
}
+38 -47
View File
@@ -32,91 +32,82 @@ export const metadata = {
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>
<div className="min-h-screen bg-pop-bg">
<section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-white pt-24">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_30%,rgba(255,107,107,0.22),transparent_34%),radial-gradient(circle_at_80%_20%,rgba(255,160,122,0.18),transparent_34%)]" />
<div className="relative z-10 mx-auto max-w-3xl space-y-6 px-6 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-pop-pink">Our Story</p>
<h1 className="text-5xl font-bold leading-tight text-pop-black md:text-7xl">
Born from a passion for <span className="italic text-pop-pink">stillness</span>
</h1>
<p className="text-slate-300 text-lg leading-relaxed">
<p className="text-lg leading-relaxed text-gray-600">
HomAroma 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">
<section className="border-y border-gray-200 bg-white px-6 py-12">
<div className="mx-auto grid max-w-4xl grid-cols-2 gap-8 text-center md:grid-cols-4">
{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 className="text-4xl font-bold text-pop-pink">{s.value}</div>
<div className="text-xs uppercase tracking-widest text-gray-500">{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">
<section className="px-6 py-24">
<div className="mx-auto grid max-w-6xl items-center gap-16 md:grid-cols-2">
<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">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-pop-pink">Our Mission</p>
<h2 className="text-4xl font-bold leading-tight text-pop-black">Transforming spaces into sanctuaries of peace</h2>
<p className="leading-relaxed text-gray-700">
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">
<p className="text-sm leading-relaxed text-gray-600">
Every HomAroma 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="relative h-80 overflow-hidden rounded-3xl border border-gray-100 bg-white shadow-brutal">
<div className="absolute inset-0 bg-gradient-to-br from-pop-bg via-white to-pop-orange/20" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-3">
<div className="space-y-3 text-center">
<div className="text-7xl">🌿</div>
<p className="text-white/30 text-sm uppercase tracking-widest">HomAroma Studio, Bangkok</p>
<p className="text-sm uppercase tracking-widest text-gray-500">HomAroma 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" />
<section className="border-y border-gray-200 bg-white px-6 py-24">
<div className="mx-auto max-w-6xl space-y-12">
<div className="space-y-3 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-pop-pink">What We Stand For</p>
<h2 className="text-4xl font-bold text-pop-black">Three Pillars</h2>
<div className="mx-auto h-px w-12 bg-pop-pink" />
</div>
<div className="grid md:grid-cols-3 gap-8">
<div className="grid gap-8 md:grid-cols-3">
{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 key={p.title} className="space-y-4 rounded-2xl border border-gray-100 bg-pop-bg p-8 shadow-brutal transition-all duration-300 hover:-translate-y-1 hover:shadow-brutal-hover">
<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>
<h3 className="text-xl font-bold text-pop-black">{p.title}</h3>
<p className="text-sm leading-relaxed text-gray-600">{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">
<section className="space-y-6 px-6 py-24 text-center">
<h2 className="text-4xl font-bold text-pop-black">Ready to find your scent?</h2>
<p className="mx-auto max-w-md text-gray-600">Let our Scent Guide help you discover the perfect blend for your moment.</p>
<div className="flex flex-col justify-center gap-4 pt-4 sm:flex-row">
<Link href="/scent-guide" className="rounded-full bg-pop-black px-10 py-4 font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
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">
<Link href="/shop" className="rounded-full border border-pop-pink bg-white px-10 py-4 font-bold uppercase tracking-widest text-pop-pink transition hover:bg-pop-bg">
Shop All Products
</Link>
</div>
+17 -20
View File
@@ -36,43 +36,40 @@ export default async function ArticleReaderPage({ params }: { params: Promise<{
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 */}
<div className="flex min-h-screen flex-col bg-pop-bg">
<main className="flex-1 pb-20 pt-24">
<article className="relative mx-auto mt-8 w-full max-w-4xl">
<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"
className="absolute -left-16 top-0 mt-8 hidden rounded-full border border-gray-200 bg-white p-4 text-pop-black shadow-sm transition-colors hover:text-pop-pink xl:-left-24 lg:flex"
>
<ArrowLeft className="w-5 h-5 text-slate-300" />
<ArrowLeft className="h-5 w-5" />
</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
<div className="space-y-6 px-6 py-12 text-center">
<Link href="/scent-guide" className="mb-4 inline-flex items-center gap-2 text-sm text-gray-600 hover:text-pop-pink lg:hidden">
<ArrowLeft className="h-4 w-4" /> Go Back
</Link>
<p className="text-cyan-glow uppercase tracking-[0.3em] font-bold text-xs">
<p className="text-xs font-bold uppercase tracking-[0.3em] text-pop-pink">
{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">
<h1 className="mx-auto max-w-3xl text-4xl font-bold leading-tight text-pop-black md:text-5xl lg:text-6xl">
{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 className="mb-16 aspect-[16/7] w-full overflow-hidden bg-white shadow-brutal md:rounded-[2rem]">
<img src={article.coverImage} alt={article.title} className="h-full w-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 className="mx-auto max-w-3xl rounded-3xl bg-white px-6 py-10 shadow-brutal md:px-12">
<div
dangerouslySetInnerHTML={{ __html: article.content }}
className="space-y-6 leading-relaxed text-gray-700 [&_a]:font-semibold [&_a]:text-pop-pink [&_h2]:text-3xl [&_h2]:font-bold [&_h2]:text-pop-black [&_h3]:text-2xl [&_h3]:font-bold [&_h3]:text-pop-black [&_img]:rounded-2xl [&_img]:shadow-brutal [&_p]:leading-relaxed [&_strong]:text-pop-black"
/>
</div>
</article>
</main>
</div>
+133 -125
View File
@@ -1,140 +1,148 @@
"use client";
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import type { Category, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
import ProductCard from '../../../components/ProductCard'
import { getCategoryImage, type Category, type Product } from '@homaroma/types'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult } from '../../../lib/cms'
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
import { resolveLocalized } from '../../../lib/localization'
import { API_BASE } from '../../../lib/api'
const lang = 'th'
export default function CategoryPage() {
const params = useParams<{ id: string }>()
const id = params.id
type CategoryProductsResponse = {
category?: Category
data?: Product[]
}
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)
async function getCategoryProducts(id: string): Promise<ApiResult<CategoryProductsResponse>> {
return await fetchJsonResult<CategoryProductsResponse>(`/categories/${id}/products?limit=1000&lang=${lang}`, {
next: { revalidate: 60 },
})
.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])
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const result = await getCategoryProducts(id)
const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : ''
return {
title: categoryName ? `${categoryName} | HomAroma` : 'Category | HomAroma',
description: categoryName ? `Shop ${categoryName} products from HomAroma.` : 'Shop HomAroma category products.',
}
}
export default async function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const [categoryProductsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoryProducts(id),
getCategoriesResult(lang),
getCmsPageResult('category-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] category-layout unavailable: ${cmsLayoutResult.error}`)
}
if (!categoryProductsResult.ok) {
if (categoryProductsResult.status === 404) notFound()
return (
<div className="min-h-screen">
{/* Hero Banner */}
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={category?.imageUrl || getCategoryImage(typeof category?.name === 'string' ? category.name : (category?.name as any)?.th || (category?.name as any)?.en || '')}
className="absolute inset-0 w-full h-full object-cover brightness-30 scale-105"
alt={typeof category?.name === 'string' ? category.name : (category?.name as any)?.th || (category?.name as any)?.en || ''}
/>
<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">{typeof category?.name === 'string' ? category.name : (category?.name as any)?.th || (category?.name as any)?.en || '...'}</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" /> : typeof category?.name === 'string' ? category.name : (category?.name as any)?.th || (category?.name as any)?.en || ''}
</h1>
{!loading && category?._count && (
<p className="text-slate-400 mt-2">{category._count.products} products available</p>
<div className="min-h-screen bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<div className="px-6 py-20">
<div className="mx-auto max-w-3xl rounded-3xl border border-rose-100 bg-white p-10 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">Unable to load this category</p>
<p className="mt-2 text-sm text-rose-500">{categoryProductsResult.error}</p>
<Link href="/shop" className="mt-6 inline-flex font-semibold text-pop-pink">Back to Shop</Link>
</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'
}`}
>
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || ''}
</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>
)
}
if (!categoryProductsResult.data.category) notFound()
const category = categoryProductsResult.data.category
const products = categoryProductsResult.data.data ?? []
const categoryName = resolveLocalized(category.name, lang)
return (
<div className="min-h-screen bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<section className="relative overflow-hidden bg-white">
<img
src={category.imageUrl || getCategoryImage(categoryName)}
className="absolute inset-0 h-full w-full object-cover"
alt={categoryName}
/>
<div className="absolute inset-0 bg-gradient-to-r from-pop-bg/95 via-pop-bg/70 to-white/20" />
<div className="relative mx-auto max-w-7xl px-6 py-20">
<div className="mb-5 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-gray-500">
<Link href="/" className="transition hover:text-pop-pink">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-pop-pink">Shop</Link>
<span>/</span>
<span className="text-pop-black">{categoryName}</span>
</div>
<h1 className="text-5xl font-semibold text-pop-black md:text-6xl">{categoryName}</h1>
{category._count && <p className="mt-3 text-gray-600">{category._count.products} products available</p>}
</div>
</section>
)}
<div className="mx-auto max-w-7xl space-y-10 px-6 py-12">
<div className="flex flex-wrap gap-3">
{!categoriesResult.ok && (
<span className="rounded-full border border-rose-100 bg-white px-5 py-2 text-xs font-bold uppercase tracking-widest text-rose-500">
Categories unavailable
</span>
)}
{categories.map((item) => {
const name = resolveLocalized(item.name, lang)
const isActive = item.id === category.id
return (
<Link
key={item.id}
href={`/category/${item.id}`}
className={`rounded-full px-5 py-2 text-xs font-bold uppercase tracking-widest transition ${
isActive
? 'bg-pop-black text-white'
: 'border border-gray-200 bg-white text-gray-600 hover:border-pop-pink hover:text-pop-pink'
}`}
>
{name}
</Link>
)
})}
</div>
{products.length === 0 ? (
<div className="rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">No products in this category yet.</p>
<Link href="/shop" className="mt-4 inline-flex font-semibold text-pop-pink">Back to Shop</Link>
</div>
) : (
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{products.map((product) => <ProductCard key={product.id} product={product} />)}
</div>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { CreditCard } from 'lucide-react'
import CheckoutClient from '../../components/CheckoutClient'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'th'
export const metadata = {
title: 'Checkout | HomAroma',
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ HomAroma',
}
export default async function CheckoutPage() {
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('checkout-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] checkout-layout unavailable: ${cmsLayoutResult.error}`)
}
return (
<main className="min-h-[70vh] bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<section className="mx-auto max-w-4xl px-6 pt-20 text-center md:pt-24">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-white text-pop-pink shadow-brutal">
<CreditCard className="h-8 w-8" />
</div>
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Secure Checkout</p>
<h1 className="mt-4 text-4xl font-semibold text-pop-black md:text-5xl">ยืนยันคำสั่งซื้อ</h1>
<p className="mx-auto mt-4 max-w-2xl text-gray-600">
ตรวจสอบรายการสินค้า ใช้คูปองหรือคะแนนสะสม และสร้างคำสั่งซื้อเพื่อไปยังหน้ารายละเอียดออเดอร์
</p>
</section>
)}
<section className="mx-auto max-w-7xl px-6 py-14 md:py-20">
<CheckoutClient />
</section>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</main>
)
}
+37 -41
View File
@@ -5,8 +5,6 @@ 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')
@@ -24,19 +22,17 @@ export default function ContactPage() {
}
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 className="min-h-screen bg-pop-bg">
<div className="space-y-4 border-b border-gray-200 bg-white px-6 pb-16 pt-32 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-pop-pink">Get in Touch</p>
<h1 className="text-5xl font-bold text-pop-black">Contact Us</h1>
<p className="mx-auto max-w-lg text-gray-600">Have a question, custom request, or partnership idea? We&apos;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 className="mx-auto grid max-w-6xl gap-16 px-6 py-16 md:grid-cols-5">
<div className="space-y-8 md:col-span-2">
<div>
<h2 className="font-bold text-xl mb-6">Reach Us Directly</h2>
<h2 className="mb-6 text-xl font-bold text-pop-black">Reach Us Directly</h2>
<div className="space-y-5">
{[
{ icon: '📧', label: 'Email', value: 'hello@homaroma.shop', href: 'mailto:hello@homaroma.shop' },
@@ -44,75 +40,75 @@ export default function ContactPage() {
{ icon: '📸', label: 'Instagram', value: '@homaroma', 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>
<a key={item.label} href={item.href} className="group flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-gray-100 bg-white text-lg shadow-sm transition-colors group-hover:border-pop-pink/30 group-hover:bg-pop-bg">{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>
<p className="text-xs uppercase tracking-widest text-gray-500">{item.label}</p>
<p className="text-sm font-medium text-pop-black transition-colors group-hover:text-pop-pink">{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-3 rounded-3xl border border-gray-100 bg-white p-6 shadow-brutal">
<h3 className="text-sm font-bold uppercase tracking-widest text-pop-black">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>
<span className="text-gray-600">{k}</span>
<span className="font-medium text-pop-pink">{v}</span>
</div>
))}
</div>
<p className="text-[10px] text-slate-600 pt-2 uppercase tracking-wider">Mon–Sat, 9:00–18:00 (ICT)</p>
<p className="pt-2 text-[10px] uppercase tracking-wider text-gray-500">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">
<div className="flex h-full items-center justify-center rounded-3xl bg-white shadow-brutal">
<div className="space-y-5 py-16 text-center">
<div className="text-6xl">✉️</div>
<h2 className="text-2xl font-bold text-pop-black">Message Sent!</h2>
<p className="text-gray-600">Thank you for reaching out. We&apos;ll get back to you within 2 hours.</p>
<button onClick={() => setStatus('idle')} className="mt-4 rounded-full border border-pop-pink bg-white px-8 py-3 text-xs font-bold uppercase tracking-widest text-pop-pink transition-all hover:bg-pop-bg">
Send Another
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid md:grid-cols-2 gap-5">
<form onSubmit={handleSubmit} className="space-y-5 rounded-3xl border border-gray-100 bg-white p-6 shadow-brutal md:p-8">
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Full Name *</label>
<label className="text-xs uppercase tracking-widest text-gray-600">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" />
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3.5 text-sm text-pop-black outline-none transition-colors placeholder:text-gray-400 focus:border-pop-pink" />
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Email *</label>
<label className="text-xs uppercase tracking-widest text-gray-600">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" />
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3.5 text-sm text-pop-black outline-none transition-colors placeholder:text-gray-400 focus:border-pop-pink" />
</div>
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Subject *</label>
<label className="text-xs uppercase tracking-widest text-gray-600">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">
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3.5 text-sm text-pop-black outline-none transition-colors focus:border-pop-pink">
<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>
<label className="text-xs uppercase tracking-widest text-gray-600">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" />
className="w-full resize-none rounded-xl border border-gray-200 bg-white px-4 py-3.5 text-sm text-pop-black outline-none transition-colors placeholder:text-gray-400 focus:border-pop-pink" />
</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">
className="flex w-full items-center justify-center gap-3 rounded-full bg-pop-black py-4 text-sm font-bold uppercase tracking-widest text-white transition-all duration-300 hover:bg-pop-pink disabled:cursor-not-allowed disabled:opacity-50">
{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 →'}
<><svg className="h-4 w-4 animate-spin" 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>
)}
+71 -55
View File
@@ -1,25 +1,15 @@
import { API_BASE } from '@/lib/api'
import Link from 'next/link'
import { Ticket, ArrowRight } from 'lucide-react'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
// 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 []
}
}
const lang = 'th'
function formatDiscount(type: string, value: number) {
if (type === 'PERCENTAGE') return `${value}% OFF`
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
if (type === 'FREE_SHIPPING') return `ส่งฟรี`
if (type === 'FREE_SHIPPING') return 'ส่งฟรี'
return `${value}`
}
@@ -29,84 +19,110 @@ export const metadata = {
}
export default async function CouponsPage() {
const coupons = await getActiveCoupons()
const [couponsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getActiveCouponsResult(),
getCategoriesResult(lang),
getCmsPageResult('coupons-layout', lang),
])
const coupons = couponsResult.ok ? couponsResult.data : []
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] coupons-layout unavailable: ${cmsLayoutResult.error}`)
}
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">
<main className="min-h-[70vh] bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<div className="mx-auto max-w-6xl px-6 py-16">
<div className="space-y-4 text-center">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Offers</p>
<h1 className="text-4xl font-semibold text-pop-black md:text-5xl">
Exclusive Offers
</h1>
<p className="text-slate-400 max-w-2xl mx-auto">
<p className="mx-auto max-w-2xl text-gray-600">
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
</p>
</div>
</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://homaroma.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"
<div className="mx-auto max-w-6xl px-6 pb-24">
{!couponsResult.ok ? (
<div className="rounded-3xl border border-rose-100 bg-white px-6 py-20 text-center shadow-brutal">
<Ticket className="mx-auto mb-4 h-16 w-16 text-rose-200" />
<h2 className="mb-2 text-xl font-semibold text-pop-black">ไม่สามารถโหลดคูปองได้</h2>
<p className="text-sm text-rose-500">{couponsResult.error}</p>
</div>
) : coupons.length === 0 ? (
<div className="rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
<Ticket className="mx-auto mb-4 h-16 w-16 text-gray-300" />
<h2 className="mb-2 text-xl font-semibold text-pop-black">ยังไม่มีคูปองส่วนลดในขณะนี้</h2>
<p className="mb-6 text-gray-500">กรุณากลับมาตรวจสอบโปรโมชั่นใหม่ในภายหลัง</p>
<Link
href="/shop"
className="inline-flex rounded-full bg-pop-pink px-8 py-3 font-bold text-white transition hover:bg-pop-red"
>
ไปช้อปปิ้งกันก่อน
</a>
</Link>
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{coupons.map((coupon: any) => (
<div
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{coupons.map((coupon) => (
<article
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"
className="relative overflow-hidden rounded-3xl bg-white p-8 shadow-brutal transition hover:-translate-y-1 hover:shadow-brutal-hover"
>
{/* 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 className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-pop-bg text-pop-pink">
<Ticket className="h-6 w-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">
<span className="rounded-md bg-amber-50 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-amber-600">
หมดเขต {new Date(coupon.expiresAt).toLocaleDateString('th-TH')}
</span>
)}
</div>
<div>
<h3 className="text-3xl font-black text-white mb-1">
<h3 className="mb-1 text-3xl font-black text-pop-black">
{formatDiscount(coupon.type, coupon.value)}
</h3>
<p className="text-sm text-slate-400">
<p className="text-sm text-gray-500">
เมื่อซื้อครบ {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">
<div className="rounded-xl border border-dashed border-pop-pink/40 bg-pop-bg p-3 text-center">
<span className="font-mono font-bold uppercase tracking-widest text-pop-pink">
{coupon.code}
</span>
</div>
<a
href={`https://homaroma.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"
<Link
href={`/shop?coupon=${encodeURIComponent(coupon.code)}`}
className="flex w-full items-center justify-center gap-2 rounded-full bg-pop-black py-4 font-bold text-white transition hover:bg-pop-pink"
>
ช้อปพร้อมใช้คูปองเลย
<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" />
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</article>
))}
</div>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</main>
)
}
+22 -20
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from 'react'
import Link from 'next/link'
const FAQS = [
{
@@ -43,22 +44,23 @@ export default function FaqPage() {
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 HomAroma products, shipping, and care.</p>
<div className="min-h-screen bg-pop-bg">
<div className="space-y-4 border-b border-gray-200 bg-white px-6 pb-16 pt-32 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-pop-pink">Support</p>
<h1 className="text-5xl font-bold text-pop-black">Frequently Asked Questions</h1>
<p className="mx-auto max-w-lg text-gray-600">Everything you need to know about HomAroma products, shipping, and care.</p>
</div>
<div className="max-w-4xl mx-auto px-6 py-16 space-y-10">
<div className="mx-auto max-w-4xl space-y-10 px-6 py-16">
<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'
className={`flex items-center gap-2 rounded-full px-5 py-2 text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
activeCategory === category
? 'bg-pop-black text-white shadow-brutal'
: 'border border-gray-200 bg-white text-pop-black hover:border-pop-pink hover:text-pop-pink'
}`}
>
<span>{icon}</span> {category}
@@ -71,17 +73,17 @@ export default function FaqPage() {
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">
<div key={key} className={`overflow-hidden rounded-2xl border bg-white shadow-brutal transition-all duration-300 ${isOpen ? 'border-pop-pink/30' : 'border-gray-100'}`}>
<button onClick={() => toggle(key)} className="flex w-full items-start justify-between gap-4 px-6 py-5 text-left">
<span className="text-sm font-semibold leading-snug text-pop-black md:text-base">{item.q}</span>
<svg className={`h-5 w-5 shrink-0 text-pop-pink 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 className="mb-4 h-px w-full bg-gray-100" />
<p className="text-sm leading-relaxed text-gray-600">{item.a}</p>
</div>
)}
</div>
@@ -89,13 +91,13 @@ export default function FaqPage() {
})}
</div>
<div className="glass rounded-2xl p-8 text-center space-y-4 border border-white/5 mt-12">
<div className="mt-12 space-y-4 rounded-3xl border border-gray-100 bg-white p-8 text-center shadow-brutal">
<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">
<h3 className="text-xl font-bold text-pop-black">Still have questions?</h3>
<p className="text-sm text-gray-600">Our team is here to help — usually responds within 2 hours.</p>
<Link href="/contact" className="inline-block rounded-full bg-pop-black px-8 py-3 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-300 hover:bg-pop-pink">
Contact Us
</a>
</Link>
</div>
</div>
</div>
+2 -2
View File
@@ -30,8 +30,8 @@ export default async function RootLayout({
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">
<html lang="th" className="scroll-smooth">
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
<ClientProviders>
<Navbar categories={categories} />
<main className="pt-[72px]">
+198
View File
@@ -0,0 +1,198 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react'
import { formatPrice } from '@homaroma/types'
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '../../../lib/cms'
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
const lang = 'th'
function orderLabel(id: number) {
return `#${id.toString().padStart(4, '0')}`
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
PENDING_PAYMENT: 'รอชำระเงิน',
PAID: 'ชำระเงินแล้ว',
PROCESSING: 'กำลังจัดเตรียมสินค้า',
SHIPPED: 'จัดส่งแล้ว',
DELIVERED: 'จัดส่งสำเร็จ',
CANCELLED: 'ยกเลิกแล้ว',
}
return labels[status] ?? status.replaceAll('_', ' ')
}
function statusTone(status: string) {
if (status === 'PENDING_PAYMENT') return 'bg-amber-50 text-amber-700 border-amber-100'
if (status === 'PAID' || status === 'PROCESSING') return 'bg-emerald-50 text-emerald-700 border-emerald-100'
if (status === 'SHIPPED' || status === 'DELIVERED') return 'bg-sky-50 text-sky-700 border-sky-100'
if (status === 'CANCELLED') return 'bg-rose-50 text-rose-700 border-rose-100'
return 'bg-gray-50 text-gray-700 border-gray-100'
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const result = await getOrderResult(id)
if (!result.ok) return { title: 'Order Not Found | HomAroma' }
return {
title: `Order ${orderLabel(result.data.id)} | HomAroma`,
description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก HomAroma`,
}
}
export default async function OrderStatusPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const [orderResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getOrderResult(id),
getCategoriesResult(lang),
getCmsPageResult('order-success-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] order-success-layout unavailable: ${cmsLayoutResult.error}`)
}
if (!orderResult.ok) {
if (orderResult.status === 404) notFound()
return (
<main className="min-h-[70vh] bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<section className="mx-auto max-w-3xl px-6 py-24 text-center">
<div className="rounded-3xl border border-rose-100 bg-white p-10 shadow-brutal">
<h1 className="text-2xl font-semibold text-pop-black">ไม่สามารถโหลดคำสั่งซื้อนี้ได้</h1>
<p className="mt-3 text-sm text-rose-500">{orderResult.error}</p>
<Link href="/track" className="mt-6 inline-flex font-semibold text-pop-pink">กลับไปค้นหาคำสั่งซื้อ</Link>
</div>
</section>
</main>
)
}
const order = orderResult.data
const subtotal = order.subtotal ?? Math.max(0, order.totalAmount - order.shippingFee)
return (
<main className="min-h-[70vh] bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<section className="mx-auto max-w-4xl space-y-8 px-6 py-16 md:py-20">
<div className="text-center">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-white text-pop-pink shadow-brutal">
<CheckCircle2 className="h-10 w-10" />
</div>
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Order Status</p>
<h1 className="mt-4 text-4xl font-semibold text-pop-black md:text-5xl">
{orderLabel(order.id)}
</h1>
<p className="mt-3 text-gray-600">คำสั่งซื้อของคุณถูกบันทึกแล้ว สามารถตรวจสอบสถานะล่าสุดได้จากหน้านี้</p>
</div>
<section className="grid gap-5 md:grid-cols-3">
<article className="rounded-3xl bg-white p-6 shadow-brutal">
<Clock className="mb-4 h-6 w-6 text-pop-pink" />
<p className="text-xs font-bold uppercase tracking-[0.22em] text-gray-400">Created</p>
<p className="mt-2 font-semibold text-pop-black">
{new Date(order.createdAt).toLocaleDateString('th-TH')}
</p>
</article>
<article className="rounded-3xl bg-white p-6 shadow-brutal">
<PackageCheck className="mb-4 h-6 w-6 text-pop-pink" />
<p className="text-xs font-bold uppercase tracking-[0.22em] text-gray-400">Status</p>
<span className={`mt-3 inline-flex rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-[0.14em] ${statusTone(order.status)}`}>
{statusLabel(order.status)}
</span>
</article>
<article className="rounded-3xl bg-white p-6 shadow-brutal">
<Truck className="mb-4 h-6 w-6 text-pop-pink" />
<p className="text-xs font-bold uppercase tracking-[0.22em] text-gray-400">Tracking No.</p>
<p className="mt-2 font-semibold text-pop-black">{order.trackingNo || 'อยู่ระหว่างจัดเตรียมพัสดุ'}</p>
</article>
</section>
<section className="rounded-3xl bg-white p-8 shadow-brutal">
<h2 className="border-b border-gray-100 pb-4 text-sm font-bold uppercase tracking-[0.22em] text-pop-pink">
Order Items
</h2>
<div className="mt-6 space-y-4">
{order.items.map((item) => (
<div key={item.id} className="flex items-center gap-4 rounded-2xl bg-pop-bg p-4">
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-white">
<img
src={item.productImage || '/images/oil_ozone.png'}
alt={item.productName}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-pop-black">{item.productName}</p>
<p className="text-sm text-gray-500">
{item.variantSize ? `${item.variantSize} · ` : ''}x {item.quantity}
</p>
</div>
<p className="font-bold text-pop-pink">{formatPrice(item.priceAtPurchase * item.quantity)}</p>
</div>
))}
</div>
<div className="mt-6 space-y-3 border-t border-gray-100 pt-5 text-sm">
<div className="flex justify-between text-gray-500">
<span>Subtotal</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between text-gray-500">
<span>Shipping Fee</span>
<span>{formatPrice(order.shippingFee)}</span>
</div>
{(order.discountAmount ?? 0) > 0 && (
<div className="flex justify-between text-emerald-600">
<span>Discount{order.couponCode ? ` (${order.couponCode})` : ''}</span>
<span>-{formatPrice(order.discountAmount)}</span>
</div>
)}
<div className="flex justify-between border-t border-gray-100 pt-4 text-lg font-bold text-pop-black">
<span>Total</span>
<span className="text-pop-pink">{formatPrice(order.totalAmount)}</span>
</div>
</div>
</section>
{order.status === 'PENDING_PAYMENT' && (
<section className="rounded-3xl border border-amber-100 bg-amber-50 p-6 text-amber-800">
<h2 className="font-semibold">คำสั่งซื้อนี้ยังรอการชำระเงิน</h2>
<p className="mt-2 text-sm leading-relaxed">
หน้านี้รองรับการตรวจสอบสถานะคำสั่งซื้อแล้ว ส่วนการชำระเงินและอัปโหลดสลิปบน website จะย้ายมาในรอบถัดไป เพื่อไม่ทำ workaround ที่เสี่ยงกับ payment flow
</p>
</section>
)}
<div className="flex flex-wrap justify-center gap-3">
<Link href="/track" className="rounded-full bg-white px-6 py-3 font-semibold text-pop-black shadow-brutal transition hover:text-pop-pink">
ค้นหาออเดอร์อื่น
</Link>
<Link href="/shop" className="rounded-full bg-pop-pink px-6 py-3 font-semibold text-white transition hover:bg-pop-red">
กลับไปช้อปต่อ
</Link>
</div>
</section>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</main>
)
}
+26 -153
View File
@@ -1,170 +1,43 @@
import Link from 'next/link'
import ProductCard from '../components/ProductCard'
import { getCategoryImage, type Category, type Product, type Hero } from '@homaroma/types'
import { API_BASE } from '../lib/api'
import type { Metadata } from 'next'
import DynamicPageRenderer from '../components/dynamic/DynamicPageRenderer'
import { getCategories, getCmsPage } from '../lib/cms'
// Cache settings for Next.js fetch
const revalidate = 60; // Revalidate cache every 60 seconds
const lang = 'th'
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 [] }
export async function generateMetadata(): Promise<Metadata> {
const page = await getCmsPage('home', lang)
return {
title: page?.title ? `${page.title} | HomAroma` : 'HomAroma',
description: 'Premium aromatherapy, home fragrance, and botanical scent products from HomAroma.',
}
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(),
const [categories, page] = await Promise.all([
getCategories(lang),
getCmsPage('home', lang),
])
const sections = page?.sections ?? []
return (
<div className="min-h-screen bg-pop-bg">
{/* Hero Section */}
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-[21/9] flex items-center justify-center overflow-hidden bg-white">
<div className="absolute inset-0 z-0 opacity-40">
<img
src={hero?.imageUrl || "/images/hero.png"}
className="w-full h-full object-cover scale-[1.02] filter contrast-125 saturate-150"
alt="Hero Background"
/>
<div className="absolute inset-0 bg-gradient-to-r from-pop-bg/80 to-transparent"></div>
</div>
<div className="relative z-10 text-center md:text-left space-y-6 px-8 animate-pop-in max-w-6xl w-full mx-auto">
<p className="text-pop-pink font-semibold tracking-[0.2em] text-sm uppercase">
{hero?.tagline || "Vibrant Elegance Collection"}
</p>
<h1 className="text-5xl md:text-7xl font-bold text-pop-black leading-[1.1] tracking-tight max-w-3xl">
{hero ? (
hero.title.split('[').map((part, i) => {
if (part.includes(']')) {
const [accent, rest] = part.split(']')
return (
<span key={i}>
<span className="text-pop-pink font-semibold">
{accent}
</span>
{rest}
</span>
)
}
return <span key={i}>{part}</span>
})
{sections.length > 0 ? (
<DynamicPageRenderer sections={sections} categories={categories} lang={lang} />
) : (
<>
Inhale <span className="text-pop-pink">Serenity</span>
</>
)}
</h1>
<p className="text-gray-600 max-w-xl text-lg md:text-xl font-normal pt-4 pb-6">
{hero?.description || "Crafting premium sensory experiences that transform your space into a sanctuary of peace."}
<section className="mx-auto flex min-h-[70vh] max-w-5xl flex-col items-center justify-center px-6 text-center">
<p className="mb-4 text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">
HomAroma
</p>
<h1 className="text-5xl font-semibold text-pop-black md:text-7xl">
Premium scents for calmer spaces
</h1>
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-gray-600">
The home CMS layout is unavailable right now, so this SEO-safe fallback keeps the page meaningful while the API is checked.
</p>
<div className="flex flex-col sm:flex-row gap-4 items-center md:justify-start justify-center">
<Link
href={hero?.buttonLink || "/shop"}
className="brutal-button shadow-lg shadow-pop-pink/30"
>
{hero?.buttonText || "Explore Collection"}
</Link>
<Link
href="#categories"
className="px-8 py-4 text-pop-black font-medium hover:text-pop-pink transition-colors"
>
Learn More
</Link>
</div>
</div>
</section>
{/* Categories Showcase */}
<section id="categories" className="py-24 px-6 relative overflow-hidden bg-white">
<div className="max-w-7xl mx-auto space-y-16 relative z-10">
<div className="text-center space-y-4">
<h2 className="text-4xl md:text-5xl font-bold tracking-tight text-pop-black">
Our Collections
</h2>
<p className="text-gray-500 max-w-2xl mx-auto">Discover the perfect scent for every moment</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
{categories.map((cat) => (
<Link
key={cat.id}
href={`/category/${cat.id}`}
className="relative h-80 rounded-2xl group overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300"
>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
<img
src={cat.imageUrl || getCategoryImage(typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || '')}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || ''}
/>
<div className="absolute bottom-6 left-6 right-6 z-20 text-white">
<span className="font-semibold text-xl block mb-1">
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || ''}
</span>
{cat._count && (
<span className="text-sm font-medium text-white/80">
{cat._count.products} Products
</span>
)}
</div>
</Link>
))}
</div>
</div>
</section>
{/* Featured Products */}
<section className="bg-pop-bg py-24 px-6 relative">
<div className="max-w-7xl mx-auto space-y-12 relative z-10">
<div className="flex flex-col md:flex-row justify-between items-end gap-6 border-b border-gray-200 pb-6">
<div>
<h2 className="text-4xl md:text-5xl font-bold text-pop-black">
Featured Picks
</h2>
</div>
<Link
href="/shop"
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
>
View All Collection &rarr;
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{featuredProducts.map((p) => (
<div key={p.id}>
<ProductCard product={p} />
</div>
))}
</div>
</div>
</section>
</div>
)
}
+118 -100
View File
@@ -1,46 +1,38 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
import { getCategoryImage, type Product } from '@homaroma/types'
import AddToCartSection from '../../../components/AddToCartSection'
import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer'
import ProductCard from '../../../components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '../../../lib/cms'
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
import { resolveLocalized } from '../../../lib/localization'
import { API_BASE } from '../../../lib/api'
import type { TranslatableString } from '@homaroma/types'
/** Resolve TranslatableString → plain string (SSR: always defaults to 'th') */
function resolveStr(v: TranslatableString | null | undefined, lang = 'th'): string {
if (!v) return ''
if (typeof v === 'string') return v
return v[lang] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
}
async function getProduct(id: string): Promise<Product | null> {
try {
const res = await fetch(`${API_BASE}/products/${id}?lang=th`, { next: { revalidate: 60 } })
return res.ok ? res.json() : null
} catch { return null }
}
const lang = 'th'
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 [] }
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
if (!result.ok) return []
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
}
// 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)
const product = await getProduct(id, lang)
if (!product) return { title: 'Product Not Found | HomAroma' }
const productName = resolveLocalized(product.name, lang) || product.scent.name
const categoryName = resolveLocalized(product.category.name, lang)
const description = resolveLocalized(product.description, lang)
return {
title: `${product.scent.name} ${typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''} | HomAroma`,
description: resolveStr(product.description) ?? `Premium ${typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''} — ${product.scent.name}. Shop HomAroma for high-quality botanical scents.`,
title: `${productName} ${categoryName ? `| ${categoryName}` : ''} | HomAroma`,
description: description || `Premium ${categoryName} from HomAroma.`,
openGraph: {
title: product.scent.name,
description: resolveStr(product.description) ?? '',
title: productName,
description,
images: product.images?.[0]?.url ? [product.images[0].url] : [],
},
}
@@ -48,86 +40,112 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
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) : []),
const [productResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getProductResult(id, lang),
getCategoriesResult(lang),
getCmsPageResult('product-detail-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!product) notFound()
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] product-detail-layout unavailable: ${cmsLayoutResult.error}`)
}
const firstVariant = product.variants[0]
if (!productResult.ok) {
if (productResult.status === 404) notFound()
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">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''}
</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(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || '')}
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">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''}
</div>
</div>
{/* Info — Interactive (Add to Cart, Variant select) must be Client */}
<AddToCartSection product={product} />
</div>
{/* Full Details Section */}
{resolveStr(product.details) && (
<div className="pt-16 border-t border-white/10 space-y-6">
<h2 className="text-3xl font-bold">Product Details</h2>
<div
className="text-slate-300 leading-relaxed prose prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: resolveStr(product.details) }}
/>
</div>
<div className="min-h-screen bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{/* 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 {typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''}
</p>
<h2 className="text-3xl font-bold">Related Products</h2>
<div className="px-6 py-20">
<div className="mx-auto max-w-3xl rounded-3xl border border-rose-100 bg-white p-10 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">Unable to load this product</p>
<p className="mt-2 text-sm text-rose-500">{productResult.error}</p>
<Link href="/shop" className="mt-6 inline-flex font-semibold text-pop-pink">Back to Shop</Link>
</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>
)
}
const product = productResult.data
const related = await getRelated(product.category.id, product.id)
const productName = resolveLocalized(product.name, lang) || product.scent.name
const categoryName = resolveLocalized(product.category.name, lang)
const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
return (
<div className="min-h-screen bg-pop-bg pb-24 md:pb-0">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
<div className="mx-auto max-w-7xl space-y-16 px-6 py-14 md:py-20">
<nav className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-widest text-gray-400">
<Link href="/" className="transition hover:text-pop-pink">Home</Link>
<span>/</span>
<Link href="/shop" className="transition hover:text-pop-pink">Shop</Link>
<span>/</span>
<Link href={`/category/${product.category.id}`} className="transition hover:text-pop-pink">
{categoryName}
</Link>
<span>/</span>
<span className="text-pop-black">{productName}</span>
</nav>
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
<div className="relative overflow-hidden rounded-3xl bg-white shadow-brutal">
<img
src={imageUrl}
className="aspect-square h-full w-full object-cover"
alt={productName}
/>
{categoryName && (
<div className="absolute left-5 top-5 rounded-full bg-white/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-pop-black shadow-sm">
{categoryName}
</div>
)}
</div>
<AddToCartSection product={product} />
</section>
{details && (
<section className="rounded-3xl bg-white p-8 shadow-brutal md:p-12">
<h2 className="mb-6 text-3xl font-semibold text-pop-black">Product Details</h2>
<div
className="product-editorial leading-relaxed text-gray-600"
dangerouslySetInnerHTML={{ __html: details }}
/>
</section>
)}
{related.length > 0 && (
<section className="space-y-8">
<div className="flex flex-col gap-4 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">
More {categoryName}
</p>
<h2 className="text-3xl font-semibold text-pop-black">Related Products</h2>
</div>
<Link href={`/category/${product.category.id}`} className="font-semibold text-pop-pink transition hover:text-pop-red">
View All &rarr;
</Link>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{related.map((item) => <ProductCard key={item.id} product={item} />)}
</div>
</section>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { User } from 'lucide-react'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import ProfileClient from '../../components/ProfileClient'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'th'
export const metadata = {
title: 'โปรไฟล์สมาชิก | HomAroma',
description: 'จัดการโปรไฟล์สมาชิก HomAroma ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง',
}
export default async function ProfilePage() {
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('profile-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] profile-layout unavailable: ${cmsLayoutResult.error}`)
}
return (
<main className="min-h-[70vh] bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<section className="mx-auto max-w-4xl px-6 pt-20 text-center md:pt-24">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-white text-pop-pink shadow-brutal">
<User className="h-8 w-8" />
</div>
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Member Profile</p>
<h1 className="mt-4 text-4xl font-semibold text-pop-black md:text-5xl">โปรไฟล์ของฉัน</h1>
<p className="mx-auto mt-4 max-w-2xl text-gray-600">
ตรวจสอบประวัติคำสั่งซื้อ คะแนนสะสม และจัดการที่อยู่จัดส่งสำหรับการสั่งซื้อครั้งถัดไป
</p>
</section>
)}
<section className="mx-auto max-w-5xl px-6 py-14 md:py-20">
<ProfileClient />
</section>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</main>
)
}
+51 -29
View File
@@ -1,65 +1,87 @@
import Link from 'next/link'
import type { Article } from '@homaroma/types'
import { API_BASE } from '../../lib/api'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'th'
export const metadata = {
title: 'Scent Guide | HomAroma',
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 [articlesResult, categoriesResult, cmsLayoutResult] = await Promise.all([
getArticlesResult(),
getCategoriesResult(lang),
getCmsPageResult('scent-guide-layout', lang),
])
const articles = articlesResult.ok ? articlesResult.data : []
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] scent-guide-layout unavailable: ${cmsLayoutResult.error}`)
}
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 className="flex min-h-screen flex-col bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<div className="space-y-4 border-b border-gray-200 bg-white px-6 pb-16 pt-32 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-pop-pink">Journal</p>
<h1 className="text-5xl font-bold text-pop-black">Scent Guide</h1>
<p className="mx-auto max-w-lg text-gray-600">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">
<div className="mx-auto w-full max-w-7xl flex-1 px-6 py-16">
{!articlesResult.ok ? (
<div className="rounded-3xl border border-rose-100 bg-white px-6 py-20 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">Unable to load articles</p>
<p className="mt-2 text-sm text-rose-500">{articlesResult.error}</p>
</div>
) : articles.length === 0 ? (
<div className="rounded-3xl bg-white px-6 py-20 text-center italic text-gray-500 shadow-brutal">
Articles coming soon. Check back later!
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{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"
className="group flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-brutal transition-all duration-300 hover:-translate-y-1 hover:shadow-brutal-hover"
>
<div className="aspect-[4/3] bg-night-900 overflow-hidden shrink-0">
<div className="aspect-[4/3] shrink-0 overflow-hidden bg-pop-bg">
{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" />
<img src={article.coverImage} alt={article.title} className="h-full w-full object-cover transition-all duration-500 group-hover:scale-105" />
) : (
<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 className="flex h-full w-full items-center justify-center bg-pop-bg">
<span className="text-sm uppercase tracking-widest text-gray-500">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">
<div className="flex flex-1 flex-col space-y-3 p-6">
<p className="text-[10px] font-bold uppercase tracking-widest text-pop-pink">
{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 }} />
<h2 className="text-xl font-bold leading-tight text-pop-black transition-colors group-hover:text-pop-pink">{article.title}</h2>
<div className="line-clamp-3 text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: article.content }} />
</div>
</Link>
))}
</div>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</div>
)
}
+94 -110
View File
@@ -1,136 +1,120 @@
"use client"
import { useState, useEffect, useRef, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import ProductCard from '../../components/ProductCard'
import { type Product } from '@homaroma/types'
import { API_BASE } from '../../lib/api'
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
import { resolveLocalized } from '../../lib/localization'
function SearchResults() {
const searchParams = useSearchParams()
const router = useRouter()
const q = searchParams.get('q') || ''
const lang = 'th'
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)
export const metadata = {
title: 'Search Products | HomAroma',
description: 'Search HomAroma products by scent, category, or keyword.',
}
// Sync input → URL
useEffect(() => {
const timer = setTimeout(() => {
if (query.trim()) router.replace(`/search?q=${encodeURIComponent(query.trim())}`)
}, 400)
return () => clearTimeout(timer)
}, [query, router])
export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const params = await searchParams
const rawQuery = Array.isArray(params.q) ? params.q[0] : params.q
const query = rawQuery?.trim() ?? ''
const [categoriesResult, cmsLayoutResult, productsResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('search-layout', lang),
query ? getProductsResult(`search=${encodeURIComponent(query)}&limit=1000`, lang) : Promise.resolve({ ok: true as const, data: [] }),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
const products = productsResult.ok ? productsResult.data : []
// 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])
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] search-layout unavailable: ${cmsLayoutResult.error}`)
}
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 className="min-h-screen bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<section className="border-b border-gray-200 bg-white px-6 py-16 text-center">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Search</p>
<h1 className="mt-4 text-5xl font-semibold tracking-tight text-pop-black">Find Your Scent</h1>
</section>
)}
</div>
</div>
{!loading && q && (
<p className="text-slate-500 text-sm">
<section className="border-b border-gray-100 bg-white/70 px-6 py-8">
<form className="mx-auto flex max-w-xl overflow-hidden rounded-full border border-gray-200 bg-white shadow-brutal" action="/search">
<input
name="q"
defaultValue={query}
placeholder="Search by scent or product name..."
className="min-w-0 flex-1 px-5 py-3 text-sm text-pop-black outline-none placeholder:text-gray-400"
/>
<button className="bg-pop-black px-6 py-3 text-sm font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
Search
</button>
</form>
{categories.length > 0 && (
<div className="mx-auto mt-5 flex max-w-3xl flex-wrap justify-center gap-2">
{categories.map((category) => {
const name = resolveLocalized(category.name, lang)
if (!name) return null
return (
<Link
key={category.id}
href={`/search?q=${encodeURIComponent(name)}`}
className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-xs font-semibold text-gray-600 transition hover:border-pop-pink hover:text-pop-pink"
>
{name}
</Link>
)
})}
</div>
)}
{query && (
<p className="mt-5 text-center text-sm text-gray-500">
{products.length > 0
? `Found ${products.length} result${products.length !== 1 ? 's' : ''} for "${q}"`
: `No results for "${q}"`}
? `Found ${products.length} result${products.length !== 1 ? 's' : ''} for "${query}"`
: `No results for "${query}"`}
</p>
)}
</div>
</section>
<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 className="mx-auto max-w-7xl px-6 py-12">
{!query ? (
<div className="rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">Type something to search</p>
<Link href="/shop" className="mt-4 inline-flex font-semibold text-pop-pink">
Browse All Products &rarr;
</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" />
))}
) : !productsResult.ok ? (
<div className="rounded-3xl border border-rose-100 bg-white px-6 py-20 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">Unable to search products</p>
<p className="mt-2 text-sm text-rose-500">{productsResult.error}</p>
</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 className="rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">No products found</p>
<p className="mt-2 text-sm text-gray-500">Try a different scent name or keyword.</p>
<Link href="/shop" className="mt-4 inline-flex font-semibold text-pop-pink">
Browse All Products &rarr;
</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 className="grid grid-cols-1 gap-8 md:grid-cols-3">
{products.map((product) => <ProductCard key={product.id} product={product} />)}
</div>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</div>
</div>
)
}
export default function SearchPage() {
return (
<Suspense>
<SearchResults />
</Suspense>
)
}
+80 -128
View File
@@ -1,152 +1,104 @@
"use client";
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import ProductCard from '../../components/ProductCard'
import { type Category, type Product } from '@homaroma/types'
import ShopCouponBridge from '../../components/ShopCouponBridge'
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
import { resolveLocalized } from '../../lib/localization'
import { API_BASE } from '../../lib/api'
const lang = 'th'
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)
export const metadata = {
title: 'Shop Aroma Products | HomAroma',
description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.',
}
useEffect(() => {
fetch(`${API_BASE}/categories`)
.then((r) => r.json())
.then((data: Category[]) => { setCategories(data); setLoading(false) })
.catch(console.error)
}, [])
export default async function ShopPage({ searchParams }: { searchParams: Promise<{ coupon?: string | string[] }> }) {
const params = await searchParams
const coupon = Array.isArray(params.coupon) ? params.coupon[0] : params.coupon
const [categoriesResult, productsResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getProductsResult('limit=1000', lang),
getCmsPageResult('shop-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const products = productsResult.ok ? productsResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
const apiError = !categoriesResult.ok
? categoriesResult.error
: !productsResult.ok
? productsResult.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])
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] shop-layout unavailable: ${cmsLayoutResult.error}`)
}
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="min-h-screen bg-pop-bg">
<ShopCouponBridge coupon={coupon} />
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<section className="border-b border-gray-200 bg-white px-6 py-16 text-center">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">All Products</p>
<h1 className="mt-4 text-5xl font-semibold tracking-tight text-pop-black">The Collection</h1>
<p className="mx-auto mt-4 max-w-2xl text-gray-600">
Browse our full range of premium aroma products crafted to elevate every space.
</p>
</section>
)}
<div className="max-w-7xl mx-auto px-6 py-12 space-y-10">
{/* Category Filter Tabs */}
<div className="mx-auto max-w-7xl space-y-10 px-6 py-12">
<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'
}`}
>
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || ''}
{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 className="rounded-full bg-pop-black px-5 py-2 text-xs font-bold uppercase tracking-widest text-white" href="/shop">
All
</Link>
)}
{categories.map((category) => {
const name = resolveLocalized(category.name, lang)
return (
<Link
key={category.id}
href={`/category/${category.id}`}
className="rounded-full border border-gray-200 bg-white px-5 py-2 text-xs font-bold uppercase tracking-widest text-gray-600 transition hover:border-pop-pink hover:text-pop-pink"
>
{name}
{category._count && <span className="ml-2 opacity-60">({category._count.products})</span>}
</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 className="flex items-center justify-between">
<p className="text-sm text-gray-500">{products.length} products</p>
<Link href="/search" className="text-sm font-semibold text-pop-pink transition hover:text-pop-red">
Search products
</Link>
</div>
{apiError ? (
<div className="rounded-3xl border border-rose-100 bg-white px-6 py-20 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">Unable to load products</p>
<p className="mt-2 text-sm text-rose-500">{apiError}</p>
</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 className="rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
<p className="text-lg font-semibold text-pop-black">No products found</p>
<p className="mt-2 text-sm text-gray-500">Please check back soon.</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 className="grid grid-cols-1 gap-8 md:grid-cols-3">
{products.map((product) => <ProductCard key={product.id} product={product} />)}
</div>
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</div>
</div>
)
}
+56 -3
View File
@@ -1,5 +1,58 @@
import { redirect } from 'next/navigation'
import { MapPin } from 'lucide-react'
export default function TrackPage() {
redirect('/')
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import TrackOrderClient from '../../components/TrackOrderClient'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'th'
export const metadata = {
title: 'ติดตามคำสั่งซื้อ | HomAroma',
description: 'ตรวจสอบสถานะคำสั่งซื้อ HomAroma ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ',
}
export default async function TrackPage() {
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('track-order-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] track-order-layout unavailable: ${cmsLayoutResult.error}`)
}
return (
<main className="min-h-[70vh] bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<section className="mx-auto max-w-4xl px-6 pt-20 text-center md:pt-24">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-white text-pop-pink shadow-brutal">
<MapPin className="h-8 w-8" />
</div>
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Track Order</p>
<h1 className="mt-4 text-4xl font-semibold text-pop-black md:text-5xl">Track Your Status</h1>
<p className="mx-auto mt-4 max-w-2xl text-gray-600">
กรอกหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์เพื่อตรวจสอบสถานะสินค้า หรือกลับมาดูคำสั่งซื้อที่เคยค้นหาบนอุปกรณ์นี้
</p>
</section>
)}
<section className="mx-auto max-w-5xl px-6 py-14 md:py-20">
<TrackOrderClient />
</section>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</main>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { Heart } from 'lucide-react'
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
import WishlistClient from '../../components/WishlistClient'
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
import { splitCmsLayoutSections } from '../../lib/cms-sections'
const lang = 'th'
export const metadata = {
title: 'Wishlist | HomAroma',
description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ HomAroma',
}
export default async function WishlistPage() {
const [categoriesResult, cmsLayoutResult] = await Promise.all([
getCategoriesResult(lang),
getCmsPageResult('wishlist-layout', lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] wishlist-layout unavailable: ${cmsLayoutResult.error}`)
}
return (
<main className="min-h-[70vh] bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
<section className="mx-auto max-w-4xl px-6 pt-20 text-center md:pt-24">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-white text-rose-500 shadow-brutal">
<Heart className="h-8 w-8 fill-current" />
</div>
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">Wishlist</p>
<h1 className="mt-4 text-4xl font-semibold text-pop-black md:text-5xl">Your Wishlist</h1>
<p className="mx-auto mt-4 max-w-2xl text-gray-600">
Items you&apos;ve loved. Keep them here until you&apos;re ready to make them yours.
</p>
</section>
)}
<section className="mx-auto max-w-7xl px-6 py-14 md:py-20">
<WishlistClient />
</section>
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</main>
)
}
@@ -1,15 +1,10 @@
"use client";
import { useState } from 'react'
import Link from 'next/link'
import { getCategoryImage, formatPrice, type Product, type TranslatableString } from '@homaroma/types'
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
import { useCartStore } from '../store/cartStore'
/** Resolve TranslatableString → plain string */
function resolveStr(v: TranslatableString | null | undefined, lang = 'th'): string {
if (!v) return ''
if (typeof v === 'string') return v
return v[lang] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
}
import { resolveLocalized } from '../lib/localization'
interface Props {
product: Product
@@ -20,129 +15,134 @@ export default function AddToCartSection({ product }: Props) {
const { addToCart, toggleCart } = useCartStore()
const currentVariant = product.variants[selectedVariant]
const categoryName = resolveLocalized(product.category.name)
const productName = resolveLocalized(product.name) || product.scent.name
const description = resolveLocalized(product.description)
const handleAddToCart = () => {
if (!currentVariant) return
addToCart({
productId: product.id,
variantId: currentVariant.id,
quantity: 1,
price: currentVariant.price || product.price || 0,
productName: product.scent.name,
productName,
variantSize: currentVariant.size,
productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''),
productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName),
})
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">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''}
{categoryName && (
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">
{categoryName}
</p>
)}
<h1 className="text-4xl font-semibold leading-tight text-pop-black md:text-6xl">
{productName}
</h1>
<p className="text-sm font-semibold uppercase tracking-widest text-gray-500">
Premium Collection
</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>
{resolveStr(product.description) && (
<p className="text-slate-300 leading-relaxed">{resolveStr(product.description)}</p>
)}
{description && <p className="leading-relaxed text-gray-600">{description}</p>}
{/* Price */}
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-cyan-glow">
{formatPrice(currentVariant?.price)}
<span className="text-4xl font-bold text-pop-pink">
{formatPrice(currentVariant?.price ?? product.price)}
</span>
{currentVariant && (
<span className="text-slate-400 text-sm">/ {currentVariant.size}</span>
)}
{currentVariant && <span className="text-sm text-gray-500">/ {currentVariant.size}</span>}
</div>
{/* Variant Selector */}
{product.variants.length > 0 && (
<div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-slate-400 font-semibold">Select Size</p>
<p className="text-xs font-bold uppercase tracking-widest text-gray-500">Select Size</p>
<div className="flex flex-wrap gap-3">
{product.variants.map((v, i) => (
{product.variants.map((variant, index) => (
<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'
key={variant.id}
onClick={() => setSelectedVariant(index)}
className={`rounded-xl border px-5 py-3 text-sm font-bold uppercase tracking-widest transition ${
selectedVariant === index
? 'border-pop-pink bg-pop-pink text-white shadow-brutal'
: 'border-gray-200 bg-white text-gray-600 hover:border-pop-pink hover:text-pop-pink'
}`}
>
{v.size}
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-night-800' : 'text-slate-400'}`}>
{formatPrice(v.price)}
{variant.size}
<span className={`mt-1 block text-[10px] ${selectedVariant === index ? 'text-white/80' : 'text-gray-400'}`}>
{formatPrice(variant.price)}
</span>
</button>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-4">
<div className="hidden gap-4 md:flex">
<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"
className="flex-1 rounded-full bg-pop-black px-8 py-4 font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink"
>
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>
{categoryName && (
<Link
href={`/category/${product.category.id}`}
className="rounded-full border border-gray-200 bg-white px-8 py-4 font-bold uppercase tracking-widest text-gray-600 transition hover:border-pop-pink hover:text-pop-pink"
>
{categoryName}
</Link>
)}
</div>
{/* Meta */}
<div className="border-t border-white/5 pt-6 grid grid-cols-2 gap-4 text-sm text-slate-400">
<div className="grid grid-cols-2 gap-4 border-t border-gray-200 pt-6 text-sm text-gray-500">
<div>
<span className="text-xs uppercase tracking-widest block mb-1">Scent</span>
<span className="text-white font-medium">{product.scent.name}</span>
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-gray-400">Scent</span>
<span className="font-semibold text-pop-black">{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">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''}
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-gray-400">Category</span>
<Link href={`/category/${product.category.id}`} className="font-semibold text-pop-black transition hover:text-pop-pink">
{categoryName}
</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)]">
<div className="fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white/95 p-4 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur md:hidden">
{product.variants.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
{product.variants.map((v, i) => (
<div className="mb-3 flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
{product.variants.map((variant, index) => (
<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'
key={`mobile-v-${variant.id}`}
onClick={() => setSelectedVariant(index)}
className={`flex-shrink-0 rounded-full border px-4 py-2 text-[10px] font-bold uppercase tracking-widest transition ${
selectedVariant === index
? 'border-pop-pink bg-pop-pink text-white'
: 'border-gray-200 bg-white text-gray-500'
}`}
>
{v.size}
{variant.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">
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
{product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
</span>
<span className="text-xl font-bold text-cyan-glow">{formatPrice(currentVariant?.price)}</span>
<span className="text-xl font-bold text-pop-pink">{formatPrice(currentVariant?.price ?? product.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"
className="flex-1 rounded-full bg-pop-black px-5 py-3.5 font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink"
>
Add to Cart
</button>
+30 -56
View File
@@ -13,83 +13,62 @@ export default function CartDrawer() {
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"
className="absolute inset-0 bg-pop-black/45 backdrop-blur-sm"
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" />
<div className="relative flex h-full w-full max-w-md flex-col border-l border-gray-200 bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 bg-pop-bg/70 p-6">
<h2 className="flex items-center gap-2 text-xl font-bold text-pop-black">
<ShoppingBag className="h-5 w-5 text-pop-pink" />
{t('cart.title')}
<span className="text-sm font-normal text-slate-400 bg-white/5 px-2 py-0.5 rounded-full ml-2">
<span className="ml-2 rounded-full bg-white px-2 py-0.5 text-sm font-normal text-gray-500">
{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 onClick={() => toggleCart(false)} className="rounded-full border border-gray-200 p-2 text-gray-500 transition-colors hover:text-pop-pink">
<X className="h-5 w-5" />
</button>
</div>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex-1 space-y-6 overflow-y-auto p-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" />
<div className="flex h-full flex-col items-center justify-center space-y-4 text-gray-500">
<ShoppingBag className="h-16 w-16 opacity-30" />
<p>{t('cart.empty')}</p>
<button
onClick={() => toggleCart(false)}
className="text-cyan-glow hover:underline tracking-widest uppercase text-xs font-bold"
>
<button onClick={() => toggleCart(false)} className="text-xs font-bold uppercase tracking-widest text-pop-pink hover:underline">
{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">
<div key={item.cartItemId} className="group relative flex gap-4 rounded-2xl border border-gray-100 bg-pop-bg/60 p-4">
<div className="h-24 w-20 flex-shrink-0 overflow-hidden rounded-lg bg-white">
<img
src={item.productImage || '/images/oil_ozone.png'}
alt={item.productName}
className="w-full h-full object-cover opacity-80"
className="h-full w-full object-cover"
/>
</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="relative flex flex-1 flex-col space-y-1 pt-1">
<h3 className="pr-6 text-sm font-bold leading-tight text-pop-black">{item.productName}</h3>
{item.variantSize && <p className="text-xs text-gray-500">{item.variantSize}</p>}
<p className="mt-auto text-sm font-bold text-pop-pink">{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" />
<div className="mt-2 flex w-max items-center gap-3 rounded-lg border border-gray-200 bg-white">
<button onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)} className="p-1 transition-colors hover:text-pop-pink">
<Minus className="h-4 w-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 onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)} className="p-1 transition-colors hover:text-pop-pink">
<Plus className="h-4 w-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 onClick={() => removeFromCart(item.cartItemId)} className="absolute right-0 top-1 p-1 text-gray-400 opacity-0 transition-colors hover:text-rose-500 group-hover:opacity-100">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
@@ -97,19 +76,14 @@ export default function CartDrawer() {
)}
</div>
{/* Footer */}
{items.length > 0 && (
<div className="p-6 border-t border-white/10 bg-night-950/50 space-y-6">
<div className="space-y-6 border-t border-gray-100 bg-pop-bg/70 p-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>
<span className="text-pop-pink">{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"
>
<p className="text-center text-xs text-gray-500">{t('cart.shippingNote')}</p>
<Link href="/checkout" onClick={() => toggleCart(false)} className="block w-full rounded-full bg-pop-black py-4 text-center text-sm font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
{t('cart.proceed')}
</Link>
</div>
@@ -0,0 +1,765 @@
"use client";
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, CheckCircle2, Loader2, PackageCheck, ShoppingBag, Ticket, UserRound } from 'lucide-react'
import { formatPrice, type CartItem, type CustomerAddress } from '@homaroma/types'
import { API_BASE } from '../lib/api'
import { useAuthStore, type Customer } from '../store/authStore'
import { useCartStore } from '../store/cartStore'
type StoreSettings = {
shippingFee?: number
}
type Promotion = {
id: number
name: string
level: number
type: 'PERCENTAGE' | 'FIXED_AMOUNT' | string
value: number
minPurchase: number
}
type CouponValidationResponse = {
valid?: boolean
coupon?: {
code: string
}
discountAmount?: number
isFreeShipping?: boolean
error?: string
}
type CustomerWithPoints = Customer & {
points?: number
}
type CheckoutFormState = {
name: string
email: string
phone: string
}
type AddressFormState = {
addressLine: string
subdistrict: string
district: string
province: string
zipcode: string
}
type CreatedOrder = {
id: number
}
const emptyAddressForm: AddressFormState = {
addressLine: '',
subdistrict: '',
district: '',
province: '',
zipcode: '',
}
function calculatePromotionDiscount(promotion: Promotion | undefined, amount: number) {
if (!promotion || amount < promotion.minPurchase) return 0
if (promotion.type === 'PERCENTAGE') return amount * (promotion.value / 100)
return Math.min(promotion.value, amount)
}
function formatAddress(address: CustomerAddress) {
return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}`
}
async function readJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, init)
const data = await response.json()
if (!response.ok) {
const message = data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
? data.error
: `${path} returned ${response.status}`
throw new Error(message)
}
return data as T
}
function toOrderItem(item: CartItem) {
return {
productId: item.productId,
variantId: item.variantId ?? undefined,
quantity: item.quantity,
priceAtPurchase: item.price,
productName: item.productName,
variantSize: item.variantSize ?? undefined,
productImage: item.productImage ?? undefined,
}
}
function saveLocalOrder(orderId: number) {
try {
const existingValue = window.localStorage.getItem('homaroma_local_orders')
const existing = existingValue ? JSON.parse(existingValue) : []
const orderIds = Array.isArray(existing) ? existing.filter((id) => typeof id === 'number') : []
if (!orderIds.includes(orderId)) {
window.localStorage.setItem('homaroma_local_orders', JSON.stringify([orderId, ...orderIds]))
}
} catch (error) {
console.warn('[website-checkout] Failed to persist local order history', error)
}
}
export default function CheckoutClient() {
const router = useRouter()
const { customer } = useAuthStore()
const { items, clearCart, getCartTotal } = useCartStore()
const [hydrated, setHydrated] = useState(false)
const [shippingFee, setShippingFee] = useState(35)
const [loadingSettings, setLoadingSettings] = useState(true)
const [activePromotions, setActivePromotions] = useState<Promotion[]>([])
const [savedAddresses, setSavedAddresses] = useState<CustomerAddress[]>([])
const [selectedAddressId, setSelectedAddressId] = useState<number | 'new' | null>('new')
const [customerPoints, setCustomerPoints] = useState(0)
const [usePoints, setUsePoints] = useState(false)
const [pointsToUse, setPointsToUse] = useState(0)
const [formData, setFormData] = useState<CheckoutFormState>({
name: '',
email: '',
phone: '',
})
const [addressForm, setAddressForm] = useState<AddressFormState>(emptyAddressForm)
const [couponCode, setCouponCode] = useState('')
const [appliedCoupon, setAppliedCoupon] = useState<string | null>(null)
const [discountAmount, setDiscountAmount] = useState(0)
const [isFreeShipping, setIsFreeShipping] = useState(false)
const [couponLoading, setCouponLoading] = useState(false)
const [couponError, setCouponError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [summaryOpen, setSummaryOpen] = useState(false)
const subtotal = getCartTotal()
const promoLevel1 = useMemo(
() => activePromotions.find((promotion) => promotion.level === 1 && subtotal >= promotion.minPurchase),
[activePromotions, subtotal],
)
const promoLevel1Disc = calculatePromotionDiscount(promoLevel1, subtotal)
const subtotalAfterL1 = Math.max(0, subtotal - promoLevel1Disc)
const promoLevel2 = useMemo(
() => activePromotions.find((promotion) => promotion.level === 2 && subtotalAfterL1 >= promotion.minPurchase),
[activePromotions, subtotalAfterL1],
)
const promoLevel2Disc = calculatePromotionDiscount(promoLevel2, subtotalAfterL1)
const subtotalAfterL2 = Math.max(0, subtotalAfterL1 - promoLevel2Disc)
const subtotalAfterCoupon = Math.max(0, subtotalAfterL2 - discountAmount)
const maxPointsAllowed = Math.floor(subtotalAfterCoupon)
const pointsDiscount = usePoints ? Math.min(pointsToUse, customerPoints, maxPointsAllowed) : 0
const effectiveShippingFee = isFreeShipping ? 0 : shippingFee
const total = Math.max(0, subtotalAfterCoupon - pointsDiscount + effectiveShippingFee)
useEffect(() => {
queueMicrotask(() => setHydrated(true))
}, [])
useEffect(() => {
const loadCheckoutData = async () => {
setLoadingSettings(true)
try {
const [settings, promotions] = await Promise.all([
readJson<StoreSettings>('/settings'),
readJson<Promotion[]>('/promotions/active'),
])
if (typeof settings.shippingFee === 'number') setShippingFee(settings.shippingFee)
setActivePromotions(Array.isArray(promotions) ? promotions : [])
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถโหลดข้อมูล checkout ได้')
} finally {
setLoadingSettings(false)
}
}
void loadCheckoutData()
}, [])
useEffect(() => {
queueMicrotask(() => {
const savedCoupon = window.localStorage.getItem('homaroma_active_coupon')
if (savedCoupon) setCouponCode(savedCoupon)
})
}, [])
useEffect(() => {
if (!customer) return
queueMicrotask(() => {
setFormData((current) => ({
name: current.name || customer.displayName || '',
email: current.email || customer.email || '',
phone: current.phone || customer.phone || '',
}))
})
const loadCustomerData = async () => {
try {
const [addresses, profile] = await Promise.all([
readJson<CustomerAddress[]>(`/profile/${customer.id}/addresses`),
readJson<CustomerWithPoints>(`/customers/${customer.id}`),
])
const addressList = Array.isArray(addresses) ? addresses : []
const defaultAddress = addressList.find((address) => address.isDefault)
setSavedAddresses(addressList)
if (defaultAddress) {
setSelectedAddressId(defaultAddress.id)
setFormData((current) => ({
...current,
name: current.name || defaultAddress.recipientName,
phone: current.phone || defaultAddress.phone,
}))
} else if (addressList.length > 0) {
setSelectedAddressId(addressList[0].id)
}
setCustomerPoints(profile.points ?? customer.points ?? 0)
} catch (error) {
console.warn('[website-checkout] Failed to preload customer checkout data', error)
}
}
void loadCustomerData()
}, [customer])
useEffect(() => {
if (!couponCode || appliedCoupon || subtotalAfterL2 <= 0) return
const savedCoupon = window.localStorage.getItem('homaroma_active_coupon')
if (!savedCoupon || savedCoupon !== couponCode) return
const applySavedCoupon = async () => {
try {
const data = await readJson<CouponValidationResponse>('/coupons/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: savedCoupon, cartTotal: subtotalAfterL2 }),
})
if (data.valid && data.coupon?.code) {
setAppliedCoupon(data.coupon.code)
setDiscountAmount(data.discountAmount ?? 0)
setIsFreeShipping(Boolean(data.isFreeShipping))
} else {
window.localStorage.removeItem('homaroma_active_coupon')
}
} catch {
window.localStorage.removeItem('homaroma_active_coupon')
}
}
void applySavedCoupon()
}, [appliedCoupon, couponCode, subtotalAfterL2])
const applyCoupon = async () => {
const code = couponCode.trim().toUpperCase()
if (!code) return
setCouponLoading(true)
setCouponError(null)
try {
const data = await readJson<CouponValidationResponse>('/coupons/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, cartTotal: subtotalAfterL2 }),
})
if (!data.valid || !data.coupon?.code) {
throw new Error(data.error || 'คูปองนี้ไม่สามารถใช้ได้')
}
setAppliedCoupon(data.coupon.code)
setDiscountAmount(data.discountAmount ?? 0)
setIsFreeShipping(Boolean(data.isFreeShipping))
window.localStorage.setItem('homaroma_active_coupon', data.coupon.code)
} catch (error) {
setAppliedCoupon(null)
setDiscountAmount(0)
setIsFreeShipping(false)
setCouponError(error instanceof Error ? error.message : 'ไม่สามารถตรวจสอบคูปองได้')
} finally {
setCouponLoading(false)
}
}
const removeCoupon = () => {
setAppliedCoupon(null)
setCouponCode('')
setDiscountAmount(0)
setIsFreeShipping(false)
setCouponError(null)
window.localStorage.removeItem('homaroma_active_coupon')
}
const selectAddress = (address: CustomerAddress | 'new') => {
if (address === 'new') {
setSelectedAddressId('new')
return
}
setSelectedAddressId(address.id)
setFormData((current) => ({
...current,
name: current.name || address.recipientName,
phone: current.phone || address.phone,
}))
}
const resolveShippingAddress = () => {
if (selectedAddressId && selectedAddressId !== 'new') {
const address = savedAddresses.find((item) => item.id === selectedAddressId)
if (address) return formatAddress(address)
}
const values = Object.values(addressForm).map((value) => value.trim())
if (values.some((value) => !value)) return ''
return `${addressForm.addressLine.trim()} ต.${addressForm.subdistrict.trim()} อ.${addressForm.district.trim()} จ.${addressForm.province.trim()} ${addressForm.zipcode.trim()}`
}
const createOrder = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitError(null)
if (items.length === 0) {
setSubmitError('ตะกร้าสินค้าว่าง กรุณาเลือกสินค้าก่อน checkout')
return
}
const shippingAddress = resolveShippingAddress()
if (!shippingAddress) {
setSubmitError('กรุณากรอกที่อยู่จัดส่งให้ครบถ้วน')
return
}
setSubmitting(true)
try {
const order = await readJson<CreatedOrder>('/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerId: customer ? customer.id : null,
customerName: formData.name,
customerEmail: formData.email,
customerPhone: formData.phone,
shippingAddress,
totalAmount: total,
shippingFee: effectiveShippingFee,
subtotal,
promoLevel1Name: promoLevel1?.name ?? null,
promoLevel1Disc,
promoLevel2Name: promoLevel2?.name ?? null,
promoLevel2Disc,
couponCode: appliedCoupon ?? null,
discountAmount,
pointsUsed: pointsDiscount,
pointsDiscount,
items: items.map(toOrderItem),
}),
})
clearCart()
saveLocalOrder(order.id)
router.push(`/order/${order.id}`)
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'ไม่สามารถสร้างคำสั่งซื้อได้')
} finally {
setSubmitting(false)
}
}
if (!hydrated) {
return (
<div className="rounded-3xl bg-white p-10 text-center shadow-brutal">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-pop-pink" />
<p className="mt-4 text-sm font-semibold text-gray-500">กำลังโหลดตะกร้าสินค้า...</p>
</div>
)
}
if (items.length === 0) {
return (
<div className="mx-auto max-w-xl rounded-3xl bg-white p-10 text-center shadow-brutal">
<ShoppingBag className="mx-auto h-14 w-14 text-pop-pink" />
<h2 className="mt-5 text-3xl font-semibold text-pop-black">ตะกร้าสินค้าว่าง</h2>
<p className="mt-3 text-gray-600">เลือกสินค้าที่คุณชอบก่อน แล้วกลับมายืนยันคำสั่งซื้อที่หน้านี้ได้เลย</p>
<Link href="/shop" className="mt-8 inline-flex rounded-full bg-pop-black px-8 py-4 text-sm font-bold uppercase tracking-[0.16em] text-white transition hover:bg-pop-pink">
กลับไปเลือกสินค้า
</Link>
</div>
)
}
const maxRedeemablePoints = Math.min(customerPoints, maxPointsAllowed)
const visiblePointsToUse = Math.min(pointsToUse || maxRedeemablePoints, maxRedeemablePoints)
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_420px]">
<form id="checkout-form" onSubmit={createOrder} className="space-y-6">
<section className="rounded-3xl bg-white p-6 shadow-brutal md:p-8">
<div className="flex items-center gap-3 border-b border-gray-100 pb-5">
<UserRound className="h-5 w-5 text-pop-pink" />
<div>
<h2 className="text-xl font-semibold text-pop-black">ข้อมูลผู้รับสินค้า</h2>
<p className="text-sm text-gray-500">ข้อมูลนี้จะถูกส่งไปสร้างคำสั่งซื้อตาม contract ของ backend</p>
</div>
</div>
<div className="mt-6 grid gap-5 md:grid-cols-2">
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">ชื่อผู้รับ *</span>
<input
required
value={formData.name}
onChange={(event) => setFormData((current) => ({ ...current, name: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
placeholder="ชื่อ-นามสกุล"
/>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">เบอร์โทรศัพท์ *</span>
<input
required
pattern="[0-9]{9,10}"
title="กรุณากรอกเบอร์โทรศัพท์ 9-10 หลัก"
value={formData.phone}
onChange={(event) => setFormData((current) => ({ ...current, phone: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
placeholder="0812345678"
/>
</label>
<label className="space-y-2 md:col-span-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">อีเมล *</span>
<input
required
type="email"
value={formData.email}
onChange={(event) => setFormData((current) => ({ ...current, email: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
placeholder="you@example.com"
/>
</label>
</div>
</section>
<section className="rounded-3xl bg-white p-6 shadow-brutal md:p-8">
<div className="flex items-center gap-3 border-b border-gray-100 pb-5">
<PackageCheck className="h-5 w-5 text-pop-pink" />
<div>
<h2 className="text-xl font-semibold text-pop-black">ที่อยู่จัดส่ง</h2>
<p className="text-sm text-gray-500">สมาชิกสามารถเลือกที่อยู่ที่บันทึกไว้ หรือกรอกที่อยู่ใหม่ได้</p>
</div>
</div>
{savedAddresses.length > 0 && (
<div className="mt-6 grid gap-3">
{savedAddresses.map((address) => (
<label
key={address.id}
className={`flex cursor-pointer gap-3 rounded-2xl border p-4 transition ${
selectedAddressId === address.id
? 'border-pop-pink bg-pop-bg'
: 'border-gray-100 bg-white hover:border-pop-pink/40'
}`}
>
<input
type="radio"
name="shippingAddress"
checked={selectedAddressId === address.id}
onChange={() => selectAddress(address)}
className="mt-1 accent-pop-pink"
/>
<span>
<span className="block text-sm font-bold text-pop-black">
{address.recipientName}
{address.isDefault && (
<span className="ml-2 rounded-full bg-pop-pink/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-pop-pink">
default
</span>
)}
</span>
<span className="mt-1 block text-xs text-gray-500">{address.phone}</span>
<span className="mt-1 block text-sm text-gray-600">{formatAddress(address)}</span>
</span>
</label>
))}
<label
className={`flex cursor-pointer items-center gap-3 rounded-2xl border p-4 transition ${
selectedAddressId === 'new' ? 'border-pop-pink bg-pop-bg' : 'border-gray-100 bg-white hover:border-pop-pink/40'
}`}
>
<input
type="radio"
name="shippingAddress"
checked={selectedAddressId === 'new'}
onChange={() => selectAddress('new')}
className="accent-pop-pink"
/>
<span className="text-sm font-bold text-pop-black">กรอกที่อยู่ใหม่</span>
</label>
</div>
)}
{(savedAddresses.length === 0 || selectedAddressId === 'new') && (
<div className="mt-6 grid gap-5 md:grid-cols-2">
<label className="space-y-2 md:col-span-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">บ้านเลขที่ / ถนน / ซอย *</span>
<input
required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.addressLine}
onChange={(event) => setAddressForm((current) => ({ ...current, addressLine: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
placeholder="บ้านเลขที่ หมู่บ้าน ถนน ซอย"
/>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">ตำบล/แขวง *</span>
<input
required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.subdistrict}
onChange={(event) => setAddressForm((current) => ({ ...current, subdistrict: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
/>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">อำเภอ/เขต *</span>
<input
required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.district}
onChange={(event) => setAddressForm((current) => ({ ...current, district: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
/>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">จังหวัด *</span>
<input
required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.province}
onChange={(event) => setAddressForm((current) => ({ ...current, province: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
/>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">รหัสไปรษณีย์ *</span>
<input
required={selectedAddressId === 'new' || savedAddresses.length === 0}
value={addressForm.zipcode}
onChange={(event) => setAddressForm((current) => ({ ...current, zipcode: event.target.value }))}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
placeholder="10110"
/>
</label>
</div>
)}
</section>
{submitError && (
<div className="flex gap-3 rounded-2xl border border-rose-100 bg-rose-50 p-4 text-sm text-rose-700">
<AlertCircle className="h-5 w-5 shrink-0" />
<p>{submitError}</p>
</div>
)}
<button
type="submit"
disabled={submitting || loadingSettings}
className="flex w-full items-center justify-between rounded-full bg-pop-black px-6 py-4 text-sm font-bold uppercase tracking-[0.16em] text-white transition hover:bg-pop-pink disabled:cursor-not-allowed disabled:opacity-60 lg:hidden"
>
<span className="flex items-center gap-2">
{submitting && <Loader2 className="h-5 w-5 animate-spin" />}
สร้างคำสั่งซื้อ
</span>
<span>{formatPrice(total)}</span>
</button>
</form>
<aside className="lg:sticky lg:top-28 lg:self-start">
<section className="rounded-3xl bg-white p-6 shadow-brutal md:p-8">
<button
type="button"
onClick={() => setSummaryOpen((open) => !open)}
className="flex w-full items-center justify-between border-b border-gray-100 pb-5 text-left lg:pointer-events-none"
>
<span>
<span className="block text-xl font-semibold text-pop-black">สรุปรายการ</span>
<span className="mt-1 block text-sm text-gray-500">{items.length} รายการในตะกร้า</span>
</span>
<span className="font-bold text-pop-pink lg:hidden">{formatPrice(total)}</span>
</button>
<div className={`${summaryOpen ? 'block' : 'hidden'} mt-6 space-y-6 lg:block`}>
<div className="space-y-4">
{items.map((item) => (
<div key={item.cartItemId} className="flex gap-3">
<div className="h-16 w-14 shrink-0 overflow-hidden rounded-xl bg-pop-bg">
<img
src={item.productImage || '/images/oil_ozone.png'}
alt={item.productName}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-pop-black">{item.productName}</p>
<p className="mt-1 text-xs text-gray-500">{item.variantSize || 'Standard'} x {item.quantity}</p>
</div>
<p className="text-sm font-bold text-pop-pink">{formatPrice(item.price * item.quantity)}</p>
</div>
))}
</div>
<div className="border-t border-gray-100 pt-5">
{!appliedCoupon ? (
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">คูปองส่วนลด</label>
<div className="flex gap-2">
<input
value={couponCode}
onChange={(event) => setCouponCode(event.target.value.toUpperCase())}
placeholder="COUPON"
className="min-w-0 flex-1 rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm uppercase tracking-widest text-pop-black outline-none transition focus:border-pop-pink"
/>
<button
type="button"
onClick={applyCoupon}
disabled={couponLoading || !couponCode.trim()}
className="rounded-2xl bg-pop-black px-4 py-3 text-xs font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink disabled:opacity-50"
>
{couponLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ใช้'}
</button>
</div>
{couponError && <p className="text-xs font-semibold text-rose-500">{couponError}</p>}
</div>
) : (
<div className="flex items-center justify-between rounded-2xl border border-emerald-100 bg-emerald-50 p-4">
<span className="flex items-center gap-2 text-sm font-bold text-emerald-700">
<Ticket className="h-4 w-4" />
{appliedCoupon}
</span>
<button type="button" onClick={removeCoupon} className="text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-rose-500">
ลบ
</button>
</div>
)}
</div>
{customerPoints > 0 && (
<div className="space-y-3 border-t border-gray-100 pt-5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">คะแนนสะสม</span>
<span className="text-sm font-bold text-pop-black">{customerPoints} points</span>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-gray-100 bg-pop-bg p-4 text-sm font-semibold text-pop-black">
<input
type="checkbox"
checked={usePoints}
onChange={(event) => {
setUsePoints(event.target.checked)
if (event.target.checked) setPointsToUse(maxRedeemablePoints)
}}
className="accent-pop-pink"
disabled={maxRedeemablePoints <= 0}
/>
ใช้คะแนนเป็นส่วนลด
</label>
{usePoints && (
<input
type="number"
min={1}
max={maxRedeemablePoints}
value={visiblePointsToUse}
onChange={(event) => setPointsToUse(Math.min(Number(event.target.value), maxRedeemablePoints))}
className="w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink"
/>
)}
</div>
)}
<div className="space-y-3 border-t border-gray-100 pt-5 text-sm">
<div className="flex justify-between text-gray-500">
<span>Subtotal</span>
<span>{formatPrice(subtotal)}</span>
</div>
{promoLevel1Disc > 0 && (
<div className="flex justify-between text-emerald-600">
<span>{promoLevel1?.name}</span>
<span>-{formatPrice(promoLevel1Disc)}</span>
</div>
)}
{promoLevel2Disc > 0 && (
<div className="flex justify-between text-emerald-600">
<span>{promoLevel2?.name}</span>
<span>-{formatPrice(promoLevel2Disc)}</span>
</div>
)}
{discountAmount > 0 && (
<div className="flex justify-between text-emerald-600">
<span>Coupon {appliedCoupon ? `(${appliedCoupon})` : ''}</span>
<span>-{formatPrice(discountAmount)}</span>
</div>
)}
{pointsDiscount > 0 && (
<div className="flex justify-between text-emerald-600">
<span>Points</span>
<span>-{formatPrice(pointsDiscount)}</span>
</div>
)}
<div className="flex justify-between text-gray-500">
<span>Shipping</span>
{loadingSettings ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<span className={isFreeShipping ? 'text-gray-400 line-through' : ''}>{formatPrice(shippingFee)}</span>
)}
</div>
{isFreeShipping && (
<div className="flex justify-between text-emerald-600">
<span>Free shipping</span>
<span>-{formatPrice(shippingFee)}</span>
</div>
)}
<div className="flex justify-between border-t border-gray-100 pt-4 text-lg font-bold text-pop-black">
<span>Total</span>
<span className="text-pop-pink">{formatPrice(total)}</span>
</div>
</div>
<div className="rounded-2xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-800">
<div className="flex gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<p>
รอบนี้จะสร้างคำสั่งซื้อสถานะรอชำระเงิน แล้วพาไปหน้า Order Detail ส่วน payment/slip upload ยังเป็นงานถัดไปตามแผน
</p>
</div>
</div>
<button
type="submit"
form="checkout-form"
disabled={submitting || loadingSettings}
className="hidden w-full items-center justify-between rounded-full bg-pop-black px-6 py-4 text-sm font-bold uppercase tracking-[0.16em] text-white transition hover:bg-pop-pink disabled:cursor-not-allowed disabled:opacity-60 lg:flex"
>
<span className="flex items-center gap-2">
{submitting && <Loader2 className="h-5 w-5 animate-spin" />}
สร้างคำสั่งซื้อ
</span>
<span>{formatPrice(total)}</span>
</button>
</div>
</section>
</aside>
</div>
)
}
+24 -23
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import type { Category } from '@homaroma/types'
import { useTranslation } from 'react-i18next'
import { resolveLocalized } from '../lib/localization'
interface FooterProps {
categories?: Category[]
@@ -10,18 +11,18 @@ interface FooterProps {
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">
<footer className="border-t border-gray-200 bg-white px-6 py-20 text-pop-black">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 md:grid-cols-4">
<div className="space-y-6">
<div className="text-2xl font-bold tracking-widest">
HomAroma <span className="text-cyan-glow text-sm font-light italic">Essential</span>
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
</div>
<p className="text-slate-500 text-sm leading-relaxed">
<p className="text-sm leading-relaxed text-gray-600">
{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">
<a key={s} href="#" className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white">
{s}
</a>
))}
@@ -29,42 +30,42 @@ export default function Footer({ categories = [] }: FooterProps) {
</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>
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('footer.explore')}</h4>
<div className="flex flex-col space-y-2 text-sm text-gray-600">
<Link href="/" className="transition-colors hover:text-pop-pink">{t('nav.home')}</Link>
<Link href="/shop" className="transition-colors hover:text-pop-pink">{t('nav.shop')}</Link>
<Link href="/scent-guide" className="transition-colors hover:text-pop-pink">{t('nav.scentGuide')}</Link>
<Link href="/about" className="transition-colors hover:text-pop-pink">{t('nav.about')}</Link>
<Link href="/contact" className="transition-colors hover:text-pop-pink">{t('nav.contact')}</Link>
<Link href="/faq" className="transition-colors hover:text-pop-pink">{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">
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('nav.collections')}</h4>
<div className="flex flex-col space-y-2 text-sm text-gray-600">
{categories.map((c) => (
<Link key={c.id} href={`/category/${c.id}`} className="hover:text-white transition-colors">
{typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || ''}
<Link key={c.id} href={`/category/${c.id}`} className="transition-colors hover:text-pop-pink">
{resolveLocalized(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">
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">Newsletter</h4>
<p className="text-xs text-gray-500">Subscribe for exclusive releases and sensory wisdom.</p>
<div className="flex rounded-xl border border-gray-200 bg-pop-bg p-1">
<input
type="email"
placeholder="Your email"
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600"
className="w-full border-none bg-transparent p-2 text-sm text-pop-black outline-none placeholder:text-gray-400"
/>
<button className="bg-white text-black px-4 py-2 font-bold text-xs uppercase hover:bg-cyan-glow transition-colors shrink-0">
<button className="shrink-0 rounded-lg bg-pop-black px-4 py-2 text-xs font-bold uppercase text-white transition-colors hover:bg-pop-pink">
Join
</button>
</div>
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
<p className="text-[10px] uppercase tracking-widest text-gray-500">
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
</p>
</div>
+121 -143
View File
@@ -3,9 +3,12 @@ import Link from 'next/link'
import { useRouter, usePathname } from 'next/navigation'
import { useState, useEffect } from 'react'
import type { Category } from '@homaroma/types'
import { ShoppingBag, MapPin } from 'lucide-react'
import { Heart, ShoppingBag, MapPin, User } from 'lucide-react'
import { useCartStore } from '../store/cartStore'
import { useWishlistStore } from '../store/wishlistStore'
import { useAuthStore } from '../store/authStore'
import { useTranslation } from 'react-i18next'
import { resolveLocalized } from '../lib/localization'
interface NavbarProps {
categories?: Category[]
@@ -21,6 +24,8 @@ export default function Navbar({ categories = [] }: NavbarProps) {
const pathname = usePathname()
const { toggleCart, getCartCount } = useCartStore()
const wishlistCount = useWishlistStore((state) => state.items.length)
const customer = useAuthStore((state) => state.customer)
const cartCount = getCartCount()
const { t, i18n } = useTranslation()
@@ -30,23 +35,21 @@ export default function Navbar({ categories = [] }: NavbarProps) {
localStorage.setItem('homaroma_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('homaroma_local_orders')
if (stored) {
try { setLocalOrdersCount(JSON.parse(stored).length) } catch (e) {}
try {
const parsed = JSON.parse(stored) as unknown[]
queueMicrotask(() => setLocalOrdersCount(parsed.length))
} catch {}
}
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 = '' }
@@ -63,46 +66,48 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}
const navLinkClass = (path: string) =>
pathname === path ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
pathname === path
? 'text-pop-pink'
: 'text-pop-black/75 transition-colors hover:text-pop-pink'
const iconButtonClass = 'rounded-full border border-gray-200 bg-white p-2.5 text-pop-black shadow-sm transition hover:border-pop-pink hover:text-pop-pink hover:shadow-brutal'
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'
className={`fixed top-0 z-50 flex w-full items-center justify-between px-4 py-3.5 transition-all duration-300 md:px-6 md:py-4 ${
scrolled
? 'border-b border-gray-200 bg-white/95 shadow-brutal backdrop-blur-xl'
: 'border-b border-gray-100 bg-white/85 backdrop-blur-xl'
}`}
>
{/* Logo */}
<Link href="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-white uppercase italic shrink-0">
HomAroma <span className="text-cyan-glow text-xs md:text-sm not-italic font-light opacity-60">Essential</span>
<Link href="/" onClick={() => setMobileOpen(false)} className="shrink-0 text-xl font-bold uppercase italic tracking-widest text-pop-black md:text-2xl">
HomAroma <span className="text-xs font-semibold not-italic text-pop-pink md:text-sm">Essential</span>
</Link>
{/* ── DESKTOP NAV ── */}
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
<div className="hidden space-x-7 text-sm font-semibold uppercase tracking-wide md:flex">
<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">
<div className="group relative">
<button className="flex items-center gap-1 text-sm font-semibold uppercase tracking-wide text-pop-black/75 transition-colors hover:text-pop-pink">
{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">
<svg className="h-3 w-3 opacity-60 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">
<div className="pointer-events-none absolute left-1/2 top-full mt-4 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-gray-200 bg-white opacity-0 shadow-brutal transition-all duration-300 group-hover:pointer-events-auto group-hover:opacity-100">
<div className="space-y-0.5 p-2">
{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"
className="flex items-center justify-between rounded-xl px-4 py-2.5 text-xs uppercase tracking-widest text-gray-600 transition-colors hover:bg-pop-bg hover:text-pop-pink"
>
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || ''}
<svg className="w-3 h-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{resolveLocalized(cat.name)}
<svg className="h-3 w-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
@@ -116,141 +121,127 @@ export default function Navbar({ categories = [] }: NavbarProps) {
<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" />
<div className="hidden items-center gap-3 md:flex">
<Link href="/track" className={`relative ${iconButtonClass}`} aria-label="Track Orders" title="Track Orders">
<MapPin className="h-4 w-4" />
{localOrdersCount > 0 && <span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-rose-500" />}
</Link>
<Link href="/wishlist" className={`relative ${iconButtonClass}`} aria-label="Wishlist" title="Wishlist">
<Heart className="h-4 w-4" />
{wishlistCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-rose-500 text-[9px] font-bold text-white">
{wishlistCount}
</span>
)}
</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">
<Link href="/profile" className={iconButtonClass} aria-label="Profile" title={customer?.displayName || 'Profile'}>
{customer?.pictureUrl ? (
<img src={customer.pictureUrl} alt={customer.displayName || 'Profile'} className="h-4 w-4 rounded-full object-cover" />
) : (
<User className="h-4 w-4" />
)}
</Link>
<button onClick={() => setSearchOpen(true)} className={iconButtonClass} aria-label="Search">
<svg className="h-4 w-4" 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" />
<button onClick={() => toggleCart(true)} className={`relative ${iconButtonClass}`} aria-label="Cart">
<ShoppingBag className="h-4 w-4" />
{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">
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-pop-pink text-[9px] font-bold text-white">
{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"
>
<Link href="/shop" className="rounded-full bg-pop-black px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
{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"
>
<button onClick={toggleLanguage} className="rounded-full border border-pop-pink/30 bg-white px-3 py-2 text-xs font-bold uppercase text-pop-pink transition hover:bg-pop-bg">
{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" />
<div className="flex items-center gap-2 md:hidden">
<Link href="/track" onClick={() => setMobileOpen(false)} className={`relative ${iconButtonClass}`} aria-label="Track Orders">
<MapPin className="h-4 w-4" />
{localOrdersCount > 0 && <span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-rose-500" />}
</Link>
<Link href="/wishlist" onClick={() => setMobileOpen(false)} className={`relative ${iconButtonClass}`} aria-label="Wishlist">
<Heart className="h-4 w-4" />
{wishlistCount > 0 && (
<span className="absolute right-0 top-0 flex h-4 w-4 translate-x-1/4 -translate-y-1/4 items-center justify-center rounded-full bg-rose-500 text-[9px] font-bold text-white">
{wishlistCount}
</span>
)}
</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">
<Link href="/profile" onClick={() => setMobileOpen(false)} className={iconButtonClass} aria-label="Profile">
{customer?.pictureUrl ? (
<img src={customer.pictureUrl} alt={customer.displayName || 'Profile'} className="h-4 w-4 rounded-full object-cover" />
) : (
<User className="h-4 w-4" />
)}
</Link>
<button onClick={() => { setSearchOpen(true); setMobileOpen(false) }} className={iconButtonClass} aria-label="Search">
<svg className="h-5 w-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" />
<button onClick={() => { toggleCart(true); setMobileOpen(false) }} className={`relative ${iconButtonClass}`} aria-label="Cart">
<ShoppingBag className="h-5 w-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">
<span className="absolute right-0 top-0 flex h-4 w-4 translate-x-1/4 -translate-y-1/4 items-center justify-center rounded-full bg-pop-pink text-[9px] font-bold text-white">
{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'}`} />
<button onClick={() => setMobileOpen(prev => !prev)} className={iconButtonClass} aria-label={mobileOpen ? 'Close menu' : 'Open menu'}>
<div className="relative flex h-5 w-5 flex-col justify-center gap-[5px]">
<span className={`block h-0.5 w-5 rounded-full bg-pop-black transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} />
<span className={`block h-0.5 rounded-full bg-pop-black transition-all duration-300 ${mobileOpen ? 'w-0 opacity-0' : 'w-4'}`} />
<span className={`block h-0.5 rounded-full bg-pop-black transition-all duration-300 ${mobileOpen ? 'absolute w-5 -rotate-45' : 'w-5'}`} />
</div>
</button>
</div>
</nav>
{/* ── MOBILE DRAWER ── */}
<div className={`fixed inset-0 z-40 transition-all duration-300 md:hidden ${mobileOpen ? 'visible' : 'invisible'}`}>
<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'}`}
className={`absolute inset-0 bg-pop-black/45 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'}`}
className={`absolute right-0 top-0 flex h-full w-72 flex-col border-l border-gray-200 bg-white shadow-2xl 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 justify-between border-b border-gray-100 px-6 py-5">
<span className="text-sm font-bold uppercase tracking-widest text-pop-black">{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">
<button onClick={toggleLanguage} className="rounded-md border border-pop-pink/30 px-2.5 py-1 text-[10px] font-bold uppercase text-pop-pink">
{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">
<button onClick={() => setMobileOpen(false)} className="rounded-full border border-gray-200 p-1.5 text-pop-black transition hover:text-pop-pink">
<svg className="h-4 w-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">
<div className="flex-1 space-y-1 overflow-y-auto px-4 py-6">
{[
{ to: '/', label: t('nav.home') },
{ to: '/shop', label: t('nav.shop') },
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
{ to: '/wishlist', label: '♡ Wishlist' },
{ to: '/profile', label: customer ? `👤 ${customer.displayName || 'Profile'}` : '👤 Profile' },
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
{ to: '/about', label: t('nav.about') },
{ to: '/faq', label: t('nav.faq') },
@@ -260,29 +251,26 @@ export default function Navbar({ categories = [] }: NavbarProps) {
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'
}`
}
className={`block rounded-xl px-4 py-3 text-sm font-semibold tracking-wide transition-all duration-200 ${
pathname === item.to ? 'border border-pop-pink/20 bg-pop-bg text-pop-pink' : 'text-pop-black/75 hover:bg-pop-bg hover:text-pop-pink'
}`}
>
{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>
<p className="px-4 pb-2 text-[10px] uppercase tracking-widest text-gray-500">{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"
className="flex items-center justify-between rounded-xl px-4 py-2.5 text-sm text-gray-600 transition-all hover:bg-pop-bg hover:text-pop-pink"
>
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || ''}
<svg className="w-3 h-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{resolveLocalized(cat.name)}
<svg className="h-3 w-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
@@ -291,45 +279,35 @@ export default function Navbar({ categories = [] }: NavbarProps) {
)}
</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"
>
<div className="space-y-3 border-t border-gray-100 px-4 pb-8 pt-4">
<Link href="/shop" onClick={() => setMobileOpen(false)} className="block w-full rounded-xl bg-pop-black py-3 text-center text-xs font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
{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"
>
<Link href="/scent-guide" onClick={() => setMobileOpen(false)} className="block w-full rounded-xl border border-pop-pink/30 bg-white py-3 text-center text-xs font-bold uppercase tracking-widest text-pop-pink transition hover:bg-pop-bg">
{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"
className="fixed inset-0 z-[100] flex flex-col items-center bg-pop-bg/95 px-4 pt-24 backdrop-blur-xl md:px-6 md:pt-32"
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">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-widest text-gray-500">{t('nav.search.title')}</p>
<button onClick={() => setSearchOpen(false)} className="rounded-full border border-gray-200 bg-white p-2 text-pop-black transition hover:text-pop-pink">
<svg className="h-4 w-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">
<div className="flex overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-brutal transition-colors focus-within:border-pop-pink">
<svg className="m-4 h-5 w-5 shrink-0 text-gray-400" 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
@@ -337,20 +315,20 @@ export default function Navbar({ categories = [] }: NavbarProps) {
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"
className="w-full border-none bg-transparent p-4 pl-0 text-lg text-pop-black outline-none placeholder:text-gray-400 md:text-xl"
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">
<button type="submit" className="shrink-0 bg-pop-black px-4 text-xs font-bold uppercase tracking-widest text-white transition-colors hover:bg-pop-pink md:px-6">
Go
</button>
</div>
</form>
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest mb-3">Quick Links</p>
<p className="mb-3 text-xs uppercase tracking-widest text-gray-500">Quick Links</p>
<div className="flex flex-wrap gap-2">
{[
...categories.map(c => ({ label: typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || '', to: `/category/${c.id}` })),
...categories.map(c => ({ label: resolveLocalized(c.name), to: `/category/${c.id}` })),
{ label: '✨ Scent Guide', to: '/scent-guide' },
{ label: 'All Products', to: '/shop' },
].map(item => (
@@ -358,7 +336,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
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"
className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-xs font-semibold text-gray-600 transition-all hover:border-pop-pink hover:text-pop-pink"
>
{item.label}
</Link>
+44 -36
View File
@@ -1,63 +1,71 @@
import Link from 'next/link'
import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types'
import { resolveLocalized } from '../lib/localization'
import WishlistButton from './WishlistButton'
interface ProductCardProps {
product: Product
}
export default function ProductCard({ product }: ProductCardProps) {
const categoryName = resolveLocalized(product.category.name)
const productName = resolveLocalized(product.name) || product.scent.name
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const firstPrice = product.variants[0]?.price ?? product.price
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">
<article className="group flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-brutal transition duration-300 hover:-translate-y-1 hover:shadow-brutal-hover">
<div className="relative aspect-[4/3] overflow-hidden bg-pop-bg">
<WishlistButton product={product} />
<Link href={`/products/${product.id}`} className="block h-full w-full">
<img
src={product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || '')}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={product.scent.name}
src={imageUrl}
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
alt={productName}
/>
{/* 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">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''}
</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">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''}
</p>
</div>
<span className="text-cyan-glow font-bold text-xs md:text-lg">
{formatPrice(product.variants[0]?.price)}
{categoryName && (
<span className="absolute left-4 top-4 rounded-full bg-white/90 px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-pop-black shadow-sm">
{categoryName}
</span>
)}
</Link>
</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 className="flex flex-1 flex-col gap-4 p-5">
<div className="space-y-1">
<h3 className="line-clamp-2 text-lg font-semibold leading-snug text-pop-black">
<Link href={`/products/${product.id}`} className="transition hover:text-pop-pink">
{productName}
</Link>
</h3>
{product.scent.name && (
<p className="text-xs font-semibold uppercase tracking-widest text-gray-400">
{product.scent.name}
</p>
)}
</div>
{product.variants.length > 0 && (
<div className="flex flex-wrap gap-2">
{product.variants.slice(0, 3).map((variant) => (
<span key={variant.id} className="rounded-full bg-pop-bg px-3 py-1 text-xs font-semibold text-gray-600">
{variant.size}
</span>
))}
</div>
)}
<div className="flex-1" />
<div className="mt-auto flex items-center justify-between gap-4">
<span className="text-lg font-bold text-pop-pink">{formatPrice(firstPrice)}</span>
<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"
className="rounded-full bg-pop-black px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink"
>
Discover
View
</Link>
</div>
</div>
</article>
)
}
@@ -0,0 +1,613 @@
"use client";
import Link from 'next/link'
import { useEffect, useState } from 'react'
import {
Award,
Check,
Edit3,
ExternalLink,
Loader2,
LogOut,
Mail,
MapPin,
Package,
Phone,
Plus,
Settings,
Trash2,
User,
type LucideIcon,
} from 'lucide-react'
import { formatPrice, type CustomerAddress, type Order } from '@homaroma/types'
import { API_BASE } from '../lib/api'
import { useAuthStore, type Customer } from '../store/authStore'
type ProfileTab = 'orders' | 'addresses' | 'settings'
type ProfileTabItem = {
tab: ProfileTab
label: string
icon: LucideIcon
count: number | null
}
type ProfileCustomer = Customer & {
points?: number
addresses?: CustomerAddress[]
}
type AddressFormState = {
recipientName: string
phone: string
addressLine: string
subdistrict: string
district: string
province: string
zipcode: string
isDefault: boolean
}
const emptyAddressForm: AddressFormState = {
recipientName: '',
phone: '',
addressLine: '',
subdistrict: '',
district: '',
province: '',
zipcode: '',
isDefault: false,
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
PENDING_PAYMENT: 'รอการชำระเงิน',
PAID: 'ชำระเงินสำเร็จ',
PROCESSING: 'กำลังจัดเตรียมสินค้า',
SHIPPED: 'จัดส่งแล้ว',
DELIVERED: 'จัดส่งสำเร็จ',
CANCELLED: 'ยกเลิกแล้ว',
}
return labels[status] ?? status.replaceAll('_', ' ')
}
function statusClass(status: string) {
if (status === 'PENDING_PAYMENT') return 'border-amber-100 bg-amber-50 text-amber-700'
if (status === 'PAID' || status === 'PROCESSING') return 'border-emerald-100 bg-emerald-50 text-emerald-700'
if (status === 'SHIPPED' || status === 'DELIVERED') return 'border-sky-100 bg-sky-50 text-sky-700'
if (status === 'CANCELLED') return 'border-rose-100 bg-rose-50 text-rose-700'
return 'border-gray-100 bg-gray-50 text-gray-700'
}
function formatAddress(address: CustomerAddress) {
return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}`
}
function toAddressForm(address: CustomerAddress): AddressFormState {
return {
recipientName: address.recipientName,
phone: address.phone,
addressLine: address.addressLine,
subdistrict: address.subdistrict,
district: address.district,
province: address.province,
zipcode: address.zipcode,
isDefault: address.isDefault,
}
}
async function readJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, init)
const data = await response.json()
if (!response.ok) {
const message = data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
? data.error
: `${path} returned ${response.status}`
throw new Error(message)
}
return data as T
}
export default function ProfileClient() {
const { token, customer, setAuth, logout } = useAuthStore()
const [activeTab, setActiveTab] = useState<ProfileTab>('orders')
const [dbCustomer, setDbCustomer] = useState<ProfileCustomer | null>(null)
const [orders, setOrders] = useState<Order[]>([])
const [addresses, setAddresses] = useState<CustomerAddress[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showAddressForm, setShowAddressForm] = useState(false)
const [editingAddress, setEditingAddress] = useState<CustomerAddress | null>(null)
const [addressForm, setAddressForm] = useState<AddressFormState>(emptyAddressForm)
const [submittingAddress, setSubmittingAddress] = useState(false)
const [settingsForm, setSettingsForm] = useState({ email: '', phone: '' })
const [submittingSettings, setSubmittingSettings] = useState(false)
const [settingsSuccess, setSettingsSuccess] = useState(false)
const loadProfileData = async (currentCustomer: Customer) => {
setLoading(true)
setError(null)
try {
const [profileData, orderData, addressData] = await Promise.all([
readJson<ProfileCustomer>(`/profile/${currentCustomer.id}`),
readJson<Order[]>(`/orders/customer/${currentCustomer.id}`),
readJson<CustomerAddress[]>(`/profile/${currentCustomer.id}/addresses`),
])
setDbCustomer(profileData)
setOrders(Array.isArray(orderData) ? orderData : [])
setAddresses(Array.isArray(addressData) ? addressData : [])
setSettingsForm({
email: profileData.email ?? '',
phone: profileData.phone ?? '',
})
} catch (err) {
setError(err instanceof Error ? err.message : 'ไม่สามารถโหลดข้อมูลสมาชิกได้')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!customer) return
queueMicrotask(() => {
void loadProfileData(customer)
})
}, [customer])
const resetAddressForm = () => {
setAddressForm(emptyAddressForm)
setEditingAddress(null)
setShowAddressForm(false)
}
const updateAddressForm = (key: keyof AddressFormState, value: string | boolean) => {
setAddressForm((current) => ({ ...current, [key]: value }))
}
const handleStartAddress = (address?: CustomerAddress) => {
if (address) {
setEditingAddress(address)
setAddressForm(toAddressForm(address))
} else {
setEditingAddress(null)
setAddressForm(emptyAddressForm)
}
setShowAddressForm(true)
}
const reloadAddresses = async (customerId: number) => {
const data = await readJson<CustomerAddress[]>(`/profile/${customerId}/addresses`)
setAddresses(Array.isArray(data) ? data : [])
}
const handleAddressSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!customer) return
setSubmittingAddress(true)
setError(null)
try {
await readJson<CustomerAddress>(
editingAddress
? `/profile/${customer.id}/addresses/${editingAddress.id}`
: `/profile/${customer.id}/addresses`,
{
method: editingAddress ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressForm),
},
)
resetAddressForm()
await reloadAddresses(customer.id)
} catch (err) {
setError(err instanceof Error ? err.message : 'ไม่สามารถบันทึกที่อยู่ได้')
} finally {
setSubmittingAddress(false)
}
}
const handleDeleteAddress = async (addressId: number) => {
if (!customer) return
if (!window.confirm('ยืนยันที่จะลบที่อยู่จัดส่งนี้?')) return
setError(null)
try {
await readJson<{ success: boolean }>(`/profile/${customer.id}/addresses/${addressId}`, { method: 'DELETE' })
await reloadAddresses(customer.id)
} catch (err) {
setError(err instanceof Error ? err.message : 'ไม่สามารถลบที่อยู่ได้')
}
}
const handleSetDefaultAddress = async (addressId: number) => {
if (!customer) return
setError(null)
try {
await readJson<CustomerAddress>(`/profile/${customer.id}/addresses/${addressId}/default`, { method: 'PATCH' })
await reloadAddresses(customer.id)
} catch (err) {
setError(err instanceof Error ? err.message : 'ไม่สามารถตั้งค่าที่อยู่เริ่มต้นได้')
}
}
const handleSettingsSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!customer) return
setSubmittingSettings(true)
setSettingsSuccess(false)
setError(null)
try {
const updatedCustomer = await readJson<ProfileCustomer>(`/profile/${customer.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: settingsForm.email || null,
phone: settingsForm.phone || null,
}),
})
setDbCustomer(updatedCustomer)
if (token) setAuth(token, updatedCustomer)
setSettingsSuccess(true)
setTimeout(() => setSettingsSuccess(false), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'ไม่สามารถบันทึกข้อมูลติดต่อได้')
} finally {
setSubmittingSettings(false)
}
}
const handleLineLogin = () => {
const maybeWindow = window as Window & {
liff?: {
login: (options?: { redirectUri?: string }) => void
}
}
if (maybeWindow.liff) {
maybeWindow.liff.login({ redirectUri: window.location.href })
return
}
setError('ยังไม่พบ LINE LIFF ในหน้า website นี้ กรุณาเปิดผ่าน LINE OA หรือรอให้ทีมตั้งค่า LIFF สำหรับ website')
}
if (!customer) {
return (
<section className="mx-auto max-w-md rounded-3xl bg-white p-10 text-center shadow-brutal">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-pop-bg text-pop-pink">
<User className="h-10 w-10" />
</div>
<h1 className="text-3xl font-semibold text-pop-black">เข้าสู่ระบบสมาชิก</h1>
<p className="mt-3 text-sm leading-relaxed text-gray-600">
ล็อกอินเพื่อตรวจสอบประวัติคำสั่งซื้อ สะสมคะแนนสมาชิก และบันทึกสมุดที่อยู่จัดส่งสินค้า
</p>
{error && (
<p className="mt-5 rounded-2xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-700">
{error}
</p>
)}
<button
type="button"
onClick={handleLineLogin}
className="mt-8 flex w-full items-center justify-center gap-3 rounded-full bg-[#00B900] px-6 py-4 text-sm font-bold uppercase tracking-[0.16em] text-white transition hover:bg-[#009900]"
>
<Award className="h-5 w-5" />
เข้าสู่ระบบผ่าน LINE
</button>
<Link href="/shop" className="mt-5 inline-flex text-sm font-semibold text-pop-pink">
กลับไปเลือกซื้อสินค้า
</Link>
</section>
)
}
const displayCustomer = dbCustomer ?? customer
const tabs: ProfileTabItem[] = [
{ tab: 'orders', label: 'ออเดอร์', icon: Package, count: orders.length },
{ tab: 'addresses', label: 'ที่อยู่', icon: MapPin, count: addresses.length },
{ tab: 'settings', label: 'ตั้งค่า', icon: Settings, count: null },
]
return (
<section className="space-y-8">
{error && (
<div className="rounded-3xl border border-rose-100 bg-rose-50 p-5 text-sm font-medium text-rose-600">
{error}
</div>
)}
<div className="relative overflow-hidden rounded-3xl bg-white p-6 shadow-brutal">
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pop-pink via-pop-yellow to-pop-red" />
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-4">
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-full border border-pop-pink/20 bg-pop-bg">
<img
src={displayCustomer.pictureUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(displayCustomer.displayName || 'HomAroma')}`}
alt={displayCustomer.displayName || 'LINE Profile'}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0">
<h1 className="truncate text-2xl font-semibold text-pop-black">{displayCustomer.displayName || 'HomAroma Member'}</h1>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-gray-500">
{displayCustomer.phone && <span className="inline-flex items-center gap-1"><Phone className="h-3 w-3" />{displayCustomer.phone}</span>}
{displayCustomer.email && <span className="inline-flex items-center gap-1"><Mail className="h-3 w-3" />{displayCustomer.email}</span>}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-2xl bg-pop-bg px-4 py-3">
<Award className="h-6 w-6 text-pop-pink" />
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-gray-500">คะแนนสะสม</p>
<p className="text-xl font-black text-pop-black">{displayCustomer.points ?? 0} คะแนน</p>
</div>
</div>
</div>
</div>
<div className="flex gap-2 overflow-x-auto border-b border-gray-200">
{tabs.map(({ tab, label, icon: Icon, count }) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`relative flex shrink-0 items-center gap-2 px-3 pb-4 text-sm font-bold transition ${
activeTab === tab ? 'text-pop-pink' : 'text-gray-500 hover:text-pop-black'
}`}
>
<Icon className="h-4 w-4" />
{label}
{typeof count === 'number' && (
<span className="rounded-full bg-pop-bg px-2 py-0.5 text-[10px]">{count}</span>
)}
{activeTab === tab && <span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-pop-pink" />}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center rounded-3xl bg-white py-24 shadow-brutal">
<Loader2 className="h-8 w-8 animate-spin text-pop-pink" />
</div>
) : (
<>
{activeTab === 'orders' && (
<div className="space-y-5">
{orders.length === 0 ? (
<EmptyPanel icon={Package} title="ไม่มีประวัติการสั่งซื้อ" description="คุณยังไม่ได้ทำการสั่งซื้อสินค้าใด ๆ กับ HomAroma" actionHref="/shop" actionText="ไปหน้าช้อปปิ้ง" />
) : (
orders.map((order) => (
<article key={order.id} className="rounded-3xl bg-white p-6 shadow-brutal">
<div className="flex flex-col gap-3 border-b border-gray-100 pb-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold text-gray-500">เลขออเดอร์ #{order.id.toString().padStart(4, '0')}</p>
<p className="mt-1 text-[11px] text-gray-400">
สั่งเมื่อ {new Date(order.createdAt).toLocaleDateString('th-TH', { year: 'numeric', month: 'long', day: 'numeric' })}
</p>
</div>
<div className="flex items-center gap-3">
<span className={`rounded-full border px-3 py-1 text-xs font-bold ${statusClass(order.status)}`}>
{statusLabel(order.status)}
</span>
<Link href={`/order/${order.id}`} className="rounded-full border border-gray-200 p-2 text-gray-500 transition hover:text-pop-pink">
<ExternalLink className="h-4 w-4" />
</Link>
</div>
</div>
<div className="mt-4 space-y-3">
{order.items.map((item) => (
<div key={item.id} className="flex items-center gap-3 text-sm">
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-xl bg-pop-bg">
<img src={item.productImage || '/images/oil_ozone.png'} alt={item.productName} className="h-full w-full object-cover" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-pop-black">{item.productName}</p>
<p className="text-xs text-gray-500">{item.variantSize ? `${item.variantSize} · ` : ''}x {item.quantity}</p>
</div>
<p className="font-bold text-pop-pink">{formatPrice(item.priceAtPurchase * item.quantity)}</p>
</div>
))}
</div>
<div className="mt-5 flex flex-col gap-3 border-t border-gray-100 pt-4 text-sm md:flex-row md:items-center md:justify-between">
<p className="text-gray-500">
{order.trackingNo ? <>เลขพัสดุ: <span className="font-mono font-bold text-pop-black">{order.trackingNo}</span></> : 'ยังไม่มีเลขพัสดุ'}
</p>
<p className="font-bold text-pop-black">ยอดรวมสุทธิ: <span className="text-pop-pink">{formatPrice(order.totalAmount)}</span></p>
</div>
</article>
))
)}
</div>
)}
{activeTab === 'addresses' && (
<div className="space-y-6">
<div className="flex flex-col gap-3 border-b border-gray-200 pb-4 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-xl font-semibold text-pop-black">
<MapPin className="h-5 w-5 text-pop-pink" />
สมุดที่อยู่จัดส่ง
</h2>
{!showAddressForm && (
<button type="button" onClick={() => handleStartAddress()} className="inline-flex items-center justify-center gap-2 rounded-full bg-pop-black px-5 py-3 text-xs font-bold uppercase tracking-[0.16em] text-white transition hover:bg-pop-pink">
<Plus className="h-4 w-4" />
เพิ่มที่อยู่ใหม่
</button>
)}
</div>
{showAddressForm && (
<form onSubmit={handleAddressSubmit} className="space-y-5 rounded-3xl bg-white p-6 shadow-brutal">
<h3 className="text-sm font-bold uppercase tracking-[0.2em] text-pop-pink">
{editingAddress ? 'แก้ไขที่อยู่จัดส่ง' : 'เพิ่มที่อยู่จัดส่งใหม่'}
</h3>
<div className="grid gap-4 md:grid-cols-2">
<TextInput label="ชื่อผู้รับ" value={addressForm.recipientName} onChange={(value) => updateAddressForm('recipientName', value)} required />
<TextInput label="เบอร์โทรศัพท์ผู้รับ" value={addressForm.phone} onChange={(value) => updateAddressForm('phone', value)} required />
</div>
<TextInput label="ที่อยู่" value={addressForm.addressLine} onChange={(value) => updateAddressForm('addressLine', value)} required />
<div className="grid gap-4 md:grid-cols-4">
<TextInput label="ตำบล/แขวง" value={addressForm.subdistrict} onChange={(value) => updateAddressForm('subdistrict', value)} required />
<TextInput label="อำเภอ/เขต" value={addressForm.district} onChange={(value) => updateAddressForm('district', value)} required />
<TextInput label="จังหวัด" value={addressForm.province} onChange={(value) => updateAddressForm('province', value)} required />
<TextInput label="รหัสไปรษณีย์" value={addressForm.zipcode} onChange={(value) => updateAddressForm('zipcode', value)} required />
</div>
<label className="flex items-center gap-3 text-sm font-semibold text-gray-600">
<input
type="checkbox"
checked={addressForm.isDefault}
onChange={(event) => updateAddressForm('isDefault', event.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-pop-pink"
/>
ตั้งเป็นที่อยู่จัดส่งเริ่มต้น
</label>
<div className="flex justify-end gap-3 border-t border-gray-100 pt-4">
<button type="button" onClick={resetAddressForm} className="rounded-full border border-gray-200 px-5 py-2 text-sm font-semibold text-gray-600">
ยกเลิก
</button>
<button type="submit" disabled={submittingAddress} className="inline-flex items-center gap-2 rounded-full bg-pop-pink px-5 py-2 text-sm font-semibold text-white disabled:opacity-60">
{submittingAddress && <Loader2 className="h-4 w-4 animate-spin" />}
บันทึกที่อยู่
</button>
</div>
</form>
)}
{addresses.length === 0 ? (
<EmptyPanel icon={MapPin} title="ไม่มีข้อมูลที่อยู่จัดส่ง" description="เพิ่มที่อยู่เพื่อใช้ในขั้นตอน checkout รอบถัดไป" />
) : (
<div className="grid gap-4 md:grid-cols-2">
{addresses.map((address) => (
<article key={address.id} className={`rounded-3xl bg-white p-5 shadow-brutal ${address.isDefault ? 'ring-2 ring-pop-pink/30' : ''}`}>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-semibold text-pop-black">{address.recipientName}</p>
{address.isDefault && <span className="rounded-full bg-pop-bg px-2 py-0.5 text-[10px] font-bold text-pop-pink">เริ่มต้น</span>}
</div>
<p className="mt-1 text-xs font-semibold text-gray-500">{address.phone}</p>
</div>
<div className="flex gap-1">
<button type="button" onClick={() => handleStartAddress(address)} className="rounded-full border border-gray-200 p-2 text-gray-500 hover:text-pop-pink">
<Edit3 className="h-4 w-4" />
</button>
<button type="button" onClick={() => void handleDeleteAddress(address.id)} className="rounded-full border border-rose-100 p-2 text-rose-500">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<p className="mt-4 text-sm leading-relaxed text-gray-600">{formatAddress(address)}</p>
{!address.isDefault && (
<button type="button" onClick={() => void handleSetDefaultAddress(address.id)} className="mt-4 w-full rounded-full border border-gray-200 px-4 py-2 text-xs font-bold uppercase tracking-[0.16em] text-gray-600 hover:text-pop-pink">
ตั้งเป็นที่อยู่เริ่มต้น
</button>
)}
</article>
))}
</div>
)}
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-6">
<h2 className="flex items-center gap-2 border-b border-gray-200 pb-4 text-xl font-semibold text-pop-black">
<Settings className="h-5 w-5 text-pop-pink" />
ข้อมูลติดต่อสมาชิก
</h2>
<form onSubmit={handleSettingsSubmit} className="max-w-xl space-y-5 rounded-3xl bg-white p-6 shadow-brutal">
{settingsSuccess && (
<div className="flex items-center justify-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 p-4 text-sm font-bold text-emerald-700">
<Check className="h-4 w-4" />
บันทึกข้อมูลติดต่อสำเร็จแล้ว
</div>
)}
<TextInput label="อีเมล" value={settingsForm.email} onChange={(email) => setSettingsForm((current) => ({ ...current, email }))} type="email" />
<TextInput label="เบอร์โทรศัพท์" value={settingsForm.phone} onChange={(phone) => setSettingsForm((current) => ({ ...current, phone }))} type="tel" />
<button type="submit" disabled={submittingSettings} className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-pop-pink px-6 py-3 text-sm font-bold uppercase tracking-[0.16em] text-white disabled:opacity-60">
{submittingSettings ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
บันทึกข้อมูลติดต่อ
</button>
</form>
</div>
)}
</>
)}
<button type="button" onClick={logout} className="inline-flex items-center gap-2 text-sm font-semibold text-gray-500 transition hover:text-rose-500">
<LogOut className="h-4 w-4" />
ออกจากระบบบนอุปกรณ์นี้
</button>
</section>
)
}
function TextInput({
label,
value,
onChange,
required = false,
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
required?: boolean
type?: string
}) {
return (
<label className="space-y-1.5">
<span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-gray-500">{label}</span>
<input
type={type}
required={required}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-sm text-pop-black outline-none transition focus:border-pop-pink focus:ring-2 focus:ring-pop-pink/20"
/>
</label>
)
}
function EmptyPanel({
icon: Icon,
title,
description,
actionHref,
actionText,
}: {
icon: typeof Package
title: string
description: string
actionHref?: string
actionText?: string
}) {
return (
<div className="rounded-3xl bg-white px-6 py-16 text-center shadow-brutal">
<Icon className="mx-auto mb-5 h-12 w-12 text-gray-300" />
<h3 className="text-xl font-semibold text-pop-black">{title}</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-gray-500">{description}</p>
{actionHref && actionText && (
<Link href={actionHref} className="mt-6 inline-flex rounded-full bg-pop-pink px-6 py-3 text-sm font-bold text-white">
{actionText}
</Link>
)}
</div>
)
}
@@ -0,0 +1,21 @@
"use client";
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
type ShopCouponBridgeProps = {
coupon?: string
}
export default function ShopCouponBridge({ coupon }: ShopCouponBridgeProps) {
const router = useRouter()
useEffect(() => {
if (!coupon) return
localStorage.setItem('homaroma_active_coupon', coupon)
router.replace('/shop', { scroll: false })
}, [coupon, router])
return null
}
@@ -0,0 +1,183 @@
"use client";
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { ChevronRight, Loader2, Package, Search } from 'lucide-react'
import { API_BASE } from '../lib/api'
type TrackResponse =
| { success: true; orderId: number }
| { error?: string }
function parseStoredOrders(value: string | null) {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed)
? parsed.map(Number).filter((id) => Number.isFinite(id) && id > 0)
: []
} catch {
return []
}
}
function orderLabel(id: number) {
return `#${id.toString().padStart(4, '0')}`
}
export default function TrackOrderClient() {
const router = useRouter()
const [orderId, setOrderId] = useState('')
const [phone, setPhone] = useState('')
const [localOrders, setLocalOrders] = useState<number[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const storedOrders = parseStoredOrders(localStorage.getItem('homaroma_local_orders'))
queueMicrotask(() => setLocalOrders(storedOrders))
}, [])
const saveLocalOrder = (id: number) => {
setLocalOrders((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 12)
localStorage.setItem('homaroma_local_orders', JSON.stringify(next))
return next
})
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setLoading(true)
setError(null)
const normalizedOrderId = Number.parseInt(orderId.replace(/[^0-9]/g, ''), 10)
if (!Number.isFinite(normalizedOrderId) || normalizedOrderId <= 0) {
setError('กรุณากรอกหมายเลขคำสั่งซื้อให้ถูกต้อง')
setLoading(false)
return
}
try {
const response = await fetch(`${API_BASE}/orders/track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orderId: normalizedOrderId,
phone,
}),
})
const data = await response.json() as TrackResponse
if (response.ok && 'success' in data && data.success) {
saveLocalOrder(data.orderId)
router.push(`/order/${data.orderId}`)
return
}
setError('error' in data && data.error ? data.error : 'ไม่สามารถยืนยันข้อมูลคำสั่งซื้อได้')
} catch (err) {
setError(err instanceof Error ? err.message : 'เกิดข้อผิดพลาดในการเชื่อมต่อ กรุณาลองใหม่อีกครั้ง')
} finally {
setLoading(false)
}
}
return (
<div className="grid gap-10 md:grid-cols-2">
<section className="self-start rounded-3xl bg-white p-8 shadow-brutal">
<div className="mb-6 flex items-center gap-3 border-b border-gray-100 pb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pop-bg text-pop-pink">
<Search className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-bold uppercase tracking-[0.24em] text-pop-pink">Manual Search</p>
<h2 className="text-xl font-semibold text-pop-black">ค้นหาคำสั่งซื้อ</h2>
</div>
</div>
<form className="space-y-5" onSubmit={handleSubmit}>
{error && (
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-sm font-medium text-rose-600">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="orderId" className="text-xs font-bold uppercase tracking-[0.22em] text-gray-500">
Order Number
</label>
<input
id="orderId"
required
value={orderId}
onChange={(event) => setOrderId(event.target.value)}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-pop-black outline-none transition focus:border-pop-pink focus:ring-2 focus:ring-pop-pink/20"
placeholder="เช่น 1024"
type="text"
/>
</div>
<div className="space-y-2">
<label htmlFor="phone" className="text-xs font-bold uppercase tracking-[0.22em] text-gray-500">
Phone Number
</label>
<input
id="phone"
required
value={phone}
onChange={(event) => setPhone(event.target.value)}
className="w-full rounded-2xl border border-gray-200 bg-pop-bg px-4 py-3 text-pop-black outline-none transition focus:border-pop-pink focus:ring-2 focus:ring-pop-pink/20"
placeholder="เบอร์โทรที่ใช้ตอนสั่งซื้อ"
type="tel"
/>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-full bg-pop-black px-6 py-4 text-sm font-bold uppercase tracking-[0.18em] text-white transition hover:bg-pop-pink disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Find Order'}
</button>
</form>
</section>
<section className="space-y-6">
<div className="flex items-center gap-3 border-b border-gray-200 pb-4">
<Package className="h-5 w-5 text-pop-pink" />
<h2 className="text-xl font-semibold text-pop-black">Recent Device Orders</h2>
</div>
{localOrders.length === 0 ? (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white p-10 text-center shadow-brutal">
<p className="text-sm font-semibold text-gray-600">ยังไม่มีประวัติคำสั่งซื้อบนอุปกรณ์นี้</p>
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400">ใช้ฟอร์มค้นหาด้านซ้ายเพื่อบันทึกออเดอร์</p>
</div>
) : (
<div className="space-y-3">
{localOrders.map((id) => (
<Link
key={id}
href={`/order/${id}`}
className="group flex items-center justify-between rounded-2xl bg-white p-5 shadow-brutal transition hover:-translate-y-0.5 hover:shadow-brutal-hover"
>
<div>
<p className="text-xs font-bold uppercase tracking-[0.22em] text-gray-400">Order</p>
<p className="mt-1 font-mono text-2xl font-bold text-pop-black">{orderLabel(id)}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pop-bg text-pop-black transition group-hover:bg-pop-pink group-hover:text-white">
<ChevronRight className="h-5 w-5" />
</div>
</Link>
))}
</div>
)}
</section>
</div>
)
}
@@ -0,0 +1,34 @@
"use client";
import { Heart } from 'lucide-react'
import type { Product } from '@homaroma/types'
import { useWishlistStore } from '../store/wishlistStore'
type WishlistButtonProps = {
product: Product
}
export default function WishlistButton({ product }: WishlistButtonProps) {
const isInWishlist = useWishlistStore((state) => state.isInWishlist(product.id))
const toggleItem = useWishlistStore((state) => state.toggleItem)
return (
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleItem(product)
}}
aria-label={isInWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
className={`absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full border bg-white/90 shadow-sm backdrop-blur transition hover:scale-105 ${
isInWishlist
? 'border-rose-200 text-rose-500'
: 'border-gray-200 text-pop-black hover:border-rose-200 hover:text-rose-500'
}`}
>
<Heart className={`h-5 w-5 ${isInWishlist ? 'fill-current' : ''}`} />
</button>
)
}
@@ -0,0 +1,39 @@
"use client";
import Link from 'next/link'
import { Heart } from 'lucide-react'
import ProductCard from './ProductCard'
import { useWishlistStore } from '../store/wishlistStore'
export default function WishlistClient() {
const items = useWishlistStore((state) => state.items)
if (items.length === 0) {
return (
<section className="mx-auto max-w-2xl rounded-3xl bg-white px-6 py-20 text-center shadow-brutal">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-rose-50 text-rose-300">
<Heart className="h-10 w-10" />
</div>
<h2 className="text-2xl font-semibold text-pop-black">Your wishlist is empty</h2>
<p className="mx-auto mt-3 max-w-md text-gray-500">
Explore our products and tap the heart icon to save your favorites.
</p>
<Link
href="/shop"
className="mt-8 inline-flex rounded-full bg-pop-pink px-8 py-3 text-sm font-bold uppercase tracking-[0.18em] text-white transition hover:bg-pop-red"
>
Browse Shop
</Link>
</section>
)
}
return (
<section className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{items.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</section>
)
}
@@ -0,0 +1,567 @@
import Link from 'next/link'
import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization'
type DynamicPageRendererProps = {
sections: PageSection[]
categories: Category[]
lang?: string
}
function categoryName(category: Category, lang: string) {
if (typeof category.name === 'string') return category.name
return category.name[lang] ?? category.name.th ?? category.name.en ?? Object.values(category.name)[0] ?? ''
}
function getProducts(payload: Record<string, unknown>): Product[] {
return Array.isArray(payload.products) ? payload.products as Product[] : []
}
function getCategoriesFromPayload(payload: Record<string, unknown>): Category[] {
return Array.isArray(payload.categories) ? payload.categories as Category[] : []
}
function localizedValue(value: unknown, lang: string) {
return resolveLocalized(value as never, lang)
}
function optionalText(value: unknown, lang: string) {
if (Array.isArray(value)) return value.filter(Boolean).join(', ')
return localizedValue(value, lang)
}
function renderAccentTitle(text: string, accentClassName = 'text-pop-pink italic') {
if (!text.includes('[') || !text.includes(']')) return text
return text.split('[').map((part, index) => {
if (!part.includes(']')) return <span key={index}>{part}</span>
const [accent, rest] = part.split(']')
return (
<span key={index}>
<span className={accentClassName}>{accent}</span>
{rest}
</span>
)
})
}
function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const slides = Array.isArray(payload.slides) ? payload.slides : []
if (!slides.length) return null
return (
<section className="bg-pop-black px-4 py-3 text-center text-sm font-semibold tracking-wide text-white">
{slides.map((slide, index) => (
<span key={index} className={index === 0 ? '' : 'hidden md:inline'}>
{index > 0 && <span className="mx-4 text-white/30">/</span>}
{localizedFromRecord({ slide }, 'slide', lang)}
</span>
))}
</section>
)
}
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const tagline = localizedFromRecord(payload, 'tagline', lang)
const title = localizedFromRecord(payload, 'title', lang)
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
return (
<section className="relative flex min-h-[78vh] w-full items-center justify-center overflow-hidden bg-white">
<img
src={imageUrl}
alt={title || 'HomAroma'}
className="absolute inset-0 h-full w-full scale-[1.02] object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-pop-bg/90 via-pop-bg/55 to-white/10" />
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-24">
<div className="max-w-3xl space-y-6">
{tagline && (
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">
{tagline}
</p>
)}
<h1 className="text-5xl font-semibold leading-tight text-pop-black md:text-7xl">
{title || 'HomAroma'}
</h1>
{description && (
<p className="max-w-2xl text-lg leading-relaxed text-gray-700 md:text-xl">
{description}
</p>
)}
<Link href={buttonLink} className="inline-flex rounded-full bg-pop-pink px-8 py-4 font-semibold text-white shadow-brutal transition hover:bg-pop-red">
{buttonText || 'Explore Collection'}
</Link>
</div>
</div>
</section>
)
}
function CategoriesSection({
payload,
categories,
lang,
}: {
payload: Record<string, unknown>
categories: Category[]
lang: string
}) {
const title = localizedFromRecord(payload, 'title', lang) || 'Our Collections'
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
const categoryIds = Array.isArray(payload.categoryIds) ? payload.categoryIds.map(Number) : []
const payloadCategories = getCategoriesFromPayload(payload)
const sourceCategories = payloadCategories.length ? payloadCategories : categories
const displayed = categoryIds.length ? sourceCategories.filter((category) => categoryIds.includes(category.id)) : sourceCategories
return (
<section id="categories" className="bg-white px-6 py-24">
<div className="mx-auto max-w-7xl space-y-14">
<div className="mx-auto max-w-2xl space-y-4 text-center">
<h2 className="text-4xl font-semibold text-pop-black md:text-5xl">{title}</h2>
{subtitle && <p className="text-gray-500">{subtitle}</p>}
</div>
<div className="grid grid-cols-2 gap-5 md:grid-cols-5">
{displayed.map((category) => {
const name = categoryName(category, lang)
return (
<Link
key={category.id}
href={`/category/${category.id}`}
className="group relative h-72 overflow-hidden rounded-2xl shadow-brutal transition hover:-translate-y-1 hover:shadow-brutal-hover"
>
<img
src={category.imageUrl || getCategoryImage(name)}
alt={name}
className="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/65 to-transparent" />
<div className="absolute bottom-5 left-5 right-5 text-white">
<span className="block text-lg font-semibold">{name}</span>
{category._count && (
<span className="text-sm text-white/80">{category._count.products} Products</span>
)}
</div>
</Link>
)
})}
</div>
</div>
</section>
)
}
function ProductsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Featured Picks'
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
const products = getProducts(payload)
return (
<section className="bg-pop-bg px-6 py-24">
<div className="mx-auto max-w-7xl space-y-10">
<div className="flex flex-col gap-4 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-4xl font-semibold text-pop-black md:text-5xl">{title}</h2>
{subtitle && <p className="mt-2 text-gray-500">{subtitle}</p>}
</div>
<Link href="/shop" className="font-semibold text-pop-pink transition hover:text-pop-red">
View All Collection &rarr;
</Link>
</div>
{products.length ? (
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{products.map((product) => <ProductCard key={product.id} product={product} />)}
</div>
) : (
<p className="rounded-2xl bg-white p-8 text-center text-gray-500 shadow-brutal">
No products found in this section.
</p>
)}
</div>
</section>
)
}
function BannerSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
const content = (
<div className="relative mx-auto min-h-[360px] max-w-7xl overflow-hidden rounded-3xl shadow-brutal">
<img src={imageUrl} alt={title || 'Banner'} className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-r from-pop-black/75 via-pop-black/35 to-transparent" />
<div className="relative z-10 max-w-xl space-y-4 p-8 text-white md:p-14">
{title && <h2 className="text-3xl font-semibold md:text-5xl">{title}</h2>}
{description && <p className="text-white/90">{description}</p>}
{buttonText && <span className="inline-flex rounded-full bg-white px-6 py-3 font-semibold text-pop-black">{buttonText}</span>}
</div>
</div>
)
return <section className="bg-white px-6 py-16">{linkUrl ? <Link href={linkUrl}>{content}</Link> : content}</section>
}
function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const content = localizedFromRecord(payload, 'content', lang)
const alignClass = payload.textAlign === 'center'
? 'text-center'
: payload.textAlign === 'right'
? 'text-right'
: 'text-left'
return (
<section className="bg-white px-6 py-16">
<div className={`mx-auto max-w-4xl space-y-6 ${alignClass}`}>
{title && <h2 className="text-3xl font-semibold text-pop-black md:text-4xl">{title}</h2>}
{content && <div className="leading-relaxed text-gray-600" dangerouslySetInnerHTML={{ __html: content }} />}
</div>
</section>
)
}
function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Stay in touch'
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang) || 'Subscribe'
const placeholder = typeof payload.placeholderText === 'string' ? payload.placeholderText : 'you@example.com'
return (
<section className="bg-pop-black px-6 py-20 text-white">
<div className="mx-auto grid max-w-5xl gap-8 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-3">
<h2 className="text-3xl font-semibold text-white md:text-4xl">{title}</h2>
{description && <p className="max-w-2xl text-white/75">{description}</p>}
</div>
<form className="flex flex-col gap-3 sm:flex-row">
<input className="min-w-0 rounded-full px-5 py-3 text-pop-black" placeholder={placeholder} type="email" />
<button className="rounded-full bg-pop-pink px-6 py-3 font-semibold text-white" type="button">
{buttonText}
</button>
</form>
</div>
</section>
)
}
function EditorialTileSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const description = localizedFromRecord(payload, 'description', lang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : ''
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const imagePosition = payload.imagePosition === 'right' ? 'right' : 'left'
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
return (
<section className="overflow-hidden bg-white px-6 py-20" style={{ backgroundColor, color: textColor }}>
<div className="mx-auto grid max-w-7xl gap-12 md:grid-cols-2 md:items-center">
{imageUrl && (
<div className={`group aspect-square overflow-hidden rounded-3xl shadow-brutal ${imagePosition === 'left' ? 'md:order-1' : 'md:order-2'}`}>
<img
src={imageUrl}
alt={title || 'Editorial'}
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
</div>
)}
<div className={`space-y-6 ${imagePosition === 'left' ? 'md:order-2' : 'md:order-1'}`}>
{title && (
<h2 className="text-4xl font-semibold leading-tight text-pop-black md:text-6xl" style={{ color: textColor }}>
{renderAccentTitle(title)}
</h2>
)}
<div className="h-px w-14 bg-pop-pink" />
{description && <p className="whitespace-pre-line text-base leading-relaxed text-gray-600 md:text-lg" style={{ color: textColor }}>{description}</p>}
{buttonText && buttonLink && (
<Link href={buttonLink} className="inline-flex rounded-full border border-current px-8 py-3 text-xs font-bold uppercase tracking-[0.22em] transition hover:bg-pop-pink hover:text-white">
{buttonText}
</Link>
)}
</div>
</div>
</section>
)
}
function EditorialCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang)
const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : []
if (!slides.length) return null
return (
<section className="bg-pop-black px-6 py-20 text-white">
<div className="mx-auto max-w-7xl space-y-12">
{title && (
<div className="mx-auto max-w-3xl space-y-4 text-center">
<h2 className="text-4xl font-semibold text-white md:text-5xl">{renderAccentTitle(title, 'text-pop-yellow italic')}</h2>
<div className="mx-auto h-px w-16 bg-pop-yellow" />
</div>
)}
<div className="grid gap-6 md:grid-cols-3">
{slides.map((slide, index) => {
const slideTitle = localizedFromRecord(slide, 'title', lang)
const description = localizedFromRecord(slide, 'description', lang)
const category = localizedFromRecord(slide, 'category', lang)
const buttonText = localizedFromRecord(slide, 'buttonText', lang)
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : ''
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
return (
<article key={index} className="group overflow-hidden rounded-3xl border border-white/10 bg-white/5">
{imageUrl && (
<div className="aspect-[4/3] overflow-hidden">
<img src={imageUrl} alt={slideTitle || 'Editorial slide'} className="h-full w-full object-cover transition duration-700 group-hover:scale-105" />
</div>
)}
<div className="space-y-4 p-6">
{category && <p className="text-[10px] font-bold uppercase tracking-[0.24em] text-pop-yellow">{category}</p>}
{slideTitle && <h3 className="text-2xl font-semibold text-white">{renderAccentTitle(slideTitle, 'text-pop-yellow italic')}</h3>}
{description && <p className="line-clamp-4 text-sm leading-relaxed text-white/70">{description}</p>}
{buttonText && buttonLink && (
<Link href={buttonLink} className="inline-flex text-xs font-bold uppercase tracking-[0.22em] text-pop-yellow transition hover:text-white">
{buttonText} &rarr;
</Link>
)}
</div>
</article>
)
})}
</div>
</div>
</section>
)
}
function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Guide Article'
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
const author = localizedFromRecord(payload, 'author', lang)
const category = localizedFromRecord(payload, 'category', lang)
const imageUrl = typeof payload.imageUrl === 'string'
? payload.imageUrl
: typeof payload.backgroundImageUrl === 'string'
? payload.backgroundImageUrl
: ''
const date = typeof payload.date === 'string'
? payload.date
: typeof payload.publishDate === 'string'
? payload.publishDate
: ''
return (
<section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-pop-black px-6 py-24 text-center text-white md:min-h-[76vh]">
{imageUrl && (
<>
<img src={imageUrl} alt={title} className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-pop-black/55" />
</>
)}
<div className="relative z-10 mx-auto max-w-4xl space-y-6">
{category && <p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-yellow">{category}</p>}
<h1 className="text-4xl font-semibold leading-tight md:text-6xl">{title}</h1>
{subtitle && <p className="mx-auto max-w-2xl text-lg text-white/85 md:text-xl">{subtitle}</p>}
{(author || date) && (
<p className="text-xs font-bold uppercase tracking-[0.22em] text-white/70">
{[author && `By ${author}`, date].filter(Boolean).join(' / ')}
</p>
)}
</div>
</section>
)
}
function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Notes'
const description = localizedFromRecord(payload, 'description', lang)
const notes = [
['Top Notes', optionalText(payload.topNotes, lang) || 'The initial, lighter smells of the fragrance.'],
['Heart Notes', optionalText(payload.heartNotes ?? payload.middleNotes, lang) || 'The core of the fragrance, emerging just after the top notes.'],
['Base Notes', optionalText(payload.baseNotes, lang) || 'The deepest, richest scents that linger the longest.'],
]
return (
<section className="bg-pop-bg px-6 py-20">
<div className="mx-auto max-w-6xl space-y-12 text-center">
<div className="mx-auto max-w-3xl space-y-4">
<h2 className="text-4xl font-semibold text-pop-black md:text-5xl">{title}</h2>
{description && <p className="text-gray-600">{description}</p>}
</div>
<div className="grid gap-6 md:grid-cols-3">
{notes.map(([label, value]) => (
<article key={label} className="rounded-3xl bg-white p-8 text-left shadow-brutal">
<h3 className="mb-4 text-xs font-bold uppercase tracking-[0.24em] text-pop-pink">{label}</h3>
<p className="leading-relaxed text-gray-600">{value}</p>
</article>
))}
</div>
</div>
</section>
)
}
function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const quote = localizedFromRecord(payload, 'quote', lang)
const author = localizedFromRecord(payload, 'author', lang)
const role = localizedFromRecord(payload, 'role', lang)
if (!quote) return null
return (
<section className="bg-white px-6 py-24">
<div className="mx-auto max-w-4xl text-center">
<p className="mb-8 text-7xl leading-none text-pop-pink/25">&quot;</p>
<blockquote className="text-3xl font-semibold italic leading-relaxed text-pop-black md:text-5xl">
{quote}
</blockquote>
{(author || role) && (
<p className="mt-8 text-xs font-bold uppercase tracking-[0.24em] text-gray-500">
{[author, role].filter(Boolean).join(' / ')}
</p>
)}
</div>
</section>
)
}
function AccordionSection({ payload }: { payload: Record<string, unknown> }) {
const categories = Array.isArray(payload.categories) ? payload.categories.map(toRecord) : []
if (!categories.length) return null
return (
<section className="bg-pop-bg px-6 py-20">
<div className="mx-auto max-w-4xl space-y-10">
{categories.map((category, categoryIndex) => {
const questions = Array.isArray(category.questions) ? category.questions.map(toRecord) : []
const name = typeof category.name === 'string' ? category.name : ''
const icon = typeof category.icon === 'string' ? category.icon : ''
return (
<div key={categoryIndex} className="space-y-5">
{name && (
<h2 className="flex items-center gap-3 text-2xl font-semibold text-pop-black">
{icon && <span className="text-pop-pink">{icon}</span>}
{name}
</h2>
)}
<div className="space-y-4">
{questions.map((item, itemIndex) => {
const question = typeof item.q === 'string' ? item.q : ''
const answer = typeof item.a === 'string' ? item.a : ''
if (!question || !answer) return null
return (
<details key={itemIndex} className="group rounded-2xl bg-white p-5 shadow-brutal">
<summary className="cursor-pointer list-none text-lg font-semibold text-pop-black marker:hidden">
{question}
</summary>
<p className="mt-4 whitespace-pre-wrap leading-relaxed text-gray-600">{answer}</p>
</details>
)
})}
</div>
</div>
)
})}
</div>
</section>
)
}
function ContactInfoSection({ payload }: { payload: Record<string, unknown> }) {
const items = [
['ที่อยู่', typeof payload.address === 'string' ? payload.address : '', MapPin],
['อีเมล', typeof payload.email === 'string' ? payload.email : '', Mail],
['Line', typeof payload.line === 'string' ? payload.line : '', MessageCircle],
['เวลาทำการ', typeof payload.hours === 'string' ? payload.hours : '', Clock],
] as const
return (
<section className="bg-white px-6 py-20">
<div className="mx-auto grid max-w-6xl gap-6 md:grid-cols-2 lg:grid-cols-4">
{items.map(([label, value, Icon]) => {
if (!value) return null
return (
<article key={label} className="rounded-3xl bg-pop-bg p-6 text-center shadow-brutal">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white text-pop-pink">
<Icon className="h-6 w-6" />
</div>
<h3 className="mb-2 text-lg font-semibold text-pop-black">{label}</h3>
{label === 'อีเมล' ? (
<a className="break-words text-gray-600 transition hover:text-pop-pink" href={`mailto:${value}`}>
{value}
</a>
) : (
<p className="whitespace-pre-wrap break-words text-gray-600">{value}</p>
)}
</article>
)
})}
</div>
</section>
)
}
export default function DynamicPageRenderer({ sections, categories, lang = 'th' }: DynamicPageRendererProps) {
return (
<div className="flex w-full flex-col">
{sections.map((section) => {
if (!section.isActive) return null
const payload = toRecord(section.payload)
const type = section.type.toUpperCase()
switch (type) {
case 'ANNOUNCEMENT_BAR':
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
case 'HERO':
return <HeroSection key={section.id} payload={payload} lang={lang} />
case 'CATEGORIES':
return <CategoriesSection key={section.id} payload={payload} categories={categories} lang={lang} />
case 'PRODUCTS':
case 'PRODUCT_CAROUSEL':
return <ProductsSection key={section.id} payload={payload} lang={lang} />
case 'BANNER':
return <BannerSection key={section.id} payload={payload} lang={lang} />
case 'TEXT':
return <TextSection key={section.id} payload={payload} lang={lang} />
case 'NEWSLETTER':
return <NewsletterSection key={section.id} payload={payload} lang={lang} />
case 'EDITORIAL_TILE':
return <EditorialTileSection key={section.id} payload={payload} lang={lang} />
case 'EDITORIAL_CAROUSEL':
return <EditorialCarouselSection key={section.id} payload={payload} lang={lang} />
case 'HERO_ARTICLE':
return <HeroArticleSection key={section.id} payload={payload} lang={lang} />
case 'RICH_TEXT':
return <TextSection key={section.id} payload={payload} lang={lang} />
case 'SCENT_NOTES_BREAKDOWN':
return <ScentNotesBreakdownSection key={section.id} payload={payload} lang={lang} />
case 'QUOTE_CALLOUT':
return <QuoteCalloutSection key={section.id} payload={payload} lang={lang} />
case 'ACCORDION':
return <AccordionSection key={section.id} payload={payload} />
case 'CONTACT_INFO':
return <ContactInfoSection key={section.id} payload={payload} />
default:
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
}
return null
}
})}
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import type { PageSection } from '@homaroma/types'
const announcementTypes = new Set(['ANNOUNCEMENT_BAR'])
const heroTypes = new Set(['HERO', 'BANNER'])
function sectionType(section: PageSection) {
return section.type.toUpperCase()
}
export function splitCmsLayoutSections(sections: PageSection[]) {
const activeSections = sections.filter((section) => section.isActive)
return {
announcementSections: activeSections.filter((section) => announcementTypes.has(sectionType(section))),
heroSections: activeSections.filter((section) => heroTypes.has(sectionType(section))),
bottomSections: activeSections.filter((section) => {
const type = sectionType(section)
return !announcementTypes.has(type) && !heroTypes.has(type)
}),
}
}
+123
View File
@@ -0,0 +1,123 @@
import type { Article, Category, Order, Page, Product } from '@homaroma/types'
import { API_BASE } from './api'
const defaultRevalidate = 60
type ProductListResponse = {
data?: Product[]
}
export type Coupon = {
id: number
code: string
type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_SHIPPING' | string
value: number
minPurchase: number
usageLimit: number | null
usedCount: number
expiresAt?: string | null
}
export type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; status?: number }
export async function fetchJson<T>(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise<T | null> {
try {
const res = await fetch(`${API_BASE}${path}`, {
...init,
next: init?.next ?? { revalidate: defaultRevalidate },
})
if (!res.ok) {
console.error(`[website-api] ${path} returned ${res.status}`)
return null
}
return await res.json() as T
} catch (error) {
console.error(`[website-api] Failed to fetch ${path}`, error)
return null
}
}
export async function fetchJsonResult<T>(path: string, init?: RequestInit & { next?: { revalidate?: number } }): Promise<ApiResult<T>> {
try {
const res = await fetch(`${API_BASE}${path}`, {
...init,
next: init?.next ?? { revalidate: defaultRevalidate },
})
if (!res.ok) {
return { ok: false, error: `${path} returned ${res.status}`, status: res.status }
}
return { ok: true, data: await res.json() as T }
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : `Failed to fetch ${path}`,
}
}
}
export async function getCategories(lang = 'th'): Promise<Category[]> {
return await fetchJson<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } }) ?? []
}
export async function getCategoriesResult(lang = 'th'): Promise<ApiResult<Category[]>> {
return await fetchJsonResult<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } })
}
export async function getCmsPage(slug: string, lang = 'th'): Promise<Page | null> {
return await fetchJson<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getCmsPageResult(slug: string, lang = 'th'): Promise<ApiResult<Page>> {
return await fetchJsonResult<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getArticlesResult(): Promise<ApiResult<Article[]>> {
return await fetchJsonResult<Article[]>('/articles?publishedOnly=true', { next: { revalidate: defaultRevalidate } })
}
export async function getActiveCouponsResult(): Promise<ApiResult<Coupon[]>> {
const response = await fetchJsonResult<Coupon[]>('/coupons/active', { next: { revalidate: defaultRevalidate } })
if (!response.ok) return response
return {
ok: true,
data: response.data.filter((coupon) => coupon.usageLimit === null || coupon.usedCount < coupon.usageLimit),
}
}
export async function getOrderResult(id: string | number): Promise<ApiResult<Order>> {
return await fetchJsonResult<Order>(`/orders/${id}`, { next: { revalidate: defaultRevalidate } })
}
export async function getProduct(id: string | number, lang = 'th'): Promise<Product | null> {
return await fetchJson<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getProductResult(id: string | number, lang = 'th'): Promise<ApiResult<Product>> {
return await fetchJsonResult<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getProducts(query = '', lang = 'th'): Promise<Product[]> {
const separator = query ? '&' : ''
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
next: { revalidate: defaultRevalidate },
})
return response?.data ?? []
}
export async function getProductsResult(query = '', lang = 'th'): Promise<ApiResult<Product[]>> {
const separator = query ? '&' : ''
const response = await fetchJsonResult<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
next: { revalidate: defaultRevalidate },
})
if (!response.ok) return response
return { ok: true, data: response.data.data ?? [] }
}
+16
View File
@@ -0,0 +1,16 @@
import type { TranslatableString } from '@homaroma/types'
export function resolveLocalized(value: TranslatableString | null | undefined, lang = 'th'): string {
if (!value) return ''
if (typeof value === 'string') return value
return value[lang] ?? value[lang.slice(0, 2)] ?? value.th ?? value.en ?? Object.values(value)[0] ?? ''
}
export function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
}
export function localizedFromRecord(payload: Record<string, unknown>, key: string, lang = 'th'): string {
return resolveLocalized(payload[key] as TranslatableString | null | undefined, lang)
}
@@ -2,6 +2,7 @@
"nav": {
"home": "Home",
"shop": "Shop",
"promotions": "Promotions",
"collections": "Collections",
"scentGuide": "Scent Guide",
"about": "About",
@@ -2,6 +2,7 @@
"nav": {
"home": "หน้าหลัก",
"shop": "ร้านค้า",
"promotions": "โปรโมชัน",
"collections": "คอลเลกชัน",
"scentGuide": "คลาสแนะนำกลิ่น",
"about": "เกี่ยวกับเรา",
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface Customer {
id: number
lineUserId: string
displayName: string | null
pictureUrl: string | null
email: string | null
phone: string | null
points?: number
}
interface AuthState {
token: string | null
customer: Customer | null
setAuth: (token: string, customer: Customer) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
customer: null,
setAuth: (token, customer) => set({ token, customer }),
logout: () => set({ token: null, customer: null }),
}),
{
name: 'homaroma-auth-storage',
partialize: (state) => ({ token: state.token, customer: state.customer }),
},
),
)
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Product } from '@homaroma/types'
interface WishlistState {
items: Product[]
addItem: (product: Product) => void
removeItem: (productId: number) => void
toggleItem: (product: Product) => void
isInWishlist: (productId: number) => boolean
getWishlistCount: () => number
}
export const useWishlistStore = create<WishlistState>()(
persist(
(set, get) => ({
items: [],
addItem: (product) => set((state) => {
if (state.items.some((item) => item.id === product.id)) return state
return { items: [...state.items, product] }
}),
removeItem: (productId) => set((state) => ({
items: state.items.filter((item) => item.id !== productId),
})),
toggleItem: (product) => {
const { isInWishlist, removeItem, addItem } = get()
if (isInWishlist(product.id)) {
removeItem(product.id)
return
}
addItem(product)
},
isInWishlist: (productId) => get().items.some((item) => item.id === productId),
getWishlistCount: () => get().items.length,
}),
{
name: 'homaroma-wishlist',
partialize: (state) => ({ items: state.items }),
},
),
)
+151
View File
@@ -0,0 +1,151 @@
# Website CMS Parity Plan
## Goal
Bring `apps/website` closer to the Page Builder CMS behavior already used by `apps/spa`, while preserving Next.js SSR/SEO behavior.
The website should not copy SPA client rendering wholesale. It should use CMS layout sections for editable marketing/content areas, and keep SEO-critical commerce data server-rendered.
## Scope
Allowed implementation scope:
- `apps/website/**`
- `docs/**`
- `implementation_plan.md`
Do not edit without separate approval:
- `apps/spa/**`
- `apps/admin/**`
- `apps/backend/**`
- `packages/types/**`
If a correct fix requires backend, admin, schema, or shared type changes, report it as a blocker instead of patching around it in website code.
## Current Website CMS Support
Supported in `apps/website`:
- `/` fetches `/pages/home`
- Website-local `DynamicPageRenderer`
- Supported SSR-safe blocks:
- `ANNOUNCEMENT_BAR`
- `HERO`
- `CATEGORIES`
- `PRODUCTS`
- `PRODUCT_CAROUSEL`
- `BANNER`
- `TEXT`
- `RICH_TEXT`
- `NEWSLETTER`
- `EDITORIAL_TILE`
- `EDITORIAL_CAROUSEL`
- `HERO_ARTICLE`
- `SCENT_NOTES_BREAKDOWN`
- `QUOTE_CALLOUT`
- `ACCORDION`
- `CONTACT_INFO`
Not fully supported yet:
- Client-island block:
- `CONTACT_FORM`
- Nested CMS custom pages such as `/campaign/summer`
- Route layout CMS parity for shop/category/product/search/coupons/scent guide
## Route Layout Matrix
| SPA route | Website route | CMS slug | Status | Implementation notes |
|---|---|---:|---|---|
| `/` | `/` | `home` | supported-m1 | Server-rendered CMS sections. |
| `/shop` | `/shop` | `shop-layout` | supported-m1 | Renders announcement/hero/bottom CMS sections around SSR product grid. |
| `/category/:id` | `/category/[id]` | `category-layout` | supported-m1 | Renders announcement/hero/bottom CMS sections around SSR category product grid. |
| `/products/:id` | `/products/[id]` | `product-detail-layout` | supported-m1 | Renders announcement/bottom CMS sections around SSR product detail. |
| `/search` | `/search` | `search-layout` | supported-m1 | Renders announcement/hero/bottom CMS sections around SSR search form and results. |
| `/scent-guide` | `/scent-guide` | `scent-guide-layout` | supported-m1 | Renders announcement/hero/bottom CMS sections around article index and exposes article API error state. |
| `/coupons` | `/coupons` | `coupons-layout` | supported-m1 | Renders announcement/hero/bottom CMS sections around SSR coupon grid and exposes coupon API error state. |
| `/track` | `/track` | `track-order-layout` | later-phase | Depends on track flow migration. |
| `/wishlist` | none | `wishlist-layout` | later-phase | Depends on wishlist route migration. |
| `/order/:id` | none | `order-success-layout` | later-phase | Depends on order success flow migration. |
| `/:slug` | `[slug]` | page slug | supported-m1 | Server-renders published CMS pages by slug with reserved route collision guard. |
## CMS Layout Rendering Pattern
For SEO-critical routes, use this structure:
1. Fetch route business data on the server.
2. Fetch optional CMS layout page on the server.
3. Render `ANNOUNCEMENT_BAR` sections above page content.
4. Render `HERO` and `BANNER` sections as editable page header when present.
5. Render a safe hardcoded SSR fallback header when CMS header sections are missing.
6. Render route business content as SSR HTML.
7. Render remaining CMS sections below the business content.
This keeps marketing-controlled content editable without making product/category/search content client-only.
## Custom CMS Page Policy
M1 supports single-segment CMS pages through `apps/website/src/app/[slug]/page.tsx`.
Reserved slugs are intentionally blocked from CMS catch-all rendering:
- `about`
- `article`
- `category`
- `contact`
- `coupons`
- `faq`
- `order`
- `products`
- `profile`
- `search`
- `scent-guide`
- `shop`
- `track`
- `wishlist`
Why:
- Existing Next routes should keep their route-specific SSR behavior and metadata.
- Commerce/auth routes should not be silently replaced by CMS content.
- Nested CMS paths need an explicit admin/backend slug policy before website supports them.
## Block Support Roadmap
### Batch A: Layout Blocks Already Present
Use existing website renderer:
- `ANNOUNCEMENT_BAR`
- `HERO`
- `BANNER`
- `TEXT`
- `NEWSLETTER`
- `CATEGORIES`
- `PRODUCTS`
- `PRODUCT_CAROUSEL`
### Batch B: Content Blocks To Port From SPA
Website-local SSR-safe equivalents added:
- `EDITORIAL_TILE`
- `EDITORIAL_CAROUSEL`
- `HERO_ARTICLE`
- `SCENT_NOTES_BREAKDOWN`
- `QUOTE_CALLOUT`
- `ACCORDION`
- `CONTACT_INFO`
### Batch C: Client-Island Blocks
Add only after explicit route needs are clear:
- `CONTACT_FORM`
- newsletter submit behavior
- any block requiring browser state, localStorage, LIFF, auth, or cart state
`CONTACT_FORM` should not simulate successful submission without a real API contract. Keep it unsupported until the website contact submission behavior is defined.
Decision:
- Defer `CONTACT_FORM` for now because the submission endpoint is not ready.
- Do not render a fake successful submission state.
- Do not create a website-only endpoint workaround.
- Revisit only when backend/admin/API ownership for contact submissions is confirmed.
## Acceptance Criteria
- `apps/website` route still renders SEO-critical content on the server.
- CMS layout failure does not break commerce content.
- Published single-segment CMS pages can render through `/:slug`.
- API/data failure for commerce content is visible and not collapsed into a fake empty state.
- Unsupported block types are logged in development and documented in this plan.
- Lint and production build pass for `apps/website`.
- No files outside allowed scope are modified.
## Current Next Step
Skip `CONTACT_FORM` for now and continue with missing website flows such as checkout, order success, tracking, wishlist, and profile.
+43
View File
@@ -0,0 +1,43 @@
# Website Route Parity Audit
Scope: read-only comparison of `apps/spa/src/App.tsx` against `apps/website/src/app/**`.
## Route Matrix
| SPA route | Website route | Status | Notes |
|---|---|---|---|
| `/` | `/` | upgraded-m1 | Server-rendered from the CMS page endpoint internally. |
| `/shop` | `/shop` | upgraded-m1 | Server-renders product/category content, preserves coupon handoff, and consumes `shop-layout` CMS sections around the product grid. |
| `/category/:id` | `/category/[id]` | upgraded-m1 | Server-renders category/product content with dynamic metadata and consumes `category-layout` CMS sections around the product grid. |
| `/products/:id` | `/products/[id]` | upgraded-m1 | Server-renders product details and metadata, and consumes `product-detail-layout` CMS sections around the product content. |
| `/coupons` | `/coupons` | upgraded-m1 | Uses internal links, consumes `coupons-layout` CMS sections, and exposes coupon API error state. |
| `/checkout` | `/checkout` | partial-m1 | Client checkout creates pending orders through `POST /orders`, supports settings shipping fee, promotions, coupon validation, saved addresses, points, local order history, and `checkout-layout` CMS sections. Payment/slip upload remains deferred to `/order/[id]` follow-up. |
| `/order/:id` | `/order/[id]` | partial-m1 | Server-renders order status, item summary, tracking number, and `order-success-layout` CMS sections. Payment/upload actions are intentionally deferred. |
| `/track` | `/track` | upgraded-m1 | Replaces redirect with order tracking form, `POST /orders/track`, device order history, and `track-order-layout` CMS sections. |
| `/wishlist` | `/wishlist` | upgraded-m1 | Client wishlist store now matches SPA storage key, supports add/remove from product cards, and consumes `wishlist-layout` CMS sections. |
| `/search` | `/search` | upgraded-m1 | Server-renders search results, guards repeated query params, and consumes `search-layout` CMS sections around the search UI. |
| `/scent-guide` | `/scent-guide` | upgraded-m1 | Server-renders article index, consumes `scent-guide-layout` CMS sections, and exposes article API error state. |
| `/article/:slug` | `/article/[slug]` | exists-needs-upgrade | Exists with dynamic metadata and `notFound`; needs HTML sanitization review. |
| `/profile` | `/profile` | partial-m1 | Client profile dashboard uses SPA-compatible auth storage, order history, address CRUD, contact settings, and `profile-layout` CMS sections. LIFF initialization for website remains a follow-up dependency. |
| `/:slug` | `/[slug]` | upgraded-m1 | Server-renders published single-segment CMS pages with reserved route collision guard. |
| `*` | default Next behavior | missing | No custom `not-found.tsx` currently exists. |
## Top Website-Only Risks
| Risk | Severity | Impact |
|---|---|---|
| Missing legacy classes/design tokens | High | M1 visual cleanup removed `night-*`, `cyan-glow`, and `glass` from website source classes; remaining visual risk is manual UI verification across breakpoints. |
| Hardcoded production URLs | Medium | Initial coupon page issue is fixed; continue checking remaining legacy pages during Phase 6. |
| Silent API fallbacks | High | M1 SEO routes now expose explicit error states in key product listing flows; remaining legacy pages still need review. |
| Client-only SEO weakness | High | M1 fixed `/shop`, `/category/[id]`, `/search`, `/track`, `/wishlist`, `/profile`, `/checkout`, and read-only `/order/[id]`; remaining auth/payment flows are still later-phase work. |
| Client-side pricing trust | High | Checkout follows the SPA contract, but backend still trusts client-provided subtotal, discounts, points, shipping, and total. Server-side pricing validation should be owned by backend before production-grade payment rollout. |
| Image optimization debt | Low | M1 intentionally keeps `<img>` until image hosting/CDN policy is finalized; `next/image` should be revisited after `images.remotePatterns` can be configured safely. |
## Recommended Order
1. Stabilize website-local data helpers.
2. Add website CMS renderer and upgrade `/`.
3. Upgrade `/products/[id]`.
4. Convert `/shop` and `/category/[id]` to SSR shell plus client islands.
5. Add CMS custom `/:slug` support with explicit collision rules.
6. Add missing commerce/client routes after SEO pages are stable.
+301
View File
@@ -0,0 +1,301 @@
# Website SSR Migration Plan
## Goal
Upgrade `apps/website` into the Next.js SSR/SEO version of the current `apps/spa` experience.
`apps/spa` is the UX and behavior reference. `apps/website` is the only application implementation target for this migration.
## Scope
Allowed edit scope:
- `apps/website/**`
- planning or handoff documents such as this file
Do not edit without separate approval:
- `apps/spa/**`
- `apps/backend/**`
- `apps/admin/**`
- `packages/types/**`
If a correct fix requires backend, schema, shared types, admin CMS, or SPA changes, stop and report it as a blocker instead of using a workaround.
## Source Of Truth
- SPA route map: `apps/spa/src/App.tsx`
- SPA home CMS usage: `apps/spa/src/pages/HomePage.tsx`
- SPA dynamic renderer: `apps/spa/src/components/DynamicPageRenderer.tsx`
- Website API resolver: `apps/website/src/lib/api.ts`
- Website App Router pages: `apps/website/src/app/**`
- Backend CMS API contract, read-only reference only: `apps/backend/src/routes/pages.ts`
- Shared CMS payload schemas, read-only reference only: `packages/types/index.ts`
## Important Clarification
The SPA does not expose a frontend route named `/pages/home`.
The SPA route `/` renders `HomePage`, and `HomePage` calls the backend CMS API endpoint:
```text
GET ${API_BASE}/pages/home?lang=<current-language>
```
For `apps/website`, this means Next pages should keep public SEO routes such as `/`, `/shop`, `/category/[id]`, and `/products/[id]`, while using the same CMS API endpoint internally when needed.
## Migration Strategy
Do not copy the SPA wholesale into Next.
Use this pattern instead:
- Server components fetch SEO-critical data and render meaningful initial HTML.
- Client components handle cart, wishlist, LIFF, forms, filters, payment, and localStorage state.
- CMS section rendering is adapted into website-local components.
- Published single-segment CMS pages can be rendered through `/:slug` without replacing route-specific commerce pages.
- API/environment logic stays website-local and uses `NEXT_PUBLIC_API_URL` / `INTERNAL_API_URL`.
## Phase 1: Website Foundation Audit
Owner: `frontend-code-auditor` review only
Tasks:
- Build a route parity matrix between `apps/spa/src/App.tsx` and `apps/website/src/app/**`.
- Classify routes as `exists-needs-upgrade`, `missing`, `client-only-ok`, or `seo-critical`.
- Identify legacy classes, missing design tokens, hardcoded URLs, and silent API fallbacks in `apps/website`.
- Identify pages where current client-only fetch weakens SEO.
Deliverables:
- Route parity matrix.
- Website-only risk list.
- Recommended migration order.
## Phase 2: Website Data Layer
Owner: `frontend-lead`
Review: `frontend-code-auditor`
Tasks:
- Create or refine website-local fetch helpers under `apps/website/src/lib`.
- Separate server-safe fetch helpers from client-only fetch helpers.
- Ensure all helpers use `apps/website/src/lib/api.ts`.
- Add consistent handling for API failures without hiding important contract issues.
- Avoid importing runtime API constants from `@homaroma/types`.
Acceptance criteria:
- No edits outside allowed scope.
- API helpers work in Next server and client contexts as intended.
- No silent empty fallback on SEO-critical pages without an explicit fallback reason.
## Phase 3: CMS Dynamic Renderer For Website
Owner: `frontend-lead`
Review: `frontend-code-auditor`
Tasks:
- Create website-local dynamic page renderer under `apps/website/src/components`.
- Adapt these section types first:
- `ANNOUNCEMENT_BAR`
- `HERO`
- `CATEGORIES`
- `PRODUCTS`
- `PRODUCT_CAROUSEL`
- `BANNER`
- `TEXT`
- `NEWSLETTER`
- Split SSR-capable sections from client-only interactive sections.
- Keep payload guards local to `apps/website` if shared types are insufficient.
Acceptance criteria:
- Home page can render CMS sections from `/pages/home`.
- Unknown section types fail visibly in development logs, but do not crash production rendering.
- No broad `any` use unless isolated and documented as a temporary contract gap.
## Phase 4: SEO Route Migration
Owner: `frontend-lead`
Review: `frontend-code-auditor`
Migration order:
1. `/`
2. `/products/[id]`
3. `/shop`
4. `/category/[id]`
5. `/article/[slug]`
6. `/search`
Tasks:
- Convert SEO-critical content to server-rendered data where possible.
- Keep interactive controls as client islands.
- Add or preserve `generateMetadata` for pages that can provide SEO data.
- Generate fallback metadata from available API data if CMS metadata is unavailable.
Acceptance criteria:
- Initial HTML contains meaningful product/category/article/page content.
- Cart, wishlist, filters, and add-to-cart remain interactive.
- Build and lint pass for `apps/website`.
## Phase 5: Commerce And LINE OA Client Features
Owner: `frontend-lead`
Review: `frontend-code-auditor`
Flow QA: `qa-product-auditor`
Routes:
- `/checkout`
- `/order/[id]`
- `/track`
- `/wishlist`
- `/profile`
Tasks:
- Adapt SPA behavior into website-local client components.
- Add website-local auth, wishlist, and LIFF integration if needed.
- Keep SEO shell/server rendering where useful, but treat these routes as client-heavy flows.
- Use existing backend API endpoints without changing backend contracts.
Acceptance criteria:
- Checkout, order success, tracking, wishlist, and profile flows work on desktop and mobile.
- LINE OA/LIFF logic only runs on the client.
- Payment/order/upload failures have visible error states.
## Phase 6: Visual And UX Alignment
Owner: `ux-ui-designer` + `frontend-lead`
Review: `design-system-auditor`
Tasks:
- Align website visual language with SPA source of truth.
- Remove or replace legacy website-only classes that are not defined.
- Align navbar, footer, cart drawer, product card, loading states, empty states, and error states.
- Check mobile layout and common shopping flows.
Acceptance criteria:
- Website no longer mixes incompatible legacy dark/cyan styling with SPA visual language.
- UI remains readable and responsive.
- Design token usage is consistent within `apps/website`.
## Phase 7: Verification And Release Gate
Owner: `orchestrator`
Reviewers:
- `frontend-code-auditor`
- `qa-product-auditor`
- `design-system-auditor`
Required checks:
- `npm run build -w apps/website`
- `npm run lint -w apps/website`
- Manual route smoke test for SEO routes.
- Manual commerce flow test for checkout/order/tracking.
- Verify no files outside allowed scope were edited.
- Verify all blockers requiring backend/schema/shared type changes are reported, not worked around.
## Delegation Prompts
### Frontend Code Auditor
Use for Phase 1 and review gates.
```text
Review only. Do not edit files.
Inspect apps/website against apps/spa as reference.
Focus on route parity, SSR SEO risks, type/API contract risks, hardcoded values, missing states, and anti-patterns.
Allowed output: findings, severity, file paths, recommended fix, escalation needed.
```
### Frontend Lead
Use for implementation phases.
```text
Implement only inside apps/website.
Use apps/spa as UX and behavior reference, but do not edit apps/spa.
Do not edit apps/backend, apps/admin, or packages/types.
If the correct fix requires those areas, stop and report a blocker.
Preserve Next.js SSR for SEO-critical content and use client islands for interactive behavior.
```
### QA Product Auditor
Use after route groups are implemented.
```text
Review user flows only. Do not edit files unless explicitly asked.
Check broken flows, empty states, error states, mobile behavior, checkout/order/tracking/profile/wishlist flows, and release readiness.
```
### Design System Auditor
Use after visual alignment.
```text
Review visual consistency only. Do not edit files unless explicitly asked.
Check contrast, readability, missing classes, token usage, responsive layout, and SPA/website visual alignment.
```
## Open Decisions
- Should `apps/website` support locale-specific URL paths later, or keep language state client-side for now?
- Should dynamic CMS SEO metadata be generated only from existing API data in this migration?
- Should `apps/spa` remain the dedicated LINE OA app after website reaches parity, or should website eventually replace it?
- Should shared type/runtime cleanup be scheduled as a separate project after website-only migration?
- Should nested CMS paths such as `/campaign/summer` be supported, and if yes, what slug policy should admin/backend enforce to avoid collisions with business routes?
## Decisions
### Image Optimization
Decision: defer migration from `<img>` to `next/image` for M1.
Reason:
- Current image sources are mixed between local public paths such as `/images/...` and backend/CMS upload paths such as `/uploads/...`.
- `apps/website/next.config.ts` does not yet define final `images.remotePatterns` or production CDN/image host policy.
- Changing to `next/image` before image hosting is finalized can create broken remote image rendering or force premature config assumptions.
- M1 priority is SSR/SEO route parity, API correctness, visual cleanup, and build stability.
Current policy:
- Keep existing `<img>` usage for now.
- Treat `@next/next/no-img-element` warnings as accepted M1 optimization debt, not a release blocker.
- Revisit after production image domain/CDN and upload URL strategy are finalized.
Future acceptance criteria for `next/image` migration:
- Production image host or CDN is known.
- `apps/website/next.config.ts` has explicit `images.remotePatterns` for allowed hosts.
- Hero, product primary image, category hero, article cover, and CMS hero/banner images are migrated first.
- Lint still passes and images render in dev, preview, and production-like environments.
## Initial Milestone
M1: Website SEO Foundation
Target:
- Phase 1 complete.
- Phase 2 complete.
- Basic CMS renderer exists in `apps/website`.
- `/` and `/products/[id]` upgraded first.
- `apps/website` build/lint pass.
- `frontend-code-auditor` review passed.
Current status:
- Phase 1 route parity audit documented in `docs/website-route-parity-audit.md`.
- Website-local data helpers added under `apps/website/src/lib`.
- Website-local result helpers now expose API errors instead of converting every failure into an empty list.
- CMS layout parity is now wired for `/`, `/shop`, `/category/[id]`, `/products/[id]`, `/search`, `/scent-guide`, and `/coupons`.
- Published single-segment CMS pages now render through `apps/website/src/app/[slug]/page.tsx` with reserved slug collision protection.
- Website CMS renderer now supports SSR-safe editorial, article hero, scent notes, quote, FAQ accordion, and contact info blocks.
- `CONTACT_FORM` is intentionally deferred until a real submission endpoint/API owner is ready. Do not implement simulated submit behavior.
- `/track` now renders the tracking form, calls `POST /orders/track`, stores validated local order history, and consumes `track-order-layout` CMS sections.
- `/order/[id]` now exists as a read-only SSR order status page with item summary, status, tracking number, metadata, and `order-success-layout` CMS sections.
- `/wishlist` now exists with a website-local persisted wishlist store compatible with SPA `homaroma-wishlist`, product-card heart toggles, navbar counters, and `wishlist-layout` CMS sections.
- `/profile` now exists with SPA-compatible `homaroma-auth-storage`, member dashboard, order history, address CRUD, contact settings, navbar profile entry, and `profile-layout` CMS sections.
- `/checkout` now exists with a website-local client checkout, SPA-compatible cart/auth storage, `GET /settings`, `GET /promotions/active`, `POST /coupons/validate`, saved address support, points redemption, `POST /orders`, local order history, cart clearing, redirect to `/order/[id]`, and `checkout-layout` CMS sections.
Next implementation target:
- Run manual commerce QA for `/shop -> cart -> /checkout -> /order/[id] -> /track`.
- Confirm website LIFF initialization/auth bootstrap before treating `/profile` as fully production-ready.
- Extend `/order/[id]` payment/slip upload only after explicitly planning the payment side effects and QA gate.
- Escalate backend server-side pricing validation before treating checkout/payment as production-grade, because the current `POST /orders` contract trusts client totals, discounts, points, and shipping values.
- Basic website CMS renderer added under `apps/website/src/components/dynamic`.
- `/` now renders CMS sections from `/pages/home` internally.
- `/products/[id]` now uses website-local data helpers and avoids duplicate product fetch.
- `/shop`, `/category/[id]`, and `/search` now render product/category content on the server instead of client-only fetch.
- `/shop` preserves coupon handoff with a client-only localStorage bridge while keeping product content server-rendered.
- `/search` guards repeated `q` query params before trimming.
- Navbar, Footer, CartDrawer, and legacy content pages now use the website `pop-*` visual language instead of undefined dark/cyan/glass classes.
- CMS parity planning added in `docs/website-cms-parity-plan.md`.
- `/shop` now consumes `shop-layout` CMS sections for announcement, hero/banner, and bottom content around the SSR product grid.
- `/category/[id]` now consumes `category-layout` CMS sections for announcement, hero/banner, and bottom content around the SSR category product grid.
- `/products/[id]` now consumes `product-detail-layout` CMS sections for announcement and bottom content around the SSR product detail.
- `/search` now consumes `search-layout` CMS sections for announcement, hero/banner, and bottom content around SSR search results.
- `/scent-guide` now consumes `scent-guide-layout` CMS sections for announcement, hero/banner, and bottom content around the article index.
- `/scent-guide` now exposes article API failure separately from a legitimate empty article list.
- `/coupons` now consumes `coupons-layout` CMS sections for announcement, hero/banner, and bottom content around the SSR coupon grid.
- `/coupons` now exposes coupon API failure separately from a legitimate empty coupon list.
- `apps/website` CMS parity next target is CMS catch-all custom page support and remaining SPA-only block renderer parity.
- `apps/website` lint passes with warnings only.
- `apps/website` production build passes.
Remaining M1 follow-up:
- Final manual browser smoke test of upgraded website routes.
- Keep remaining `<img>` usage for M1 and schedule `next/image` as a later optimization pass after image hosting is finalized.
- Continue manual visual QA for Navbar, Footer, CartDrawer, and content pages across desktop/mobile breakpoints.