x
This commit is contained in:
1 parent
c449de2482
commit
7e4e89e33c
50 files changed
+302
-136
No files matched your search
@@ -189,11 +189,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
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: {
|
||||
type: "object",
|
||||
properties: {
|
||||
lang: { type: "string", description: "Language code" },
|
||||
lang: { type: "string" },
|
||||
footerDescription: { type: "string" },
|
||||
newsletterTitle: { type: "string" },
|
||||
newsletterDescription: { type: "string" },
|
||||
@@ -203,6 +203,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
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",
|
||||
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);
|
||||
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": {
|
||||
const { brandName } = params as any;
|
||||
const currentSettingsRes = await api.get("/settings");
|
||||
|
||||
@@ -19,6 +19,7 @@ import WishlistPage from './pages/WishlistPage'
|
||||
import CustomPage from './pages/CustomPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
import CartDrawer from './components/CartDrawer'
|
||||
import ThemeInjector from './components/ThemeInjector'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation();
|
||||
@@ -39,6 +40,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeInjector />
|
||||
<ScrollToTop />
|
||||
<CartDrawer />
|
||||
<Routes>
|
||||
|
||||
@@ -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 className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
|
||||
<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}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -151,7 +151,7 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const code = localStorage.getItem('homaroma_active_coupon')
|
||||
const code = localStorage.getItem('app_active_coupon')
|
||||
setSavedCoupon(code)
|
||||
if (code && subtotal > 0) {
|
||||
fetch(`${API_BASE}/coupons/validate`, {
|
||||
@@ -174,7 +174,7 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
|
||||
}, [subtotal, activePromos])
|
||||
|
||||
const removeCoupon = () => {
|
||||
localStorage.removeItem('homaroma_active_coupon')
|
||||
localStorage.removeItem('app_active_coupon')
|
||||
setSavedCoupon(null)
|
||||
setDiscountAmount(0)
|
||||
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'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())
|
||||
setCouponInput('')
|
||||
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">
|
||||
<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>
|
||||
{cat._count && (
|
||||
<span className="text-sm font-medium text-white/80">
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
const brandName = settings?.brandName || ''
|
||||
|
||||
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) {
|
||||
const trans = item.translations.find((translation) => translation.langCode === lang)
|
||||
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">
|
||||
{categories.map((c) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
||||
|
||||
const changeLanguage = (code: string) => {
|
||||
i18n.changeLanguage(code)
|
||||
localStorage.setItem('homaroma_lang', code)
|
||||
localStorage.setItem('app_lang', code)
|
||||
setLangSheetOpen(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import viTranslation from "./locales/vi/translation.json"
|
||||
import koTranslation from "./locales/ko/translation.json"
|
||||
|
||||
// Retrieve default lang from localStorage if set
|
||||
const savedLang = localStorage.getItem('homaroma_lang') || 'th'
|
||||
const savedLang = localStorage.getItem('app_lang') || 'th'
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function CategoryPage() {
|
||||
<span>/</span>
|
||||
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||
<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>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white">
|
||||
{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'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function CheckoutPage() {
|
||||
.catch(console.error)
|
||||
|
||||
// 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) {
|
||||
setCouponCode(savedCoupon)
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const savedCoupon = localStorage.getItem('homaroma_active_coupon')
|
||||
const savedCoupon = localStorage.getItem('app_active_coupon')
|
||||
if (savedCoupon && subtotal > 0 && !appliedCoupon && !couponError && !couponLoading) {
|
||||
setCouponCode(savedCoupon)
|
||||
// trigger fetch to validate
|
||||
@@ -190,7 +190,7 @@ export default function CheckoutPage() {
|
||||
setDiscountAmount(data.discountAmount)
|
||||
setIsFreeShipping(data.isFreeShipping)
|
||||
} else {
|
||||
localStorage.removeItem('homaroma_active_coupon') // Remove invalid
|
||||
localStorage.removeItem('app_active_coupon') // Remove invalid
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
@@ -254,7 +254,7 @@ export default function CheckoutPage() {
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
setCouponError('')
|
||||
localStorage.removeItem('homaroma_active_coupon')
|
||||
localStorage.removeItem('app_active_coupon')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -309,10 +309,10 @@ export default function CheckoutPage() {
|
||||
if (res.ok) {
|
||||
clearCart()
|
||||
|
||||
const existingStr = localStorage.getItem('homaroma_local_orders')
|
||||
const existingStr = localStorage.getItem('app_local_orders')
|
||||
const existing = existingStr ? JSON.parse(existingStr) : []
|
||||
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}`)
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function CouponsPage() {
|
||||
}
|
||||
|
||||
const handleUseNow = (code: string) => {
|
||||
localStorage.setItem('homaroma_active_coupon', code)
|
||||
localStorage.setItem('app_active_coupon', code)
|
||||
navigate('/shop')
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ export default function OrderSuccessPage() {
|
||||
{order.items.map(item => (
|
||||
<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">
|
||||
<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 className="flex-1">
|
||||
<p className="font-bold text-pop-black">{item.productName}</p>
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function ProfilePage() {
|
||||
{order.items.map(item => (
|
||||
<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">
|
||||
<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 className="flex-1 min-w-0">
|
||||
<p className="font-bold text-pop-black truncate">{item.productName}</p>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function ShopPage() {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const coupon = params.get('coupon')
|
||||
if (coupon) {
|
||||
localStorage.setItem('homaroma_active_coupon', coupon)
|
||||
localStorage.setItem('app_active_coupon', coupon)
|
||||
// Optionally clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function TrackOrderPage() {
|
||||
.finally(() => setHistoryLoading(false))
|
||||
} else {
|
||||
// Load local orders if not logged in
|
||||
const storedStr = localStorage.getItem('homaroma_local_orders')
|
||||
const storedStr = localStorage.getItem('app_local_orders')
|
||||
if (storedStr) {
|
||||
setLocalOrders(JSON.parse(storedStr))
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export default function TrackOrderPage() {
|
||||
if (!localOrders.includes(data.orderId)) {
|
||||
const newOrders = [data.orderId, ...localOrders]
|
||||
setLocalOrders(newOrders)
|
||||
localStorage.setItem('homaroma_local_orders', JSON.stringify(newOrders))
|
||||
localStorage.setItem('app_local_orders', JSON.stringify(newOrders))
|
||||
}
|
||||
navigate(`/order/${data.orderId}`)
|
||||
} else {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useWishlistStore = create<WishlistState>()(
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'homaroma-wishlist',
|
||||
name: 'app-wishlist',
|
||||
}
|
||||
)
|
||||
)
|
||||
+16
-16
@@ -16,28 +16,28 @@ export default {
|
||||
},
|
||||
colors: {
|
||||
pop: {
|
||||
pink: "#FF6B6B",
|
||||
yellow: "#FFF200",
|
||||
orange: "#FFA07A",
|
||||
red: "#FF4500",
|
||||
cyan: "#E0FFFF",
|
||||
green: "#98FB98",
|
||||
purple: "#D8BFD8",
|
||||
bg: "#FFF0F5",
|
||||
black: "#333333",
|
||||
pink: "var(--color-pop-pink, #FF6B6B)",
|
||||
yellow: "var(--color-pop-yellow, #FFF200)",
|
||||
orange: "var(--color-pop-orange, #FFA07A)",
|
||||
red: "var(--color-pop-red, #FF4500)",
|
||||
cyan: "var(--color-pop-cyan, #E0FFFF)",
|
||||
green: "var(--color-pop-green, #98FB98)",
|
||||
purple: "var(--color-pop-purple, #D8BFD8)",
|
||||
bg: "var(--color-pop-bg, #FFF0F5)",
|
||||
black: "var(--color-pop-black, #333333)",
|
||||
},
|
||||
night: {
|
||||
900: "#111827",
|
||||
900: "var(--color-night-900, #111827)",
|
||||
},
|
||||
cyan: {
|
||||
glow: "#06b6d4",
|
||||
glow: "var(--color-cyan-glow, #06b6d4)",
|
||||
},
|
||||
spa: {
|
||||
light: "#FDFDF9", // Off-white/Cream
|
||||
sage: "#EAEFEA", // Soft Sage Green
|
||||
stone: "#4B4B46", // Dark Stone Gray
|
||||
forest: "#1F3D2D", // Deep Forest Green
|
||||
gold: "#D4AF37", // Elegant Gold
|
||||
light: "var(--color-spa-light, #FDFDF9)", // Off-white/Cream
|
||||
sage: "var(--color-spa-sage, #EAEFEA)", // Soft Sage Green
|
||||
stone: "var(--color-spa-stone, #4B4B46)", // Dark Stone Gray
|
||||
forest: "var(--color-spa-forest, #1F3D2D)", // Deep Forest Green
|
||||
gold: "var(--color-spa-gold, #D4AF37)", // Elegant Gold
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found | HomAroma',
|
||||
title: 'Page Not Found',
|
||||
}
|
||||
|
||||
export default function NestedLocaleNotFound() {
|
||||
|
||||
@@ -34,14 +34,14 @@ function isReservedSlug(slug: string) {
|
||||
|
||||
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' }
|
||||
if (isReservedSlug(slug)) return { title: 'Page Not Found' }
|
||||
|
||||
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({
|
||||
title: `${pageResult.data.title} | HomAroma`,
|
||||
description: `HomAroma - ${pageResult.data.title}`,
|
||||
title: `${pageResult.data.title}`,
|
||||
description: `${pageResult.data.title}`,
|
||||
path: `/${slug}`,
|
||||
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">
|
||||
<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>
|
||||
<p className="mt-5 text-muted-text">
|
||||
หน้านี้ถูกเผยแพร่แล้ว แต่ยังไม่มี section ที่เปิดใช้งานใน Page Builder
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import { generateSeoMetadata } from '@/lib/seo'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
@@ -26,17 +26,12 @@ const STATS = [
|
||||
{ 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;
|
||||
return generateSeoMetadata({
|
||||
title: 'About Us | HomAroma',
|
||||
description: 'Born from a passion for stillness. HomAroma began in 2019 as a Bangkok studio crafting botanical aroma blends.',
|
||||
path: '/about',
|
||||
lang,
|
||||
});
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/settings?lang=${lang}`, { next: { revalidate: 600 } });
|
||||
const settings = res.ok ? await res.json() : {};
|
||||
const brandName = settings?.brandName || '';
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<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">
|
||||
@@ -47,7 +42,7 @@ export default function AboutPage() {
|
||||
Born from a passion for <span className="italic text-brand-primary">stillness</span>
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
</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.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<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="space-y-3 text-center">
|
||||
<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>
|
||||
|
||||
@@ -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> {
|
||||
const { slug, lang } = await params
|
||||
const article = await getArticle(slug)
|
||||
if (!article) return { title: 'Article Not Found | HomAroma' }
|
||||
if (!article) return { title: 'Article Not Found' }
|
||||
return generateSeoMetadata({
|
||||
title: `${article.title} | HomAroma Scent Guide`,
|
||||
title: `${article.title} Scent Guide`,
|
||||
description: article.content.replace(/<[^>]+>/g, '').slice(0, 160),
|
||||
path: `/article/${slug}`,
|
||||
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) : ''
|
||||
|
||||
return generateSeoMetadata({
|
||||
title: categoryName ? `${categoryName} | HomAroma` : 'Category | HomAroma',
|
||||
description: categoryName ? `Shop ${categoryName} products from HomAroma.` : 'Shop HomAroma category products.',
|
||||
title: categoryName ? `${categoryName}` : 'Category',
|
||||
description: categoryName ? `Shop ${categoryName} products from .` : 'Shop category products.',
|
||||
path: `/category/${id}`,
|
||||
lang,
|
||||
})
|
||||
|
||||
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
return generateSeoMetadata({
|
||||
title: 'Checkout | HomAroma',
|
||||
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ HomAroma',
|
||||
title: 'Checkout',
|
||||
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ ',
|
||||
path: '/checkout',
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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.',
|
||||
title: 'Contact Us',
|
||||
description: 'Get in touch with . We respond to all inquiries within 2 hours.',
|
||||
}
|
||||
|
||||
export default function ContactLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
"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 Status = 'idle' | 'sending' | 'sent' | 'error'
|
||||
const SUBJECTS = ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other']
|
||||
|
||||
export default function ContactPage() {
|
||||
const params = useParams()
|
||||
const lang = params?.lang as string || 'en'
|
||||
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
|
||||
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>) => {
|
||||
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>
|
||||
<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: 'Email', value: socials.email, href: `mailto:${socials.email}` },
|
||||
{ icon: '📱', label: 'LINE Official', value: socials.line, href: '#' },
|
||||
{ icon: '📸', label: 'Instagram', value: socials.ig, href: '#' },
|
||||
{ icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' },
|
||||
].map(item => (
|
||||
<a key={item.label} href={item.href} className="group flex items-start gap-4">
|
||||
|
||||
@@ -17,8 +17,8 @@ function formatDiscount(type: string, value: number) {
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
return generateSeoMetadata({
|
||||
title: 'โปรโมชั่นและคูปองส่วนลด | HomAroma',
|
||||
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก HomAroma สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
|
||||
title: 'โปรโมชั่นและคูปองส่วนลด',
|
||||
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
|
||||
path: '/coupons',
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FAQ | HomAroma',
|
||||
description: 'Everything you need to know about HomAroma products, shipping, and care.',
|
||||
title: 'FAQ',
|
||||
description: 'Everything you need to know about products, shipping, and care.',
|
||||
}
|
||||
|
||||
export default function FaqLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client"
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from '@/components/LocalizedLink'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
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: '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: '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.' },
|
||||
@@ -38,22 +39,45 @@ const FAQS = [
|
||||
]
|
||||
|
||||
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 [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 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 (
|
||||
<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">
|
||||
<p className="ui-eyebrow">Support</p>
|
||||
<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 className="mx-auto max-w-4xl space-y-10 px-6 py-16">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{FAQS.map(({ category, icon }) => (
|
||||
{processedFaqs.map(({ category, icon }) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
|
||||
@@ -25,16 +25,28 @@ const kanit = Kanit({
|
||||
variable: '--font-kanit',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'HomAroma',
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const settings = await getStoreSettings(lang);
|
||||
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
|
||||
async function getCategories(lang: string) {
|
||||
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 [];
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
@@ -46,7 +58,7 @@ async function getCategories(lang: string) {
|
||||
// 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 } });
|
||||
const res = await fetch(`${API_BASE}/menus/main?lang=${lang}`, { next: { revalidate: getCacheTime(300) } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { payload?: MegaMenuPayload[] };
|
||||
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[] }> {
|
||||
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
|
||||
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 [];
|
||||
const data = await res.json() as { items?: MenuItem[] };
|
||||
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
|
||||
async function getStoreSettings(lang: string): Promise<StoreSettings | null> {
|
||||
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;
|
||||
return await res.json() as StoreSettings;
|
||||
} catch (error) {
|
||||
|
||||
@@ -33,11 +33,11 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
const { id, lang } = await params
|
||||
const result = await getOrderResult(id)
|
||||
|
||||
if (!result.ok) return { title: 'Order Not Found | HomAroma' }
|
||||
if (!result.ok) return { title: 'Order Not Found' }
|
||||
|
||||
return generateSeoMetadata({
|
||||
title: `Order ${orderLabel(result.data.id)} | HomAroma`,
|
||||
description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก HomAroma`,
|
||||
title: `Order ${orderLabel(result.data.id)}`,
|
||||
description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก `,
|
||||
path: `/order/${id}`,
|
||||
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 className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-surface-card">
|
||||
<Image
|
||||
src={item.productImage || '/images/oil_ozone.png'}
|
||||
src={item.productImage || '/images/fallback-product.svg'}
|
||||
alt={item.productName}
|
||||
fill
|
||||
unoptimized
|
||||
|
||||
@@ -13,8 +13,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const page = await getCmsPage('home', lang)
|
||||
|
||||
return generateSeoMetadata({
|
||||
title: page?.title ? `${page.title} | HomAroma` : 'HomAroma',
|
||||
description: 'Premium aromatherapy, home fragrance, and botanical scent products from HomAroma.',
|
||||
title: page?.title ? `${page.title}` : undefined,
|
||||
description: 'Premium aromatherapy, home fragrance, and botanical scent products from .',
|
||||
path: '',
|
||||
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">
|
||||
<p className="mb-4 ui-eyebrow">
|
||||
HomAroma
|
||||
|
||||
</p>
|
||||
<h1 className="text-5xl font-semibold text-text-main md:text-7xl">
|
||||
Premium scents for calmer spaces
|
||||
|
||||
@@ -21,15 +21,15 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
const { id, lang } = await params
|
||||
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 categoryName = resolveCategoryName(product.category, lang)
|
||||
const description = resolveLocalized(product.description, lang)
|
||||
|
||||
return generateSeoMetadata({
|
||||
title: `${productName} ${categoryName ? `| ${categoryName}` : ''} | HomAroma`,
|
||||
description: description || `Premium ${categoryName} from HomAroma.`,
|
||||
title: `${productName} ${categoryName ? `| ${categoryName}` : ''}`,
|
||||
description: description || `Premium ${categoryName} from .`,
|
||||
path: `/products/${id}`,
|
||||
lang,
|
||||
openGraph: {
|
||||
|
||||
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
return generateSeoMetadata({
|
||||
title: 'โปรไฟล์สมาชิก | HomAroma',
|
||||
description: 'จัดการโปรไฟล์สมาชิก HomAroma ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง',
|
||||
title: 'โปรไฟล์สมาชิก',
|
||||
description: 'จัดการโปรไฟล์สมาชิก ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง',
|
||||
path: '/profile',
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
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.',
|
||||
path: '/scent-guide',
|
||||
lang,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Search Products | HomAroma',
|
||||
title: 'Search Products',
|
||||
description: 'Search our collection of premium aroma products by scent or product name.',
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { resolveLocalized } from '@/lib/localization'
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
return generateSeoMetadata({
|
||||
title: 'Search Products | HomAroma',
|
||||
description: 'Search HomAroma products by scent, category, or keyword.',
|
||||
title: 'Search Products',
|
||||
description: 'Search products by scent, category, or keyword.',
|
||||
path: '/search',
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
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.',
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { resolveLocalized } from '@/lib/localization'
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
return generateSeoMetadata({
|
||||
title: 'Shop Aroma Products | HomAroma',
|
||||
description: 'Browse HomAroma aroma oils, reed diffusers, room sprays, and premium scent products.',
|
||||
title: 'Shop Aroma Products',
|
||||
description: 'Browse aroma oils, reed diffusers, room sprays, and premium scent products.',
|
||||
path: '/shop',
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
return generateSeoMetadata({
|
||||
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
||||
description: 'ตรวจสอบสถานะคำสั่งซื้อ HomAroma ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ',
|
||||
title: 'ติดตามคำสั่งซื้อ',
|
||||
description: 'ตรวจสอบสถานะคำสั่งซื้อ ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ',
|
||||
path: '/track',
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||
const { lang } = await params;
|
||||
return generateSeoMetadata({
|
||||
title: 'Wishlist | HomAroma',
|
||||
description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ HomAroma',
|
||||
title: 'Wishlist',
|
||||
description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ ',
|
||||
path: '/wishlist',
|
||||
lang,
|
||||
});
|
||||
|
||||
@@ -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 className="relative h-24 w-20 flex-shrink-0 overflow-hidden rounded-lg bg-surface-card">
|
||||
<Image
|
||||
src={item.productImage || '/images/oil_ozone.png'}
|
||||
src={item.productImage || '/images/fallback-product.svg'}
|
||||
alt={item.productName}
|
||||
fill
|
||||
unoptimized
|
||||
|
||||
@@ -105,12 +105,12 @@ function toOrderItem(item: CartItem) {
|
||||
|
||||
function saveLocalOrder(orderId: number) {
|
||||
try {
|
||||
const existingValue = window.localStorage.getItem('homaroma_local_orders')
|
||||
const existingValue = window.localStorage.getItem('app_local_orders')
|
||||
const existing = existingValue ? JSON.parse(existingValue) : []
|
||||
const orderIds = Array.isArray(existing) ? existing.filter((id) => typeof id === 'number') : []
|
||||
|
||||
if (!orderIds.includes(orderId)) {
|
||||
window.localStorage.setItem('homaroma_local_orders', JSON.stringify([orderId, ...orderIds]))
|
||||
window.localStorage.setItem('app_local_orders', JSON.stringify([orderId, ...orderIds]))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[website-checkout] Failed to persist local order history', error)
|
||||
@@ -193,7 +193,7 @@ export default function CheckoutClient() {
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
const savedCoupon = window.localStorage.getItem('homaroma_active_coupon')
|
||||
const savedCoupon = window.localStorage.getItem('app_active_coupon')
|
||||
if (savedCoupon) setCouponCode(savedCoupon)
|
||||
})
|
||||
}, [])
|
||||
@@ -242,7 +242,7 @@ export default function CheckoutClient() {
|
||||
useEffect(() => {
|
||||
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
|
||||
|
||||
const applySavedCoupon = async () => {
|
||||
@@ -258,10 +258,10 @@ export default function CheckoutClient() {
|
||||
setDiscountAmount(data.discountAmount ?? 0)
|
||||
setIsFreeShipping(Boolean(data.isFreeShipping))
|
||||
} else {
|
||||
window.localStorage.removeItem('homaroma_active_coupon')
|
||||
window.localStorage.removeItem('app_active_coupon')
|
||||
}
|
||||
} 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)
|
||||
setDiscountAmount(data.discountAmount ?? 0)
|
||||
setIsFreeShipping(Boolean(data.isFreeShipping))
|
||||
window.localStorage.setItem('homaroma_active_coupon', data.coupon.code)
|
||||
window.localStorage.setItem('app_active_coupon', data.coupon.code)
|
||||
} catch (error) {
|
||||
setAppliedCoupon(null)
|
||||
setDiscountAmount(0)
|
||||
@@ -306,7 +306,7 @@ export default function CheckoutClient() {
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
setCouponError(null)
|
||||
window.localStorage.removeItem('homaroma_active_coupon')
|
||||
window.localStorage.removeItem('app_active_coupon')
|
||||
}
|
||||
|
||||
const selectAddress = (address: CustomerAddress | 'new') => {
|
||||
@@ -611,7 +611,7 @@ export default function CheckoutClient() {
|
||||
<div key={item.cartItemId} className="flex gap-3">
|
||||
<div className="relative h-16 w-14 shrink-0 overflow-hidden rounded-xl bg-surface-page">
|
||||
<Image
|
||||
src={item.productImage || '/images/oil_ozone.png'}
|
||||
src={item.productImage || '/images/fallback-product.svg'}
|
||||
alt={item.productName}
|
||||
fill
|
||||
unoptimized
|
||||
|
||||
@@ -35,7 +35,7 @@ const LANGUAGES = [
|
||||
|
||||
function setLanguageCookie(code: string) {
|
||||
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) => {
|
||||
i18n.changeLanguage(code)
|
||||
localStorage.setItem('homaroma_lang', code)
|
||||
localStorage.setItem('app_lang', code)
|
||||
setLanguageCookie(code)
|
||||
|
||||
const currentPathWithoutLang = getPathWithoutLocale(pathname)
|
||||
|
||||
@@ -327,7 +327,7 @@ export default function ProfileClient() {
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="ui-avatar-shell-sm relative shrink-0">
|
||||
<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'}
|
||||
fill
|
||||
unoptimized
|
||||
@@ -335,7 +335,7 @@ export default function ProfileClient() {
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
{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>}
|
||||
@@ -379,7 +379,7 @@ export default function ProfileClient() {
|
||||
{activeTab === 'orders' && (
|
||||
<div className="space-y-5">
|
||||
{orders.length === 0 ? (
|
||||
<EmptyPanel icon={Package} title="ไม่มีประวัติการสั่งซื้อ" description="คุณยังไม่ได้ทำการสั่งซื้อสินค้าใด ๆ กับ HomAroma" actionHref="/shop" actionText="ไปหน้าช้อปปิ้ง" />
|
||||
<EmptyPanel icon={Package} title="ไม่มีประวัติการสั่งซื้อ" description="คุณยังไม่ได้ทำการสั่งซื้อสินค้าใด ๆ " actionHref="/shop" actionText="ไปหน้าช้อปปิ้ง" />
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<article key={order.id} className="ui-panel p-6">
|
||||
@@ -404,7 +404,7 @@ export default function ProfileClient() {
|
||||
{order.items.map((item) => (
|
||||
<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">
|
||||
<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 className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-text-main">{item.productName}</p>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function ShopCouponBridge({ coupon }: ShopCouponBridgeProps) {
|
||||
useEffect(() => {
|
||||
if (!coupon) return
|
||||
|
||||
localStorage.setItem('homaroma_active_coupon', coupon)
|
||||
localStorage.setItem('app_active_coupon', coupon)
|
||||
router.replace(getPathWithLocale('/shop', lang), { scroll: false })
|
||||
}, [coupon, router, lang])
|
||||
|
||||
|
||||
@@ -41,14 +41,14 @@ export default function TrackOrderClient() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const storedOrders = parseStoredOrders(localStorage.getItem('homaroma_local_orders'))
|
||||
const storedOrders = parseStoredOrders(localStorage.getItem('app_local_orders'))
|
||||
queueMicrotask(() => setLocalOrders(storedOrders))
|
||||
}, [])
|
||||
|
||||
const saveLocalOrder = (id: number) => {
|
||||
setLocalOrders((current) => {
|
||||
const next = [id, ...current.filter((item) => item !== id)].slice(0, 12)
|
||||
localStorage.setItem('homaroma_local_orders', JSON.stringify(next))
|
||||
localStorage.setItem('app_local_orders', JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
||||
<section className="ui-hero-shell">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title || 'HomAroma'}
|
||||
alt={title || ''}
|
||||
fill
|
||||
unoptimized
|
||||
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>
|
||||
)}
|
||||
<h1 className="ui-hero-title">
|
||||
{renderAccentTitle(title || 'HomAroma')}
|
||||
{renderAccentTitle(title || '')}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="ui-hero-copy">
|
||||
|
||||
@@ -35,7 +35,7 @@ export function proxy(request: NextRequest) {
|
||||
|
||||
// Determine the preferred locale
|
||||
// 1. Check cookie
|
||||
const cookieLocale = request.cookies.get('homaroma_lang')?.value;
|
||||
const cookieLocale = request.cookies.get('app_lang')?.value;
|
||||
let targetLocale = DEFAULT_LOCALE;
|
||||
|
||||
if (cookieLocale && (SUPPORTED_LOCALES as readonly string[]).includes(cookieLocale)) {
|
||||
|
||||
Reference in new issue
Block a user