This commit is contained in:
Your Name committed 2026-06-21 01:52:57 -04:00
1 parent c449de2482
commit 7e4e89e33c
50 files changed
+306 -140

No files matched your search

+62 -2
View File
@@ -189,11 +189,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}, },
{ {
name: "update_store_settings_translation", name: "update_store_settings_translation",
description: "Add or update translations for store settings.", description: "Update translated footer and newsletter text for a specific language.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
lang: { type: "string", description: "Language code" }, lang: { type: "string" },
footerDescription: { type: "string" }, footerDescription: { type: "string" },
newsletterTitle: { type: "string" }, newsletterTitle: { type: "string" },
newsletterDescription: { type: "string" }, newsletterDescription: { type: "string" },
@@ -203,6 +203,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["lang"] required: ["lang"]
} }
}, },
{
name: "update_theme_tokens",
description: "Update the theme tokens (colors, fonts, shadows) in the store settings.",
inputSchema: {
type: "object",
properties: {
brandPrimary: { type: "string" },
brandPrimaryHover: { type: "string" },
brandAccent: { type: "string" },
brandWarm: { type: "string" },
brandCool: { type: "string" },
brandSuccess: { type: "string" },
brandSoft: { type: "string" },
surfacePage: { type: "string" },
surfaceNavRest: { type: "string" },
surfaceCard: { type: "string" },
textMain: { type: "string" },
mutedText: { type: "string" },
linePrimary: { type: "string" },
linePrimaryHover: { type: "string" },
fontBody: { type: "string" },
fontDisplay: { type: "string" },
shadowCard: { type: "string" },
shadowCardHover: { type: "string" }
}
}
},
{ {
name: "update_brand_name", name: "update_brand_name",
description: "Update the brand name used across website and SPA headers, footers, and brand displays.", description: "Update the brand name used across website and SPA headers, footers, and brand displays.",
@@ -523,6 +550,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.patch(`/settings/translations`, payload); const res = await api.patch(`/settings/translations`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "update_theme_tokens": {
const tokenParams = params as Record<string, string>;
const currentSettingsRes = await api.get("/settings");
const currentSettings = currentSettingsRes.data || {};
const currentTokens = currentSettings.themeTokens || {};
const newTokens = { ...currentTokens };
const validKeys = [
"brandPrimary", "brandPrimaryHover", "brandAccent", "brandWarm",
"brandCool", "brandSuccess", "brandSoft", "surfacePage",
"surfaceNavRest", "surfaceCard", "textMain", "mutedText",
"linePrimary", "linePrimaryHover", "fontBody", "fontDisplay",
"shadowCard", "shadowCardHover"
];
for (const key of validKeys) {
if (tokenParams[key] !== undefined) {
newTokens[key] = tokenParams[key];
}
}
const mergedSettings = { ...currentSettings, themeTokens: newTokens };
for (const key in mergedSettings) {
if (mergedSettings[key] === null) {
delete mergedSettings[key];
}
}
const res = await api.put("/settings", mergedSettings);
return { content: [{ type: "text", text: "Theme tokens updated successfully:\\n" + JSON.stringify(res.data.themeTokens, null, 2) }] };
}
case "update_brand_name": { case "update_brand_name": {
const { brandName } = params as any; const { brandName } = params as any;
const currentSettingsRes = await api.get("/settings"); const currentSettingsRes = await api.get("/settings");
+2
View File
@@ -19,6 +19,7 @@ import WishlistPage from './pages/WishlistPage'
import CustomPage from './pages/CustomPage' import CustomPage from './pages/CustomPage'
import ProfilePage from './pages/ProfilePage' import ProfilePage from './pages/ProfilePage'
import CartDrawer from './components/CartDrawer' import CartDrawer from './components/CartDrawer'
import ThemeInjector from './components/ThemeInjector'
function App() { function App() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
@@ -39,6 +40,7 @@ function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<ThemeInjector />
<ScrollToTop /> <ScrollToTop />
<CartDrawer /> <CartDrawer />
<Routes> <Routes>
+4 -4
View File
@@ -68,7 +68,7 @@ export default function CartDrawer() {
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-white border border-spa-stone/10 shadow-sm group relative"> <div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-white border border-spa-stone/10 shadow-sm group relative">
<div className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}> <div className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
<img <img
src={item.productImage || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/oil_ozone.png`} src={item.productImage || '/images/fallback-product.svg'}
alt={item.productName} alt={item.productName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -151,7 +151,7 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
const code = localStorage.getItem('homaroma_active_coupon') const code = localStorage.getItem('app_active_coupon')
setSavedCoupon(code) setSavedCoupon(code)
if (code && subtotal > 0) { if (code && subtotal > 0) {
fetch(`${API_BASE}/coupons/validate`, { fetch(`${API_BASE}/coupons/validate`, {
@@ -174,7 +174,7 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
}, [subtotal, activePromos]) }, [subtotal, activePromos])
const removeCoupon = () => { const removeCoupon = () => {
localStorage.removeItem('homaroma_active_coupon') localStorage.removeItem('app_active_coupon')
setSavedCoupon(null) setSavedCoupon(null)
setDiscountAmount(0) setDiscountAmount(0)
setIsFreeShipping(false) setIsFreeShipping(false)
@@ -190,7 +190,7 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
// We must validate with subtotalAfterL2, but since we don't have it defined here yet, // We must validate with subtotalAfterL2, but since we don't have it defined here yet,
// we'll calculate it inline or rely on the effect. Actually, we can just save it to localStorage and let the effect handle it. // we'll calculate it inline or rely on the effect. Actually, we can just save it to localStorage and let the effect handle it.
localStorage.setItem('homaroma_active_coupon', couponInput.toUpperCase()) localStorage.setItem('app_active_coupon', couponInput.toUpperCase())
setSavedCoupon(couponInput.toUpperCase()) setSavedCoupon(couponInput.toUpperCase())
setCouponInput('') setCouponInput('')
setCouponLoading(false) setCouponLoading(false)
@@ -60,7 +60,7 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
/> />
<div className="absolute bottom-6 left-6 right-6 z-20 text-white"> <div className="absolute bottom-6 left-6 right-6 z-20 text-white">
<span className="mb-1 block text-xl font-semibold"> <span className="mb-1 block text-xl font-semibold">
{getLocalizedField(cat.name, localStorage.getItem('homaroma_lang') || 'en')} {getLocalizedField(cat.name, localStorage.getItem('app_lang') || 'en')}
</span> </span>
{cat._count && ( {cat._count && (
<span className="text-sm font-medium text-white/80"> <span className="text-sm font-medium text-white/80">
+2 -2
View File
@@ -24,7 +24,7 @@ export default function Footer({ categories = [] }: FooterProps) {
const brandName = settings?.brandName || '' const brandName = settings?.brandName || ''
const translateMenuLabel = (item: MenuItemWithTranslations) => { const translateMenuLabel = (item: MenuItemWithTranslations) => {
const lang = localStorage.getItem('homaroma_lang') || 'en' const lang = localStorage.getItem('app_lang') || 'en'
if (lang !== 'en' && item.translations && item.translations.length > 0) { if (lang !== 'en' && item.translations && item.translations.length > 0) {
const trans = item.translations.find((translation) => translation.langCode === lang) const trans = item.translations.find((translation) => translation.langCode === lang)
if (trans && trans.label) { if (trans && trans.label) {
@@ -128,7 +128,7 @@ export default function Footer({ categories = [] }: FooterProps) {
<div className="flex flex-col space-y-2 text-sm text-slate-400"> <div className="flex flex-col space-y-2 text-sm text-slate-400">
{categories.map((c) => ( {categories.map((c) => (
<Link key={c.id} to={`/category/${c.id}`} className="hover:text-white transition-colors"> <Link key={c.id} to={`/category/${c.id}`} className="hover:text-white transition-colors">
{getLocalizedField(c.name, localStorage.getItem('homaroma_lang') || 'en')} {getLocalizedField(c.name, localStorage.getItem('app_lang') || 'en')}
</Link> </Link>
))} ))}
</div> </div>
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
const changeLanguage = (code: string) => { const changeLanguage = (code: string) => {
i18n.changeLanguage(code) i18n.changeLanguage(code)
localStorage.setItem('homaroma_lang', code) localStorage.setItem('app_lang', code)
setLangSheetOpen(false) setLangSheetOpen(false)
} }
+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from 'react'
import { API_BASE } from '@homaroma/types'
export default function ThemeInjector() {
useEffect(() => {
const lang = localStorage.getItem('app_lang') || 'en'
fetch(`${API_BASE}/settings?lang=${lang}`)
.then(res => res.ok ? res.json() : null)
.then(settings => {
const tokens = settings?.themeTokens
if (!tokens || typeof tokens !== 'object') return
const root = document.documentElement
// Map tokens from the backend (which use brandPrimary etc.)
// to the CSS variables expected by SPA's tailwind.config.js
const mapping: Record<string, string> = {
'--color-spa-light': tokens.surfacePage,
'--color-spa-sage': tokens.brandSoft,
'--color-spa-stone': tokens.mutedText,
'--color-spa-forest': tokens.brandPrimary,
'--color-spa-gold': tokens.brandAccent,
'--color-pop-pink': tokens.brandPrimary,
'--color-pop-yellow': tokens.brandAccent,
'--color-pop-orange': tokens.brandWarm,
'--color-pop-cyan': tokens.brandCool,
'--color-pop-bg': tokens.surfacePage,
'--color-pop-black': tokens.textMain,
'--color-night-900': tokens.surfaceCard,
}
for (const [key, value] of Object.entries(mapping)) {
if (value && typeof value === 'string' && value.trim() !== '') {
root.style.setProperty(key, value)
}
}
})
.catch(err => console.error('Failed to inject theme in SPA:', err))
}, [])
return null
}
+1 -1
View File
@@ -9,7 +9,7 @@ import viTranslation from "./locales/vi/translation.json"
import koTranslation from "./locales/ko/translation.json" import koTranslation from "./locales/ko/translation.json"
// Retrieve default lang from localStorage if set // Retrieve default lang from localStorage if set
const savedLang = localStorage.getItem('homaroma_lang') || 'th' const savedLang = localStorage.getItem('app_lang') || 'th'
i18n i18n
.use(initReactI18next) .use(initReactI18next)
+2 -2
View File
@@ -136,7 +136,7 @@ export default function CategoryPage() {
<span>/</span> <span>/</span>
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link> <Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
<span>/</span> <span>/</span>
<span className="text-white">{typeof category?.name === 'string' ? category.name : (category?.name as any)?.[localStorage.getItem('homaroma_lang') || 'en'] || (category?.name as any)?.en || '...'}</span> <span className="text-white">{typeof category?.name === 'string' ? category.name : (category?.name as any)?.[localStorage.getItem('app_lang') || 'en'] || (category?.name as any)?.en || '...'}</span>
</div> </div>
<h1 className="text-4xl md:text-5xl font-bold text-white"> <h1 className="text-4xl md:text-5xl font-bold text-white">
{loading ? ( {loading ? (
@@ -169,7 +169,7 @@ export default function CategoryPage() {
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm' : 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`} }`}
> >
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.[localStorage.getItem('homaroma_lang') || 'en'] || (cat.name as any)?.en || ''} {typeof cat.name === 'string' ? cat.name : (cat.name as any)?.[localStorage.getItem('app_lang') || 'en'] || (cat.name as any)?.en || ''}
</Link> </Link>
))} ))}
</div> </div>
+6 -6
View File
@@ -127,7 +127,7 @@ export default function CheckoutPage() {
.catch(console.error) .catch(console.error)
// Check if there's an active coupon saved in localStorage // Check if there's an active coupon saved in localStorage
const savedCoupon = localStorage.getItem('homaroma_active_coupon') const savedCoupon = localStorage.getItem('app_active_coupon')
if (savedCoupon) { if (savedCoupon) {
setCouponCode(savedCoupon) setCouponCode(savedCoupon)
// We will trigger handleApplyCoupon but we need to wait for items/subtotal to be ready. // We will trigger handleApplyCoupon but we need to wait for items/subtotal to be ready.
@@ -174,7 +174,7 @@ export default function CheckoutPage() {
// Auto-apply saved coupon if subtotal is ready // Auto-apply saved coupon if subtotal is ready
useEffect(() => { useEffect(() => {
const savedCoupon = localStorage.getItem('homaroma_active_coupon') const savedCoupon = localStorage.getItem('app_active_coupon')
if (savedCoupon && subtotal > 0 && !appliedCoupon && !couponError && !couponLoading) { if (savedCoupon && subtotal > 0 && !appliedCoupon && !couponError && !couponLoading) {
setCouponCode(savedCoupon) setCouponCode(savedCoupon)
// trigger fetch to validate // trigger fetch to validate
@@ -190,7 +190,7 @@ export default function CheckoutPage() {
setDiscountAmount(data.discountAmount) setDiscountAmount(data.discountAmount)
setIsFreeShipping(data.isFreeShipping) setIsFreeShipping(data.isFreeShipping)
} else { } else {
localStorage.removeItem('homaroma_active_coupon') // Remove invalid localStorage.removeItem('app_active_coupon') // Remove invalid
} }
}) })
.catch(console.error) .catch(console.error)
@@ -254,7 +254,7 @@ export default function CheckoutPage() {
setDiscountAmount(0) setDiscountAmount(0)
setIsFreeShipping(false) setIsFreeShipping(false)
setCouponError('') setCouponError('')
localStorage.removeItem('homaroma_active_coupon') localStorage.removeItem('app_active_coupon')
} }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -309,10 +309,10 @@ export default function CheckoutPage() {
if (res.ok) { if (res.ok) {
clearCart() clearCart()
const existingStr = localStorage.getItem('homaroma_local_orders') const existingStr = localStorage.getItem('app_local_orders')
const existing = existingStr ? JSON.parse(existingStr) : [] const existing = existingStr ? JSON.parse(existingStr) : []
if (!existing.includes(responseData.id)) { if (!existing.includes(responseData.id)) {
localStorage.setItem('homaroma_local_orders', JSON.stringify([responseData.id, ...existing])) localStorage.setItem('app_local_orders', JSON.stringify([responseData.id, ...existing]))
} }
navigate(`/order/${responseData.id}`) navigate(`/order/${responseData.id}`)
+1 -1
View File
@@ -82,7 +82,7 @@ export default function CouponsPage() {
} }
const handleUseNow = (code: string) => { const handleUseNow = (code: string) => {
localStorage.setItem('homaroma_active_coupon', code) localStorage.setItem('app_active_coupon', code)
navigate('/shop') navigate('/shop')
} }
+1 -1
View File
@@ -264,7 +264,7 @@ export default function OrderSuccessPage() {
{order.items.map(item => ( {order.items.map(item => (
<div key={item.id} className="flex items-center gap-4 text-sm"> <div key={item.id} className="flex items-center gap-4 text-sm">
<div className="w-12 h-12 rounded-lg bg-spa-stone/10 flex-shrink-0 overflow-hidden border border-spa-stone/10"> <div className="w-12 h-12 rounded-lg bg-spa-stone/10 flex-shrink-0 overflow-hidden border border-spa-stone/10">
<img src={item.productImage || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/oil_ozone.png`} alt="Product" className="w-full h-full object-cover" /> <img src={item.productImage || '/images/fallback-product.svg'} alt="Product" className="w-full h-full object-cover" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className="font-bold text-pop-black">{item.productName}</p> <p className="font-bold text-pop-black">{item.productName}</p>
+1 -1
View File
@@ -504,7 +504,7 @@ export default function ProfilePage() {
{order.items.map(item => ( {order.items.map(item => (
<div key={item.id} className="flex items-center gap-3 text-xs"> <div key={item.id} className="flex items-center gap-3 text-xs">
<div className="w-10 h-10 rounded-md bg-spa-stone/10 overflow-hidden border border-spa-stone/15 flex-shrink-0"> <div className="w-10 h-10 rounded-md bg-spa-stone/10 overflow-hidden border border-spa-stone/15 flex-shrink-0">
<img src={item.productImage || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/oil_ozone.png`} alt="Product" className="w-full h-full object-cover" /> <img src={item.productImage || '/images/fallback-product.svg'} alt="Product" className="w-full h-full object-cover" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-bold text-pop-black truncate">{item.productName}</p> <p className="font-bold text-pop-black truncate">{item.productName}</p>
+1 -1
View File
@@ -32,7 +32,7 @@ export default function ShopPage() {
const params = new URLSearchParams(location.search) const params = new URLSearchParams(location.search)
const coupon = params.get('coupon') const coupon = params.get('coupon')
if (coupon) { if (coupon) {
localStorage.setItem('homaroma_active_coupon', coupon) localStorage.setItem('app_active_coupon', coupon)
// Optionally clean URL // Optionally clean URL
window.history.replaceState({}, document.title, window.location.pathname) window.history.replaceState({}, document.title, window.location.pathname)
} }
+2 -2
View File
@@ -64,7 +64,7 @@ export default function TrackOrderPage() {
.finally(() => setHistoryLoading(false)) .finally(() => setHistoryLoading(false))
} else { } else {
// Load local orders if not logged in // Load local orders if not logged in
const storedStr = localStorage.getItem('homaroma_local_orders') const storedStr = localStorage.getItem('app_local_orders')
if (storedStr) { if (storedStr) {
setLocalOrders(JSON.parse(storedStr)) setLocalOrders(JSON.parse(storedStr))
} }
@@ -92,7 +92,7 @@ export default function TrackOrderPage() {
if (!localOrders.includes(data.orderId)) { if (!localOrders.includes(data.orderId)) {
const newOrders = [data.orderId, ...localOrders] const newOrders = [data.orderId, ...localOrders]
setLocalOrders(newOrders) setLocalOrders(newOrders)
localStorage.setItem('homaroma_local_orders', JSON.stringify(newOrders)) localStorage.setItem('app_local_orders', JSON.stringify(newOrders))
} }
navigate(`/order/${data.orderId}`) navigate(`/order/${data.orderId}`)
} else { } else {
+1 -1
View File
@@ -31,7 +31,7 @@ export const useAuthStore = create<AuthState>()(
logout: () => set({ token: null, customer: null }), logout: () => set({ token: null, customer: null }),
}), }),
{ {
name: "homaroma-auth-storage", name: "app-auth-storage",
partialize: (state) => ({ token: state.token, customer: state.customer }), // don't persist isLiffInitialized partialize: (state) => ({ token: state.token, customer: state.customer }), // don't persist isLiffInitialized
} }
) )
+1 -1
View File
@@ -71,7 +71,7 @@ export const useCartStore = create<CartState>()(
} }
}), }),
{ {
name: 'homaroma-cart-storage', name: 'app-cart-storage',
partialize: (state) => ({ items: state.items }), // Only persist items, not UI state partialize: (state) => ({ items: state.items }), // Only persist items, not UI state
} }
) )
+1 -1
View File
@@ -27,7 +27,7 @@ export const useWishlistStore = create<WishlistState>()(
} }
}), }),
{ {
name: 'homaroma-wishlist', name: 'app-wishlist',
} }
) )
) )
+16 -16
View File
@@ -16,28 +16,28 @@ export default {
}, },
colors: { colors: {
pop: { pop: {
pink: "#FF6B6B", pink: "var(--color-pop-pink, #FF6B6B)",
yellow: "#FFF200", yellow: "var(--color-pop-yellow, #FFF200)",
orange: "#FFA07A", orange: "var(--color-pop-orange, #FFA07A)",
red: "#FF4500", red: "var(--color-pop-red, #FF4500)",
cyan: "#E0FFFF", cyan: "var(--color-pop-cyan, #E0FFFF)",
green: "#98FB98", green: "var(--color-pop-green, #98FB98)",
purple: "#D8BFD8", purple: "var(--color-pop-purple, #D8BFD8)",
bg: "#FFF0F5", bg: "var(--color-pop-bg, #FFF0F5)",
black: "#333333", black: "var(--color-pop-black, #333333)",
}, },
night: { night: {
900: "#111827", 900: "var(--color-night-900, #111827)",
}, },
cyan: { cyan: {
glow: "#06b6d4", glow: "var(--color-cyan-glow, #06b6d4)",
}, },
spa: { spa: {
light: "#FDFDF9", // Off-white/Cream light: "var(--color-spa-light, #FDFDF9)", // Off-white/Cream
sage: "#EAEFEA", // Soft Sage Green sage: "var(--color-spa-sage, #EAEFEA)", // Soft Sage Green
stone: "#4B4B46", // Dark Stone Gray stone: "var(--color-spa-stone, #4B4B46)", // Dark Stone Gray
forest: "#1F3D2D", // Deep Forest Green forest: "var(--color-spa-forest, #1F3D2D)", // Deep Forest Green
gold: "#D4AF37", // Elegant Gold gold: "var(--color-spa-gold, #D4AF37)", // Elegant Gold
} }
}, },
boxShadow: { boxShadow: {
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Page Not Found | HomAroma', title: 'Page Not Found',
} }
export default function NestedLocaleNotFound() { export default function NestedLocaleNotFound() {
+5 -5
View File
@@ -34,14 +34,14 @@ function isReservedSlug(slug: string) {
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params const { slug, lang } = await params
if (isReservedSlug(slug)) return { title: 'Page Not Found | HomAroma' } if (isReservedSlug(slug)) return { title: 'Page Not Found' }
const pageResult = await getCmsPageResult(slug, lang) const pageResult = await getCmsPageResult(slug, lang)
if (!pageResult.ok || !pageResult.data.isPublished) return { title: 'Page Not Found | HomAroma' } if (!pageResult.ok || !pageResult.data.isPublished) return { title: 'Page Not Found' }
return generateSeoMetadata({ return generateSeoMetadata({
title: `${pageResult.data.title} | HomAroma`, title: `${pageResult.data.title}`,
description: `HomAroma - ${pageResult.data.title}`, description: `${pageResult.data.title}`,
path: `/${slug}`, path: `/${slug}`,
lang, lang,
}) })
@@ -100,7 +100,7 @@ export default async function CmsCustomPage({ params }: { params: Promise<{ slug
</> </>
) : ( ) : (
<div className="mx-auto max-w-4xl px-6 py-24 text-center"> <div className="mx-auto max-w-4xl px-6 py-24 text-center">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-brand-primary">HomAroma</p> <p className="text-xs font-bold uppercase tracking-[0.28em] text-brand-primary"></p>
<h1 className="mt-4 text-4xl font-semibold text-text-main md:text-5xl">{page.title}</h1> <h1 className="mt-4 text-4xl font-semibold text-text-main md:text-5xl">{page.title}</h1>
<p className="mt-5 text-muted-text"> <p className="mt-5 text-muted-text">
หน้านี้ถูกเผยแพร่แล้ว แต่ยังไม่มี section ที่เปิดใช้งานใน Page Builder หน้านี้ถูกเผยแพร่แล้ว แต่ยังไม่มี section ที่เปิดใช้งานใน Page Builder
+8 -13
View File
@@ -1,5 +1,5 @@
import Link from '@/components/LocalizedLink' import Link from '@/components/LocalizedLink'
import { generateSeoMetadata } from '@/lib/seo' import { API_BASE } from '@/lib/api'
const PILLARS = [ const PILLARS = [
{ {
@@ -26,17 +26,12 @@ const STATS = [
{ value: '100%', label: 'Natural Extracts' }, { value: '100%', label: 'Natural Extracts' },
] ]
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export default async function AboutPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: 600 } });
title: 'About Us | HomAroma', const settings = res.ok ? await res.json() : {};
description: 'Born from a passion for stillness. HomAroma began in 2019 as a Bangkok studio crafting botanical aroma blends.', const brandName = settings?.brandName || '';
path: '/about',
lang,
});
}
export default function AboutPage() {
return ( return (
<div className="min-h-screen bg-surface-page text-text-main"> <div className="min-h-screen bg-surface-page text-text-main">
<section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-surface-card pt-24"> <section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-surface-card pt-24">
@@ -47,7 +42,7 @@ export default function AboutPage() {
Born from a passion for <span className="italic text-brand-primary">stillness</span> Born from a passion for <span className="italic text-brand-primary">stillness</span>
</h1> </h1>
<p className="text-lg leading-relaxed text-muted-text"> <p className="text-lg leading-relaxed text-muted-text">
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. {brandName} 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> </p>
</div> </div>
</section> </section>
@@ -72,7 +67,7 @@ export default function AboutPage() {
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. 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>
<p className="text-sm leading-relaxed text-muted-text"> <p className="text-sm leading-relaxed text-muted-text">
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. Every {brandName} 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> </p>
</div> </div>
<div className="relative h-80 overflow-hidden rounded-3xl border border-line-primary/20 bg-surface-card shadow-card"> <div className="relative h-80 overflow-hidden rounded-3xl border border-line-primary/20 bg-surface-card shadow-card">
@@ -80,7 +75,7 @@ export default function AboutPage() {
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="space-y-3 text-center"> <div className="space-y-3 text-center">
<div className="text-7xl">🌿</div> <div className="text-7xl">🌿</div>
<p className="text-sm uppercase tracking-widest text-muted-text">HomAroma Studio, Bangkok</p> <p className="text-sm uppercase tracking-widest text-muted-text">{brandName} Studio, Bangkok</p>
</div> </div>
</div> </div>
</div> </div>
@@ -18,9 +18,9 @@ async function getArticle(slug: string): Promise<Article | null> {
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params const { slug, lang } = await params
const article = await getArticle(slug) const article = await getArticle(slug)
if (!article) return { title: 'Article Not Found | HomAroma' } if (!article) return { title: 'Article Not Found' }
return generateSeoMetadata({ return generateSeoMetadata({
title: `${article.title} | HomAroma Scent Guide`, title: `${article.title} Scent Guide`,
description: article.content.replace(/<[^>]+>/g, '').slice(0, 160), description: article.content.replace(/<[^>]+>/g, '').slice(0, 160),
path: `/article/${slug}`, path: `/article/${slug}`,
lang, lang,
@@ -29,8 +29,8 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : '' const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : ''
return generateSeoMetadata({ return generateSeoMetadata({
title: categoryName ? `${categoryName} | HomAroma` : 'Category | HomAroma', title: categoryName ? `${categoryName}` : 'Category',
description: categoryName ? `Shop ${categoryName} products from HomAroma.` : 'Shop HomAroma category products.', description: categoryName ? `Shop ${categoryName} products from .` : 'Shop category products.',
path: `/category/${id}`, path: `/category/${id}`,
lang, lang,
}) })
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'Checkout | HomAroma', title: 'Checkout',
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ HomAroma', description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ ',
path: '/checkout', path: '/checkout',
lang, lang,
}); });
@@ -1,8 +1,8 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Contact Us | HomAroma', title: 'Contact Us',
description: 'Get in touch with HomAroma. We respond to all inquiries within 2 hours.', description: 'Get in touch with . We respond to all inquiries within 2 hours.',
} }
export default function ContactLayout({ children }: { children: React.ReactNode }) { export default function ContactLayout({ children }: { children: React.ReactNode }) {
+32 -4
View File
@@ -1,13 +1,41 @@
"use client" "use client"
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
type FormState = { name: string; email: string; subject: string; message: string } type FormState = { name: string; email: string; subject: string; message: string }
type Status = 'idle' | 'sending' | 'sent' | 'error' type Status = 'idle' | 'sending' | 'sent' | 'error'
const SUBJECTS = ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other'] const SUBJECTS = ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other']
export default function ContactPage() { export default function ContactPage() {
const params = useParams()
const lang = params?.lang as string || 'en'
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' }) const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
const [status, setStatus] = useState<Status>('idle') const [status, setStatus] = useState<Status>('idle')
const [brandName, setBrandName] = useState('')
const [socials, setSocials] = useState({ line: '@homaroma', ig: '@homaroma', email: 'hello@homaroma.shop' })
useEffect(() => {
fetch(`/api/settings?lang=${lang}`)
.then(res => res.ok ? res.json() : {})
.then(data => {
const brand = data.brandName || ''
setBrandName(brand)
const username = brand.toLowerCase().replace(/\s+/g, '')
let ig = `@${username}`
if (data.instagramUrl) {
const match = data.instagramUrl.match(/instagram\.com\/([^/?]+)/)
if (match) ig = `@${match[1]}`
}
setSocials({
line: `@${username}`,
ig,
email: `hello@${username}.shop`
})
})
.catch(() => {})
}, [lang])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setForm(prev => ({ ...prev, [e.target.name]: e.target.value })) setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
@@ -35,9 +63,9 @@ export default function ContactPage() {
<h2 className="mb-6 text-xl font-bold text-text-main">Reach Us Directly</h2> <h2 className="mb-6 text-xl font-bold text-text-main">Reach Us Directly</h2>
<div className="space-y-5"> <div className="space-y-5">
{[ {[
{ icon: '📧', label: 'Email', value: 'hello@homaroma.shop', href: 'mailto:hello@homaroma.shop' }, { icon: '📧', label: 'Email', value: socials.email, href: `mailto:${socials.email}` },
{ icon: '📱', label: 'LINE Official', value: '@homaroma', href: '#' }, { icon: '📱', label: 'LINE Official', value: socials.line, href: '#' },
{ icon: '📸', label: 'Instagram', value: '@homaroma', href: '#' }, { icon: '📸', label: 'Instagram', value: socials.ig, href: '#' },
{ icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' }, { icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' },
].map(item => ( ].map(item => (
<a key={item.label} href={item.href} className="group flex items-start gap-4"> <a key={item.label} href={item.href} className="group flex items-start gap-4">
+2 -2
View File
@@ -17,8 +17,8 @@ function formatDiscount(type: string, value: number) {
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'โปรโมชั่นและคูปองส่วนลด | HomAroma', title: 'โปรโมชั่นและคูปองส่วนลด',
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก HomAroma สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ', description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
path: '/coupons', path: '/coupons',
lang, lang,
}); });
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'FAQ | HomAroma', title: 'FAQ',
description: 'Everything you need to know about HomAroma products, shipping, and care.', description: 'Everything you need to know about products, shipping, and care.',
} }
export default function FaqLayout({ children }: { children: React.ReactNode }) { export default function FaqLayout({ children }: { children: React.ReactNode }) {
+31 -7
View File
@@ -1,12 +1,13 @@
"use client" "use client"
import { useState } from 'react' import { useState, useEffect } from 'react'
import Link from '@/components/LocalizedLink' import Link from '@/components/LocalizedLink'
import { useParams } from 'next/navigation'
const FAQS = [ const FAQS = [
{ {
category: 'Products', icon: '🌿', category: 'Products', icon: '🌿',
items: [ 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: 'What are the ingredients in your aroma products?', a: 'All __BRAND__ 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: '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: '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.' }, { 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.' },
@@ -38,22 +39,45 @@ const FAQS = [
] ]
export default function FaqPage() { export default function FaqPage() {
const params = useParams()
const lang = params?.lang as string || 'en'
const [brandName, setBrandName] = useState('')
const [openItems, setOpenItems] = useState<Record<string, boolean>>({}) const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
const [activeCategory, setActiveCategory] = useState<string>('Products') const [activeCategory, setActiveCategory] = useState<string>('Products')
useEffect(() => {
fetch(`/api/settings?lang=${lang}`)
.then(res => res.ok ? res.json() : {})
.then(data => {
if (data.brandName) setBrandName(data.brandName)
})
.catch(() => {})
}, [lang])
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] })) const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
const activeFaqs = FAQS.find(f => f.category === activeCategory)
const processedFaqs = FAQS.map(f => ({
...f,
items: f.items.map(i => ({
...i,
q: i.q.replace(/__BRAND__/g, brandName),
a: i.a.replace(/__BRAND__/g, brandName)
}))
}))
const activeFaqs = processedFaqs.find(f => f.category === activeCategory)
return ( return (
<div className="min-h-screen bg-surface-page text-text-main"> <div className="min-h-screen bg-surface-page text-text-main">
<div className="space-y-4 border-b border-line-primary/20 bg-surface-card px-6 pb-16 pt-32 text-center"> <div className="space-y-4 border-b border-line-primary/20 bg-surface-card px-6 pb-16 pt-32 text-center">
<p className="ui-eyebrow">Support</p> <p className="ui-eyebrow">Support</p>
<h1 className="text-5xl font-bold text-text-main">Frequently Asked Questions</h1> <h1 className="text-5xl font-bold text-text-main">Frequently Asked Questions</h1>
<p className="mx-auto max-w-lg text-muted-text">Everything you need to know about HomAroma products, shipping, and care.</p> <p className="mx-auto max-w-lg text-muted-text">Everything you need to know about {brandName} products, shipping, and care.</p>
</div> </div>
<div className="mx-auto max-w-4xl space-y-10 px-6 py-16"> <div className="mx-auto max-w-4xl space-y-10 px-6 py-16">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{FAQS.map(({ category, icon }) => ( {processedFaqs.map(({ category, icon }) => (
<button <button
key={category} key={category}
onClick={() => setActiveCategory(category)} onClick={() => setActiveCategory(category)}
@@ -82,8 +106,8 @@ export default function FaqPage() {
</button> </button>
{isOpen && ( {isOpen && (
<div className="px-6 pb-6"> <div className="px-6 pb-6">
<div className="mb-4 h-px w-full bg-line-primary/10" /> <div className="mb-4 h-px w-full bg-line-primary/10" />
<p className="text-sm leading-relaxed text-muted-text">{item.a}</p> <p className="text-sm leading-relaxed text-muted-text">{item.a}</p>
</div> </div>
)} )}
</div> </div>
+21 -9
View File
@@ -25,16 +25,28 @@ const kanit = Kanit({
variable: '--font-kanit', variable: '--font-kanit',
}); });
export const metadata: Metadata = { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
title: 'HomAroma', const { lang } = await params;
description: 'Pure Origins, Artisan Craft, Zero Compromise', const settings = await getStoreSettings(lang);
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'), const brandName = settings?.brandName || '';
};
return {
title: {
template: `%s | ${brandName}`,
default: brandName,
},
description: 'Pure Origins, Artisan Craft, Zero Compromise',
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
};
}
// Cache configuration: 0 for dev (no cache), strict numbers for production
const getCacheTime = (prodSeconds: number) => process.env.NODE_ENV === 'development' ? 0 : prodSeconds;
// Fetch categories for Navbar and Footer on the server // Fetch categories for Navbar and Footer on the server
async function getCategories(lang: string) { async function getCategories(lang: string) {
try { try {
const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: 3600 } }); const res = await fetch(`${API_BASE}/categories?lang=${lang}`, { next: { revalidate: getCacheTime(3600) } });
if (!res.ok) return []; if (!res.ok) return [];
return await res.json(); return await res.json();
} catch (error) { } catch (error) {
@@ -46,7 +58,7 @@ async function getCategories(lang: string) {
// Fetch mega menu on the server — eliminates client-side flash // Fetch mega menu on the server — eliminates client-side flash
async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> { async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
try { try {
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: 300 } }); const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json() as { payload?: MegaMenuPayload[] }; const data = await res.json() as { payload?: MegaMenuPayload[] };
return Array.isArray(data.payload) ? data.payload : []; return Array.isArray(data.payload) ? data.payload : [];
@@ -60,7 +72,7 @@ async function getMegaMenu(lang: string): Promise<MegaMenuPayload[]> {
async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> { async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
const fetchMenu = async (slug: string): Promise<MenuItem[]> => { const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
try { try {
const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: 300 } }); const res = await fetch(`${API_BASE}/menus/${slug}?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json() as { items?: MenuItem[] }; const data = await res.json() as { items?: MenuItem[] };
return Array.isArray(data.items) return Array.isArray(data.items)
@@ -82,7 +94,7 @@ async function getFooterMenus(lang: string): Promise<{ exploreMenu: MenuItem[];
// Fetch store settings (social links, footer texts) on the server // Fetch store settings (social links, footer texts) on the server
async function getStoreSettings(lang: string): Promise<StoreSettings | null> { async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
try { try {
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: 600 } }); const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: getCacheTime(600) } });
if (!res.ok) return null; if (!res.ok) return null;
return await res.json() as StoreSettings; return await res.json() as StoreSettings;
} catch (error) { } catch (error) {
@@ -33,11 +33,11 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
const { id, lang } = await params const { id, lang } = await params
const result = await getOrderResult(id) const result = await getOrderResult(id)
if (!result.ok) return { title: 'Order Not Found | HomAroma' } if (!result.ok) return { title: 'Order Not Found' }
return generateSeoMetadata({ return generateSeoMetadata({
title: `Order ${orderLabel(result.data.id)} | HomAroma`, title: `Order ${orderLabel(result.data.id)}`,
description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก HomAroma`, description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก `,
path: `/order/${id}`, path: `/order/${id}`,
lang, lang,
}) })
@@ -129,7 +129,7 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
<div key={item.id} className="flex items-center gap-4 rounded-2xl bg-surface-page p-4"> <div key={item.id} className="flex items-center gap-4 rounded-2xl bg-surface-page p-4">
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-surface-card"> <div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-surface-card">
<Image <Image
src={item.productImage || '/images/oil_ozone.png'} src={item.productImage || '/images/fallback-product.svg'}
alt={item.productName} alt={item.productName}
fill fill
unoptimized unoptimized
+3 -3
View File
@@ -13,8 +13,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const page = await getCmsPage('home', lang) const page = await getCmsPage('home', lang)
return generateSeoMetadata({ return generateSeoMetadata({
title: page?.title ? `${page.title} | HomAroma` : 'HomAroma', title: page?.title ? `${page.title}` : undefined,
description: 'Premium aromatherapy, home fragrance, and botanical scent products from HomAroma.', description: 'Premium aromatherapy, home fragrance, and botanical scent products from .',
path: '', path: '',
lang, lang,
}) })
@@ -48,7 +48,7 @@ export default async function HomePage({ params }: Props) {
) : ( ) : (
<section className="mx-auto flex min-h-[70vh] max-w-5xl flex-col items-center justify-center px-6 text-center"> <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 ui-eyebrow"> <p className="mb-4 ui-eyebrow">
HomAroma
</p> </p>
<h1 className="text-5xl font-semibold text-text-main md:text-7xl"> <h1 className="text-5xl font-semibold text-text-main md:text-7xl">
Premium scents for calmer spaces Premium scents for calmer spaces
@@ -21,15 +21,15 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
const { id, lang } = await params const { id, lang } = await params
const product = await getProduct(id, lang) const product = await getProduct(id, lang)
if (!product) return { title: 'Product Not Found | HomAroma' } if (!product) return { title: 'Product Not Found' }
const productName = resolveLocalized(product.name, lang) || product.scent.name const productName = resolveLocalized(product.name, lang) || product.scent.name
const categoryName = resolveCategoryName(product.category, lang) const categoryName = resolveCategoryName(product.category, lang)
const description = resolveLocalized(product.description, lang) const description = resolveLocalized(product.description, lang)
return generateSeoMetadata({ return generateSeoMetadata({
title: `${productName} ${categoryName ? `| ${categoryName}` : ''} | HomAroma`, title: `${productName} ${categoryName ? `| ${categoryName}` : ''}`,
description: description || `Premium ${categoryName} from HomAroma.`, description: description || `Premium ${categoryName} from .`,
path: `/products/${id}`, path: `/products/${id}`,
lang, lang,
openGraph: { openGraph: {
+2 -2
View File
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'โปรไฟล์สมาชิก | HomAroma', title: 'โปรไฟล์สมาชิก',
description: 'จัดการโปรไฟล์สมาชิก HomAroma ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง', description: 'จัดการโปรไฟล์สมาชิก ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง',
path: '/profile', path: '/profile',
lang, lang,
}); });
@@ -9,7 +9,7 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'Scent Guide | HomAroma', title: 'Scent Guide',
description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.', description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.',
path: '/scent-guide', path: '/scent-guide',
lang, lang,
@@ -1,7 +1,7 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Search Products | HomAroma', title: 'Search Products',
description: 'Search our collection of premium aroma products by scent or product name.', description: 'Search our collection of premium aroma products by scent or product name.',
} }
+2 -2
View File
@@ -9,8 +9,8 @@ import { resolveLocalized } from '@/lib/localization'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'Search Products | HomAroma', title: 'Search Products',
description: 'Search HomAroma products by scent, category, or keyword.', description: 'Search products by scent, category, or keyword.',
path: '/search', path: '/search',
lang, lang,
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Shop All Products | HomAroma', title: 'Shop All Products',
description: 'Browse our full range of premium aroma products — crafted to elevate every space.', description: 'Browse our full range of premium aroma products — crafted to elevate every space.',
} }
+2 -2
View File
@@ -11,8 +11,8 @@ import { resolveLocalized } from '@/lib/localization'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'Shop Aroma Products | HomAroma', title: 'Shop Aroma Products',
description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.', description: 'Browse aroma oils, reed diffusers, room sprays, and premium scent products.',
path: '/shop', path: '/shop',
lang, lang,
}); });
+2 -2
View File
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'ติดตามคำสั่งซื้อ | HomAroma', title: 'ติดตามคำสั่งซื้อ',
description: 'ตรวจสอบสถานะคำสั่งซื้อ HomAroma ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ', description: 'ตรวจสอบสถานะคำสั่งซื้อ ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ',
path: '/track', path: '/track',
lang, lang,
}); });
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params; const { lang } = await params;
return generateSeoMetadata({ return generateSeoMetadata({
title: 'Wishlist | HomAroma', title: 'Wishlist',
description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ HomAroma', description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ ',
path: '/wishlist', path: '/wishlist',
lang, lang,
}); });
+1 -1
View File
@@ -44,7 +44,7 @@ export default function CartDrawer() {
<div key={item.cartItemId} className="group relative flex gap-4 rounded-2xl border border-line-primary/20 bg-surface-page/60 p-4"> <div key={item.cartItemId} className="group relative flex gap-4 rounded-2xl border border-line-primary/20 bg-surface-page/60 p-4">
<div className="relative h-24 w-20 flex-shrink-0 overflow-hidden rounded-lg bg-surface-card"> <div className="relative h-24 w-20 flex-shrink-0 overflow-hidden rounded-lg bg-surface-card">
<Image <Image
src={item.productImage || '/images/oil_ozone.png'} src={item.productImage || '/images/fallback-product.svg'}
alt={item.productName} alt={item.productName}
fill fill
unoptimized unoptimized
@@ -105,12 +105,12 @@ function toOrderItem(item: CartItem) {
function saveLocalOrder(orderId: number) { function saveLocalOrder(orderId: number) {
try { try {
const existingValue = window.localStorage.getItem('homaroma_local_orders') const existingValue = window.localStorage.getItem('app_local_orders')
const existing = existingValue ? JSON.parse(existingValue) : [] const existing = existingValue ? JSON.parse(existingValue) : []
const orderIds = Array.isArray(existing) ? existing.filter((id) => typeof id === 'number') : [] const orderIds = Array.isArray(existing) ? existing.filter((id) => typeof id === 'number') : []
if (!orderIds.includes(orderId)) { if (!orderIds.includes(orderId)) {
window.localStorage.setItem('homaroma_local_orders', JSON.stringify([orderId, ...orderIds])) window.localStorage.setItem('app_local_orders', JSON.stringify([orderId, ...orderIds]))
} }
} catch (error) { } catch (error) {
console.warn('[website-checkout] Failed to persist local order history', error) console.warn('[website-checkout] Failed to persist local order history', error)
@@ -193,7 +193,7 @@ export default function CheckoutClient() {
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
const savedCoupon = window.localStorage.getItem('homaroma_active_coupon') const savedCoupon = window.localStorage.getItem('app_active_coupon')
if (savedCoupon) setCouponCode(savedCoupon) if (savedCoupon) setCouponCode(savedCoupon)
}) })
}, []) }, [])
@@ -242,7 +242,7 @@ export default function CheckoutClient() {
useEffect(() => { useEffect(() => {
if (!couponCode || appliedCoupon || subtotalAfterL2 <= 0) return if (!couponCode || appliedCoupon || subtotalAfterL2 <= 0) return
const savedCoupon = window.localStorage.getItem('homaroma_active_coupon') const savedCoupon = window.localStorage.getItem('app_active_coupon')
if (!savedCoupon || savedCoupon !== couponCode) return if (!savedCoupon || savedCoupon !== couponCode) return
const applySavedCoupon = async () => { const applySavedCoupon = async () => {
@@ -258,10 +258,10 @@ export default function CheckoutClient() {
setDiscountAmount(data.discountAmount ?? 0) setDiscountAmount(data.discountAmount ?? 0)
setIsFreeShipping(Boolean(data.isFreeShipping)) setIsFreeShipping(Boolean(data.isFreeShipping))
} else { } else {
window.localStorage.removeItem('homaroma_active_coupon') window.localStorage.removeItem('app_active_coupon')
} }
} catch { } catch {
window.localStorage.removeItem('homaroma_active_coupon') window.localStorage.removeItem('app_active_coupon')
} }
} }
@@ -289,7 +289,7 @@ export default function CheckoutClient() {
setAppliedCoupon(data.coupon.code) setAppliedCoupon(data.coupon.code)
setDiscountAmount(data.discountAmount ?? 0) setDiscountAmount(data.discountAmount ?? 0)
setIsFreeShipping(Boolean(data.isFreeShipping)) setIsFreeShipping(Boolean(data.isFreeShipping))
window.localStorage.setItem('homaroma_active_coupon', data.coupon.code) window.localStorage.setItem('app_active_coupon', data.coupon.code)
} catch (error) { } catch (error) {
setAppliedCoupon(null) setAppliedCoupon(null)
setDiscountAmount(0) setDiscountAmount(0)
@@ -306,7 +306,7 @@ export default function CheckoutClient() {
setDiscountAmount(0) setDiscountAmount(0)
setIsFreeShipping(false) setIsFreeShipping(false)
setCouponError(null) setCouponError(null)
window.localStorage.removeItem('homaroma_active_coupon') window.localStorage.removeItem('app_active_coupon')
} }
const selectAddress = (address: CustomerAddress | 'new') => { const selectAddress = (address: CustomerAddress | 'new') => {
@@ -611,7 +611,7 @@ export default function CheckoutClient() {
<div key={item.cartItemId} className="flex gap-3"> <div key={item.cartItemId} className="flex gap-3">
<div className="relative h-16 w-14 shrink-0 overflow-hidden rounded-xl bg-surface-page"> <div className="relative h-16 w-14 shrink-0 overflow-hidden rounded-xl bg-surface-page">
<Image <Image
src={item.productImage || '/images/oil_ozone.png'} src={item.productImage || '/images/fallback-product.svg'}
alt={item.productName} alt={item.productName}
fill fill
unoptimized unoptimized
+2 -2
View File
@@ -35,7 +35,7 @@ const LANGUAGES = [
function setLanguageCookie(code: string) { function setLanguageCookie(code: string) {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.cookie = `homaroma_lang=${code}; path=/; max-age=31536000` document.cookie = `app_lang=${code}; path=/; max-age=31536000`
} }
} }
@@ -96,7 +96,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
const changeLanguage = (code: string) => { const changeLanguage = (code: string) => {
i18n.changeLanguage(code) i18n.changeLanguage(code)
localStorage.setItem('homaroma_lang', code) localStorage.setItem('app_lang', code)
setLanguageCookie(code) setLanguageCookie(code)
const currentPathWithoutLang = getPathWithoutLocale(pathname) const currentPathWithoutLang = getPathWithoutLocale(pathname)
@@ -327,7 +327,7 @@ export default function ProfileClient() {
<div className="flex min-w-0 items-center gap-4"> <div className="flex min-w-0 items-center gap-4">
<div className="ui-avatar-shell-sm relative shrink-0"> <div className="ui-avatar-shell-sm relative shrink-0">
<Image <Image
src={displayCustomer.pictureUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(displayCustomer.displayName || 'HomAroma')}`} src={displayCustomer.pictureUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(displayCustomer.displayName || 'Member')}`}
alt={displayCustomer.displayName || 'LINE Profile'} alt={displayCustomer.displayName || 'LINE Profile'}
fill fill
unoptimized unoptimized
@@ -335,7 +335,7 @@ export default function ProfileClient() {
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-2xl font-semibold text-text-main">{displayCustomer.displayName || 'HomAroma Member'}</h1> <h1 className="truncate text-2xl font-semibold text-text-main">{displayCustomer.displayName || 'Member'}</h1>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-text"> <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-text">
{displayCustomer.phone && <span className="inline-flex items-center gap-1"><Phone className="h-3 w-3" />{displayCustomer.phone}</span>} {displayCustomer.phone && <span className="inline-flex items-center gap-1"><Phone className="h-3 w-3" />{displayCustomer.phone}</span>}
{displayCustomer.email && <span className="inline-flex items-center gap-1"><Mail className="h-3 w-3" />{displayCustomer.email}</span>} {displayCustomer.email && <span className="inline-flex items-center gap-1"><Mail className="h-3 w-3" />{displayCustomer.email}</span>}
@@ -379,7 +379,7 @@ export default function ProfileClient() {
{activeTab === 'orders' && ( {activeTab === 'orders' && (
<div className="space-y-5"> <div className="space-y-5">
{orders.length === 0 ? ( {orders.length === 0 ? (
<EmptyPanel icon={Package} title="ไม่มีประวัติการสั่งซื้อ" description="คุณยังไม่ได้ทำการสั่งซื้อสินค้าใด ๆ กับ HomAroma" actionHref="/shop" actionText="ไปหน้าช้อปปิ้ง" /> <EmptyPanel icon={Package} title="ไม่มีประวัติการสั่งซื้อ" description="คุณยังไม่ได้ทำการสั่งซื้อสินค้าใด ๆ " actionHref="/shop" actionText="ไปหน้าช้อปปิ้ง" />
) : ( ) : (
orders.map((order) => ( orders.map((order) => (
<article key={order.id} className="ui-panel p-6"> <article key={order.id} className="ui-panel p-6">
@@ -404,7 +404,7 @@ export default function ProfileClient() {
{order.items.map((item) => ( {order.items.map((item) => (
<div key={item.id} className="flex items-center gap-3 text-sm"> <div key={item.id} className="flex items-center gap-3 text-sm">
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-xl bg-surface-page"> <div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-xl bg-surface-page">
<Image src={item.productImage || '/images/oil_ozone.png'} alt={item.productName} fill unoptimized className="h-full w-full object-cover" /> <Image src={item.productImage || '/images/fallback-product.svg'} alt={item.productName} fill unoptimized className="h-full w-full object-cover" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-semibold text-text-main">{item.productName}</p> <p className="truncate font-semibold text-text-main">{item.productName}</p>
@@ -16,7 +16,7 @@ export default function ShopCouponBridge({ coupon }: ShopCouponBridgeProps) {
useEffect(() => { useEffect(() => {
if (!coupon) return if (!coupon) return
localStorage.setItem('homaroma_active_coupon', coupon) localStorage.setItem('app_active_coupon', coupon)
router.replace(getPathWithLocale('/shop', lang), { scroll: false }) router.replace(getPathWithLocale('/shop', lang), { scroll: false })
}, [coupon, router, lang]) }, [coupon, router, lang])
@@ -41,14 +41,14 @@ export default function TrackOrderClient() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
const storedOrders = parseStoredOrders(localStorage.getItem('homaroma_local_orders')) const storedOrders = parseStoredOrders(localStorage.getItem('app_local_orders'))
queueMicrotask(() => setLocalOrders(storedOrders)) queueMicrotask(() => setLocalOrders(storedOrders))
}, []) }, [])
const saveLocalOrder = (id: number) => { const saveLocalOrder = (id: number) => {
setLocalOrders((current) => { setLocalOrders((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 12) const next = [id, ...current.filter((item) => item !== id)].slice(0, 12)
localStorage.setItem('homaroma_local_orders', JSON.stringify(next)) localStorage.setItem('app_local_orders', JSON.stringify(next))
return next return next
}) })
} }
@@ -115,7 +115,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
<section className="ui-hero-shell"> <section className="ui-hero-shell">
<Image <Image
src={imageUrl} src={imageUrl}
alt={title || 'HomAroma'} alt={title || ''}
fill fill
unoptimized unoptimized
className="absolute inset-0 h-full w-full scale-[1.02] object-cover" className="absolute inset-0 h-full w-full scale-[1.02] object-cover"
@@ -130,7 +130,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
</p> </p>
)} )}
<h1 className="ui-hero-title"> <h1 className="ui-hero-title">
{renderAccentTitle(title || 'HomAroma')} {renderAccentTitle(title || '')}
</h1> </h1>
{description && ( {description && (
<p className="ui-hero-copy"> <p className="ui-hero-copy">
+1 -1
View File
@@ -35,7 +35,7 @@ export function proxy(request: NextRequest) {
// Determine the preferred locale // Determine the preferred locale
// 1. Check cookie // 1. Check cookie
const cookieLocale = request.cookies.get('homaroma_lang')?.value; const cookieLocale = request.cookies.get('app_lang')?.value;
let targetLocale = DEFAULT_LOCALE; let targetLocale = DEFAULT_LOCALE;
if (cookieLocale && (SUPPORTED_LOCALES as readonly string[]).includes(cookieLocale)) { if (cookieLocale && (SUPPORTED_LOCALES as readonly string[]).includes(cookieLocale)) {