From ea9f33f60ea91e64f3a1dfedd19f949818ca6c4b Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 17 Jun 2026 07:42:59 -0400 Subject: [PATCH] Full website --- apps/website/src/app/[slug]/page.tsx | 96 +++ apps/website/src/app/about/page.tsx | 85 +- apps/website/src/app/article/[slug]/page.tsx | 37 +- apps/website/src/app/category/[id]/page.tsx | 240 +++--- apps/website/src/app/checkout/page.tsx | 58 ++ apps/website/src/app/contact/page.tsx | 78 +- apps/website/src/app/coupons/page.tsx | 192 +++-- apps/website/src/app/faq/page.tsx | 42 +- apps/website/src/app/layout.tsx | 4 +- apps/website/src/app/order/[id]/page.tsx | 198 +++++ apps/website/src/app/page.tsx | 181 +---- apps/website/src/app/products/[id]/page.tsx | 178 ++-- apps/website/src/app/profile/page.tsx | 58 ++ apps/website/src/app/scent-guide/page.tsx | 82 +- apps/website/src/app/search/page.tsx | 204 +++-- apps/website/src/app/shop/page.tsx | 210 ++--- apps/website/src/app/track/page.tsx | 59 +- apps/website/src/app/wishlist/page.tsx | 58 ++ .../src/components/AddToCartSection.tsx | 158 ++-- apps/website/src/components/CartDrawer.tsx | 98 +-- .../website/src/components/CheckoutClient.tsx | 765 ++++++++++++++++++ apps/website/src/components/Footer.tsx | 47 +- apps/website/src/components/Navbar.tsx | 276 +++---- apps/website/src/components/ProductCard.tsx | 106 +-- apps/website/src/components/ProfileClient.tsx | 613 ++++++++++++++ .../src/components/ShopCouponBridge.tsx | 21 + .../src/components/TrackOrderClient.tsx | 183 +++++ .../website/src/components/WishlistButton.tsx | 34 + .../website/src/components/WishlistClient.tsx | 39 + .../dynamic/DynamicPageRenderer.tsx | 567 +++++++++++++ apps/website/src/lib/cms-sections.ts | 21 + apps/website/src/lib/cms.ts | 123 +++ apps/website/src/lib/localization.ts | 16 + apps/website/src/locales/en/translation.json | 1 + apps/website/src/locales/th/translation.json | 1 + apps/website/src/store/authStore.ts | 34 + apps/website/src/store/wishlistStore.ts | 47 ++ docs/website-cms-parity-plan.md | 151 ++++ docs/website-route-parity-audit.md | 43 + implementation_plan.md | 301 +++++++ 40 files changed, 4503 insertions(+), 1202 deletions(-) create mode 100644 apps/website/src/app/[slug]/page.tsx create mode 100644 apps/website/src/app/checkout/page.tsx create mode 100644 apps/website/src/app/order/[id]/page.tsx create mode 100644 apps/website/src/app/profile/page.tsx create mode 100644 apps/website/src/app/wishlist/page.tsx create mode 100644 apps/website/src/components/CheckoutClient.tsx create mode 100644 apps/website/src/components/ProfileClient.tsx create mode 100644 apps/website/src/components/ShopCouponBridge.tsx create mode 100644 apps/website/src/components/TrackOrderClient.tsx create mode 100644 apps/website/src/components/WishlistButton.tsx create mode 100644 apps/website/src/components/WishlistClient.tsx create mode 100644 apps/website/src/components/dynamic/DynamicPageRenderer.tsx create mode 100644 apps/website/src/lib/cms-sections.ts create mode 100644 apps/website/src/lib/cms.ts create mode 100644 apps/website/src/lib/localization.ts create mode 100644 apps/website/src/store/authStore.ts create mode 100644 apps/website/src/store/wishlistStore.ts create mode 100644 docs/website-cms-parity-plan.md create mode 100644 docs/website-route-parity-audit.md create mode 100644 implementation_plan.md diff --git a/apps/website/src/app/[slug]/page.tsx b/apps/website/src/app/[slug]/page.tsx new file mode 100644 index 0000000..eda58ba --- /dev/null +++ b/apps/website/src/app/[slug]/page.tsx @@ -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 { + 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 ( +
+
+

CMS Page Error

+

+ ไม่สามารถโหลดหน้านี้ได้ +

+

{pageResult.error}

+
+
+ ) + } + + 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 ( +
+ {sections.length > 0 ? ( + + ) : ( +
+

HomAroma

+

{page.title}

+

+ หน้านี้ถูกเผยแพร่แล้ว แต่ยังไม่มี section ที่เปิดใช้งานใน Page Builder +

+
+ )} +
+ ) +} diff --git a/apps/website/src/app/about/page.tsx b/apps/website/src/app/about/page.tsx index 819391c..3cec1cc 100644 --- a/apps/website/src/app/about/page.tsx +++ b/apps/website/src/app/about/page.tsx @@ -32,91 +32,82 @@ export const metadata = { export default function AboutPage() { return ( -
- {/* Hero */} -
-
-
-
-

Our Story

-

- Born from a passion for stillness +
+
+
+
+

Our Story

+

+ Born from a passion for stillness

-

+

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.

- {/* Stats Band */} -
-
+
+
{STATS.map(s => (
-
{s.value}
-
{s.label}
+
{s.value}
+
{s.label}
))}
- {/* Mission Section */} -
-
+
+
-

Our Mission

-

Transforming spaces into sanctuaries of peace

-

+

Our Mission

+

Transforming spaces into sanctuaries of peace

+

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.

-

+

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.

-
-
+
+
-
+
🌿
-

HomAroma Studio, Bangkok

+

HomAroma Studio, Bangkok

- {/* Three Pillars */} -
-
-
-

What We Stand For

-

Three Pillars

-
+
+
+
+

What We Stand For

+

Three Pillars

+
-
+
{PILLARS.map(p => ( -
+
{p.icon}
-

{p.title}

-

{p.desc}

+

{p.title}

+

{p.desc}

))}
- {/* CTA */} -
-

Ready to find your scent?

-

Let our Scent Guide help you discover the perfect blend for your moment.

-
- +
+

Ready to find your scent?

+

Let our Scent Guide help you discover the perfect blend for your moment.

+
+ Take the Scent Guide - + Shop All Products
diff --git a/apps/website/src/app/article/[slug]/page.tsx b/apps/website/src/app/article/[slug]/page.tsx index 97c6efe..5ac189a 100644 --- a/apps/website/src/app/article/[slug]/page.tsx +++ b/apps/website/src/app/article/[slug]/page.tsx @@ -36,43 +36,40 @@ export default async function ArticleReaderPage({ params }: { params: Promise<{ if (!article) notFound() return ( -
-
-
- - {/* Desktop back button */} +
+
+
- + - {/* Header */} -
- - Go Back +
+ + Go Back -

+

{new Date(article.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}

-

+

{article.title}

- {/* Cover Image */} {article.coverImage && ( -
- {article.title} +
+ {article.title}
)} - {/* Content Body */} -
-
+
+
-
diff --git a/apps/website/src/app/category/[id]/page.tsx b/apps/website/src/app/category/[id]/page.tsx index d8317b8..622d077 100644 --- a/apps/website/src/app/category/[id]/page.tsx +++ b/apps/website/src/app/category/[id]/page.tsx @@ -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([]) - const [category, setCategory] = useState(null) - const [products, setProducts] = useState([]) - const [displayCount, setDisplayCount] = useState(24) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const loaderRef = useRef(null) +async function getCategoryProducts(id: string): Promise> { + return await fetchJsonResult(`/categories/${id}/products?limit=1000&lang=${lang}`, { + next: { revalidate: 60 }, + }) +} - useEffect(() => { - window.scrollTo({ top: 0, behavior: 'smooth' }) - setDisplayCount(24) - }, [id]) +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + const result = await getCategoryProducts(id) + const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : '' - useEffect(() => { - if (!id) return - setLoading(true) - setError(null) - Promise.all([ - fetch(`${API_BASE}/categories/${id}/products?limit=1000`), - fetch(`${API_BASE}/categories`), - ]) - .then(async ([catRes, allCatsRes]) => { - if (!catRes.ok) throw new Error('Category not found') - const catPaginated = await catRes.json() - const allCats: Category[] = await allCatsRes.json() - setCategory(catPaginated.category) - setProducts(catPaginated.data || []) - setCategories(allCats) - }) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)) - }, [id]) + return { + title: categoryName ? `${categoryName} | HomAroma` : 'Category | HomAroma', + description: categoryName ? `Shop ${categoryName} products from HomAroma.` : 'Shop HomAroma category products.', + } +} - 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 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) - return ( -
- {/* Hero Banner */} -
- {typeof -
-
-
-
- Home - / - Shop - / - {typeof category?.name === 'string' ? category.name : (category?.name as any)?.th || (category?.name as any)?.en || '...'} -
-

- {loading ?
: typeof category?.name === 'string' ? category.name : (category?.name as any)?.th || (category?.name as any)?.en || ''} -

- {!loading && category?._count && ( -

{category._count.products} products available

- )} + if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) { + console.error(`[website-cms] category-layout unavailable: ${cmsLayoutResult.error}`) + } + + if (!categoryProductsResult.ok) { + if (categoryProductsResult.status === 404) notFound() + + return ( +
+ {announcementSections.length > 0 && ( + + )} +
+
+

Unable to load this category

+

{categoryProductsResult.error}

+ Back to Shop
+ ) + } - {/* Products Grid */} -
+ if (!categoryProductsResult.data.category) notFound() + + const category = categoryProductsResult.data.category + const products = categoryProductsResult.data.data ?? [] + const categoryName = resolveLocalized(category.name, lang) + + return ( +
+ {announcementSections.length > 0 && ( + + )} + {heroSections.length > 0 ? ( + + ) : ( +
+ {categoryName} +
+
+
+ Home + / + Shop + / + {categoryName} +
+

{categoryName}

+ {category._count &&

{category._count.products} products available

} +
+
+ )} + +
- {categories.map((cat) => ( - - {typeof cat.name === 'string' ? cat.name : (cat.name as any)?.th || (cat.name as any)?.en || ''} - - ))} + {!categoriesResult.ok && ( + + Categories unavailable + + )} + {categories.map((item) => { + const name = resolveLocalized(item.name, lang) + const isActive = item.id === category.id + + return ( + + {name} + + ) + })}
- {error ? ( -
-

⚠️

-

{error}

- Back to Shop -
- ) : loading ? ( -
- {Array.from({ length: 9 }).map((_, i) => ( -
- ))} -
- ) : products.length === 0 ? ( -
-

🌿

-

No products in this category yet.

+ {products.length === 0 ? ( +
+

No products in this category yet.

+ Back to Shop
) : ( -
-
- {products.slice(0, displayCount).map((p) => ( - - ))} -
- {displayCount < products.length && ( -
-
-
- )} +
+ {products.map((product) => )}
)}
+ {bottomSections.length > 0 && ( + + )}
) } diff --git a/apps/website/src/app/checkout/page.tsx b/apps/website/src/app/checkout/page.tsx new file mode 100644 index 0000000..ebfcf52 --- /dev/null +++ b/apps/website/src/app/checkout/page.tsx @@ -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 ( +
+ {announcementSections.length > 0 && ( + + )} + + {heroSections.length > 0 ? ( + + ) : ( +
+
+ +
+

Secure Checkout

+

ยืนยันคำสั่งซื้อ

+

+ ตรวจสอบรายการสินค้า ใช้คูปองหรือคะแนนสะสม และสร้างคำสั่งซื้อเพื่อไปยังหน้ารายละเอียดออเดอร์ +

+
+ )} + +
+ +
+ + {bottomSections.length > 0 && ( + + )} +
+ ) +} diff --git a/apps/website/src/app/contact/page.tsx b/apps/website/src/app/contact/page.tsx index 4de3021..e9446da 100644 --- a/apps/website/src/app/contact/page.tsx +++ b/apps/website/src/app/contact/page.tsx @@ -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({ name: '', email: '', subject: '', message: '' }) const [status, setStatus] = useState('idle') @@ -24,19 +22,17 @@ export default function ContactPage() { } return ( -
- {/* Header */} -
-

Get in Touch

-

Contact Us

-

Have a question, custom request, or partnership idea? We'd love to hear from you.

+
+
+

Get in Touch

+

Contact Us

+

Have a question, custom request, or partnership idea? We'd love to hear from you.

-
- {/* Contact Info */} -
+
+
-

Reach Us Directly

+

Reach Us Directly

{[ { 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 => ( - -
{item.icon}
+
+
{item.icon}
-

{item.label}

-

{item.value}

+

{item.label}

+

{item.value}

))}
-
-

Response Time

+ +
+

Response Time

{[['Email', '~2 hours'], ['LINE', '~30 minutes'], ['Instagram DM', '~4 hours']].map(([k, v]) => (
- {k} - {v} + {k} + {v}
))}
-

Mon–Sat, 9:00–18:00 (ICT)

+

Mon-Sat, 9:00-18:00 (ICT)

- {/* Contact Form */}
{status === 'sent' ? ( -
-
-
✉️
-

Message Sent!

-

Thank you for reaching out. We'll get back to you within 2 hours.

-
) : ( -
-
+ +
- + + 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" />
- + + 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" />
- +
- +