x
This commit is contained in:
1 parent
3c4bb06003
commit
f7aa4d099f
36 files changed
+528
-180
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
|
||||
// lang is extracted from params
|
||||
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, lang: string }> }): Promise<Metadata> {
|
||||
const { slug, lang } = 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, lang: string }> }) {
|
||||
const { slug, lang } = 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}`)
|
||||
}
|
||||
|
||||
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(sections)
|
||||
|
||||
return (
|
||||
<main className="min-h-[70vh] bg-pop-bg">
|
||||
{sections.length > 0 ? (
|
||||
<>
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
{heroSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={bottomSections} 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
icon: '🌱',
|
||||
title: 'Pure Origins',
|
||||
desc: 'Every extract is sourced directly from certified organic farms in Northern Thailand, Vietnam, and Southern France. We work exclusively with small-batch artisan growers.',
|
||||
},
|
||||
{
|
||||
icon: '⚗️',
|
||||
title: 'Artisan Craft',
|
||||
desc: 'Each formula is cold-extracted and hand-blended in our Bangkok studio. No heat processing means every aromatic molecule remains intact and therapeutically potent.',
|
||||
},
|
||||
{
|
||||
icon: '♾️',
|
||||
title: 'Zero Compromise',
|
||||
desc: 'No synthetic fragrance. No parabens. No animal testing. Our commitment to clean formulation is absolute — what touches your senses should honour your wellbeing.',
|
||||
},
|
||||
]
|
||||
|
||||
const STATS = [
|
||||
{ value: '45+', label: 'Unique Scents' },
|
||||
{ value: '5', label: 'Product Lines' },
|
||||
{ value: '2019', label: 'Founded' },
|
||||
{ value: '100%', label: 'Natural Extracts' },
|
||||
]
|
||||
|
||||
export const metadata = {
|
||||
title: 'About Us | HomAroma',
|
||||
description: 'Born from a passion for stillness. HomAroma began in 2019 as a Bangkok studio crafting botanical aroma blends.',
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<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-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>
|
||||
|
||||
<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-pop-pink">{s.value}</div>
|
||||
<div className="text-xs uppercase tracking-widest text-gray-500">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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-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-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 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="space-y-3 text-center">
|
||||
<div className="text-7xl">🌿</div>
|
||||
<p className="text-sm uppercase tracking-widest text-gray-500">HomAroma Studio, Bangkok</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 gap-8 md:grid-cols-3">
|
||||
{PILLARS.map(p => (
|
||||
<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 text-pop-black">{p.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600">{p.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="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>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { Article } from '@homaroma/types'
|
||||
|
||||
import { API_BASE } from '@/lib/api'
|
||||
|
||||
async function getArticle(slug: string): Promise<Article | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/articles/${slug}`, { next: { revalidate: 300 } })
|
||||
return res.ok ? res.json() : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const article = await getArticle(slug)
|
||||
if (!article) return { title: 'Article Not Found | HomAroma' }
|
||||
return {
|
||||
title: `${article.title} | HomAroma Scent Guide`,
|
||||
description: article.content.replace(/<[^>]+>/g, '').slice(0, 160),
|
||||
openGraph: {
|
||||
title: article.title,
|
||||
images: article.coverImage ? [article.coverImage] : [],
|
||||
type: 'article',
|
||||
publishedTime: article.createdAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ArticleReaderPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const article = await getArticle(slug)
|
||||
|
||||
if (!article) notFound()
|
||||
|
||||
return (
|
||||
<div className="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 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="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
<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-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="mx-auto max-w-3xl text-4xl font-bold leading-tight text-pop-black md:text-5xl lg:text-6xl">
|
||||
{article.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{article.coverImage && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
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 { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
import { resolveLocalized } from '@/lib/localization'
|
||||
|
||||
// lang is extracted from params
|
||||
type CategoryProductsResponse = {
|
||||
category?: Category
|
||||
data?: Product[]
|
||||
}
|
||||
|
||||
async function getCategoryProducts(id: string, lang: string): Promise<ApiResult<CategoryProductsResponse>> {
|
||||
return await fetchJsonResult<CategoryProductsResponse>(`/categories/${id}/products?limit=1000&lang=${lang}`, {
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
|
||||
const { id, lang } = await params
|
||||
const result = await getCategoryProducts(id, lang)
|
||||
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, lang: string }> }) {
|
||||
const { id, lang } = await params
|
||||
const [categoryProductsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
|
||||
getCategoryProducts(id, lang),
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
// lang is extracted from params
|
||||
export const metadata = {
|
||||
title: 'Checkout | HomAroma',
|
||||
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ HomAroma',
|
||||
}
|
||||
|
||||
export default async function CheckoutPage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us | HomAroma',
|
||||
description: 'Get in touch with HomAroma. We respond to all inquiries within 2 hours.',
|
||||
}
|
||||
|
||||
export default function ContactLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
|
||||
type FormState = { name: string; email: string; subject: string; message: string }
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error'
|
||||
const SUBJECTS = ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other']
|
||||
|
||||
export default function ContactPage() {
|
||||
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus('sending')
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
setStatus('sent')
|
||||
setForm({ name: '', email: '', subject: '', message: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen 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'd love to hear from you.</p>
|
||||
</div>
|
||||
|
||||
<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="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' },
|
||||
{ icon: '📱', label: 'LINE Official', value: '@homaroma', href: '#' },
|
||||
{ icon: '📸', label: 'Instagram', value: '@homaroma', href: '#' },
|
||||
{ icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' },
|
||||
].map(item => (
|
||||
<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 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="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-gray-600">{k}</span>
|
||||
<span className="font-medium text-pop-pink">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="pt-2 text-[10px] uppercase tracking-wider text-gray-500">Mon-Sat, 9:00-18:00 (ICT)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-3">
|
||||
{status === 'sent' ? (
|
||||
<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'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 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-gray-600">Full Name *</label>
|
||||
<input required type="text" name="name" value={form.name} onChange={handleChange} placeholder="Your name"
|
||||
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-gray-600">Email *</label>
|
||||
<input required type="email" name="email" value={form.email} onChange={handleChange} placeholder="your@email.com"
|
||||
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-gray-600">Subject *</label>
|
||||
<select required name="subject" value={form.subject} onChange={handleChange}
|
||||
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-gray-600">Message *</label>
|
||||
<textarea required name="message" value={form.message} onChange={handleChange} rows={6} placeholder="Tell us about your inquiry..."
|
||||
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="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="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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
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'
|
||||
|
||||
// lang is extracted from params
|
||||
function formatDiscount(type: string, value: number) {
|
||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
|
||||
if (type === 'FREE_SHIPPING') return 'ส่งฟรี'
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'โปรโมชั่นและคูปองส่วนลด | HomAroma',
|
||||
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก HomAroma สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
|
||||
}
|
||||
|
||||
export default async function CouponsPage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
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="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="mx-auto max-w-2xl text-gray-600">
|
||||
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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"
|
||||
>
|
||||
ไปช้อปปิ้งกันก่อน
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{coupons.map((coupon) => (
|
||||
<article
|
||||
key={coupon.id}
|
||||
className="relative overflow-hidden rounded-3xl bg-white p-8 shadow-brutal transition hover:-translate-y-1 hover:shadow-brutal-hover"
|
||||
>
|
||||
<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="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="mb-1 text-3xl font-black text-pop-black">
|
||||
{formatDiscount(coupon.type, coupon.value)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
เมื่อซื้อครบ {coupon.minPurchase} บาท
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FAQ | HomAroma',
|
||||
description: 'Everything you need to know about HomAroma products, shipping, and care.',
|
||||
}
|
||||
|
||||
export default function FaqLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
import Link from '@/components/LocalizedLink'
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
category: 'Products', icon: '🌿',
|
||||
items: [
|
||||
{ q: 'What are the ingredients in your aroma products?', a: 'All HomAroma products are crafted from 100% natural botanical extracts, pure essential oils, and pharmaceutical-grade carrier bases. We never use synthetic fragrances, parabens, or harmful chemicals. Each product label lists full ingredient transparency.' },
|
||||
{ q: 'How long does each product last?', a: 'Our Reed Diffusers last 4–8 weeks depending on room size and airflow. Aroma Oils (30ml) last approximately 3–4 months with normal use. Room Sprays (100ml) provide 60–80 uses. Car Diffusers cartridges last up to 45 days.' },
|
||||
{ q: 'Are your products suitable for sensitive skin or allergies?', a: 'We recommend patch testing before topical use. Our Aroma Oils are formulated for diffuser use primarily. If you have known fragrance allergies, consult our scent guide or contact us with your specific concerns.' },
|
||||
{ q: 'What is the difference between Aroma Oil and Oil Ozone?', a: 'Aroma Oils are pure essential oil blends designed for aromatherapy diffusers and therapeutic use. Oil Ozone is our premium line featuring ozone-infused botanical extracts — designed for air purification and deeper space transformation.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Usage', icon: '💡',
|
||||
items: [
|
||||
{ q: 'How do I use a Reed Diffuser?', a: 'Remove the stopper, place the reeds into the bottle, and let them absorb the oil for 1 hour before flipping. For optimal fragrance, flip the reeds every 2–3 days. Keep away from direct sunlight and air conditioning vents.' },
|
||||
{ q: 'Can I mix different scents?', a: 'Yes! Blending scents is an art form we encourage. A general rule: combine no more than 3 scents at a time, using one dominant (60%), one supporting (30%), and one accent (10%). Our Scent Guide can help you find complementary combinations.' },
|
||||
{ q: 'How long should I run my aroma diffuser?', a: 'For best results, we recommend 30–60 minute sessions with 30-minute breaks in between. This also extends product life and prevents overwhelming fragrance build-up in small spaces.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Shipping & Orders', icon: '📦',
|
||||
items: [
|
||||
{ q: 'How long does shipping take?', a: 'Standard delivery within Bangkok takes 1–2 business days. Provincial orders take 2–4 business days. Express same-day delivery is available in Bangkok for orders placed before 12:00 PM.' },
|
||||
{ q: 'Do you ship internationally?', a: 'Currently we ship within Thailand only. International shipping is planned for late 2025. Sign up for our newsletter to be notified when we expand to your region.' },
|
||||
{ q: 'What is your return policy?', a: 'We accept returns within 14 days of purchase for unopened, undamaged products in original packaging. For defective or damaged products, we offer full replacement at no cost.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Scent Guide', icon: '🧪',
|
||||
items: [
|
||||
{ q: 'How do I choose the right scent for my space?', a: 'Consider the function of the space first. Bedrooms benefit from calming scents like lavender and chamomile. Living rooms work well with warm notes like sandalwood and vanilla. Home offices pair best with focus-enhancing eucalyptus and citrus.' },
|
||||
{ q: 'Can I request custom scent blending?', a: 'Yes! Our bespoke blending service is available for orders of 6 units or more. Contact us at least 10 business days before your desired delivery date. Custom blending fees start at ฿500 per unique formula.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function FaqPage() {
|
||||
const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
|
||||
const [activeCategory, setActiveCategory] = useState<string>('Products')
|
||||
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
const activeFaqs = FAQS.find(f => f.category === activeCategory)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen 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="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={`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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{activeFaqs?.items.map((item, i) => {
|
||||
const key = `${activeCategory}-${i}`
|
||||
const isOpen = openItems[key]
|
||||
return (
|
||||
<div key={key} className={`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="mb-4 h-px w-full bg-gray-100" />
|
||||
<p className="text-sm leading-relaxed text-gray-600">{item.a}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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="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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Metadata } from 'next';
|
||||
import '../globals.css';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import Footer from '@/components/Footer';
|
||||
import ClientProviders from '@/components/ClientProviders';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import type { MegaMenuPayload } from '@/components/navigation/menuTypes';
|
||||
import type { MenuItem, StoreSettings } from '@homaroma/types';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'HomAroma',
|
||||
description: 'Pure Origins, Artisan Craft, Zero Compromise',
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
|
||||
};
|
||||
|
||||
// Fetch categories for Navbar and Footer on the server
|
||||
async function getCategories(lang: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories in layout', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch mega menu on the server — eliminates client-side flash
|
||||
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: 300 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { payload?: MegaMenuPayload[] };
|
||||
return Array.isArray(data.payload) ? data.payload : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch mega menu in layout', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch footer menus (explore + support) on the server
|
||||
async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
|
||||
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: 300 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { items?: MenuItem[] };
|
||||
return Array.isArray(data.items)
|
||||
? data.items.sort((a, b) => a.order - b.order)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const [exploreMenu, supportMenu] = await Promise.all([
|
||||
fetchMenu('footer-explore'),
|
||||
fetchMenu('footer-support'),
|
||||
]);
|
||||
|
||||
return { exploreMenu, supportMenu };
|
||||
}
|
||||
|
||||
// Fetch store settings (social links, footer texts) on the server
|
||||
async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: 600 } });
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as StoreSettings;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch store settings in layout', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang } = await params;
|
||||
|
||||
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
|
||||
getCategories(lang),
|
||||
getMegaMenu(lang),
|
||||
getFooterMenus(lang),
|
||||
getStoreSettings(lang),
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang={lang} className="scroll-smooth">
|
||||
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||
<main className="global-main pt-[72px]">
|
||||
{children}
|
||||
</main>
|
||||
<Footer
|
||||
categories={categories}
|
||||
exploreMenu={footerMenus.exploreMenu}
|
||||
supportMenu={footerMenus.supportMenu}
|
||||
settings={settings}
|
||||
/>
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
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'
|
||||
|
||||
// lang is extracted from params
|
||||
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, lang: string }> }) {
|
||||
const { id, lang } = 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Metadata } from 'next'
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import { getCategories, getCmsPage } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ lang: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { lang } = await params
|
||||
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.',
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: Props) {
|
||||
const { lang } = await params
|
||||
|
||||
const [categories, page] = await Promise.all([
|
||||
getCategories(lang),
|
||||
getCmsPage('home', lang),
|
||||
])
|
||||
|
||||
const sections = page?.sections ?? []
|
||||
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(sections)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-pop-bg">
|
||||
{sections.length > 0 ? (
|
||||
<>
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
{heroSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
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 { resolveCategoryName, resolveLocalized } from '@/lib/localization'
|
||||
|
||||
async function getRelated(categoryId: number, excludeId: number, lang: string): Promise<Product[]> {
|
||||
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
||||
if (!result.ok) return []
|
||||
return result.data.filter((product) => product.id !== excludeId).slice(0, 6)
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
|
||||
const { id, lang } = await params
|
||||
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 = resolveCategoryName(product.category, lang)
|
||||
const description = resolveLocalized(product.description, lang)
|
||||
|
||||
return {
|
||||
title: `${productName} ${categoryName ? `| ${categoryName}` : ''} | HomAroma`,
|
||||
description: description || `Premium ${categoryName} from HomAroma.`,
|
||||
openGraph: {
|
||||
title: productName,
|
||||
description,
|
||||
images: product.images?.[0]?.url ? [product.images[0].url] : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
|
||||
const { id, lang } = await params
|
||||
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 (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
|
||||
console.error(`[website-cms] product-detail-layout unavailable: ${cmsLayoutResult.error}`)
|
||||
}
|
||||
|
||||
if (!productResult.ok) {
|
||||
if (productResult.status === 404) notFound()
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const product = productResult.data
|
||||
const related = await getRelated(product.category.id, product.id, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const categoryName = resolveCategoryName(product.category, 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 →
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
// lang is extracted from params
|
||||
export const metadata = {
|
||||
title: 'โปรไฟล์สมาชิก | HomAroma',
|
||||
description: 'จัดการโปรไฟล์สมาชิก HomAroma ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง',
|
||||
}
|
||||
|
||||
export default async function ProfilePage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
|
||||
// lang is extracted from params
|
||||
export const metadata = {
|
||||
title: 'Scent Guide | HomAroma',
|
||||
description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.',
|
||||
}
|
||||
|
||||
export default async function ScentGuidePage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
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}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<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="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 gap-8 md:grid-cols-3">
|
||||
{articles.map(article => (
|
||||
<Link
|
||||
key={article.id}
|
||||
href={`/article/${article.slug}`}
|
||||
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] shrink-0 overflow-hidden bg-pop-bg">
|
||||
{article.coverImage ? (
|
||||
<img src={article.coverImage} alt={article.title} className="h-full w-full object-cover transition-all duration-500 group-hover:scale-105" />
|
||||
) : (
|
||||
<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="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 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Search Products | HomAroma',
|
||||
description: 'Search our collection of premium aroma products by scent or product name.',
|
||||
}
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import ProductCard from '@/components/ProductCard'
|
||||
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
import { resolveLocalized } from '@/lib/localization'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search Products | HomAroma',
|
||||
description: 'Search HomAroma products by scent, category, or keyword.',
|
||||
}
|
||||
|
||||
export default async function SearchPage({ searchParams, params }: { searchParams: Promise<{ q?: string | string[] }>, params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
const sParams = await searchParams
|
||||
const rawQuery = Array.isArray(sParams.q) ? sParams.q[0] : sParams.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 : []
|
||||
|
||||
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
|
||||
console.error(`[website-cms] search-layout unavailable: ${cmsLayoutResult.error}`)
|
||||
}
|
||||
|
||||
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="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>
|
||||
)}
|
||||
|
||||
<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 "${query}"`
|
||||
: `No results for "${query}"`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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 →
|
||||
</Link>
|
||||
</div>
|
||||
) : !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="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 →
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Shop All Products | HomAroma',
|
||||
description: 'Browse our full range of premium aroma products — crafted to elevate every space.',
|
||||
}
|
||||
|
||||
export default function ShopLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import ProductCard from '@/components/ProductCard'
|
||||
import ShopCouponBridge from '@/components/ShopCouponBridge'
|
||||
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
import { resolveLocalized } from '@/lib/localization'
|
||||
|
||||
// lang is extracted from params
|
||||
export const metadata = {
|
||||
title: 'Shop Aroma Products | HomAroma',
|
||||
description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.',
|
||||
}
|
||||
|
||||
export default async function ShopPage({ searchParams, params }: { searchParams: Promise<{ coupon?: string | string[] }>, params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
const sParams = await searchParams
|
||||
const coupon = Array.isArray(sParams.coupon) ? sParams.coupon[0] : sParams.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
|
||||
: ''
|
||||
|
||||
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
|
||||
console.error(`[website-cms] shop-layout unavailable: ${cmsLayoutResult.error}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<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="mx-auto max-w-7xl space-y-10 px-6 py-12">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<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>
|
||||
|
||||
<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="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 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MapPin } from 'lucide-react'
|
||||
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import TrackOrderClient from '@/components/TrackOrderClient'
|
||||
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
|
||||
// lang is extracted from params
|
||||
export const metadata = {
|
||||
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
||||
description: 'ตรวจสอบสถานะคำสั่งซื้อ HomAroma ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ',
|
||||
}
|
||||
|
||||
export default async function TrackPage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
// lang is extracted from params
|
||||
export const metadata = {
|
||||
title: 'Wishlist | HomAroma',
|
||||
description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ HomAroma',
|
||||
}
|
||||
|
||||
export default async function WishlistPage({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params
|
||||
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've loved. Keep them here until you'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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user