This commit is contained in:
Your Name committed 2026-06-18 18:42:39 -04:00
1 parent 6f67ee6077
commit 62db477dbd
13 files changed
+660 -98

No files matched your search

+37 -11
View File
@@ -169,12 +169,21 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
})
.use(adminOnly)
.post("/", async ({ body, set }) => {
const data = body;
const operation = data?.id ? "update" : "create";
try {
const data = body;
const { id, slug, title, isPublished, status, metadata } = data;
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
set.status = 400;
return { error: "Reserved slug cannot be used" };
return {
success: false,
operation,
pageId: id ? Number(id) : null,
slug,
title,
error: "Reserved slug cannot be used",
diagnostic: "The requested slug is reserved and cannot be used for dynamic pages."
};
}
let finalIsPublished = true;
if (status !== undefined) {
@@ -183,22 +192,39 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
else if (isPublished !== undefined) {
finalIsPublished = isPublished;
}
if (id) {
return await prisma.page.update({
const page = id
? await prisma.page.update({
where: { id: Number(id) },
data: { slug, title, isPublished: finalIsPublished }
});
}
else {
return await prisma.page.create({
})
: await prisma.page.create({
data: { slug, title, isPublished: finalIsPublished }
});
}
return {
success: true,
operation,
pageId: page.id,
slug: page.slug,
title: page.title,
isPublished: page.isPublished,
page,
diagnostic: operation === "create"
? "Page created successfully."
: "Page updated successfully."
};
}
catch (err) {
console.error("[Pages] Error:", err);
set.status = 500;
return { error: "Failed to save page" };
return {
success: false,
operation,
pageId: data?.id ? Number(data.id) : null,
slug: data?.slug,
title: data?.title,
error: "Failed to save page",
diagnostic: err instanceof Error ? err.message : "Unexpected error while saving page."
};
}
}, {
body: t.Object({
@@ -479,4 +505,4 @@ function validateSectionPayload(type, payload) {
}
return null;
}
//# sourceMappingURL=pages.js.map
//# sourceMappingURL=pages.js.map
+97 -9
View File
@@ -248,6 +248,66 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
}
});
} else if (section.type === 'PRODUCT_CAROUSEL' && !Array.isArray(populatedPayload.products)) {
let products: any[] = [];
const limit = payload.limit || 3;
const productInclude = {
category: true,
scent: true,
translations: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" as const }
},
images: {
orderBy: { order: "asc" as const }
}
};
if (payload.type === 'featured' || payload.collectionId === 'featured') {
products = await prisma.product.findMany({
where: { categoryId: 1 },
include: productInclude,
take: limit
});
} else if (payload.type === 'scent' && payload.scentId) {
products = await prisma.product.findMany({
where: { scentId: Number(payload.scentId) },
include: productInclude,
take: limit
});
} else if (Array.isArray(payload.productIds)) {
const ids = payload.productIds.map(Number);
products = await prisma.product.findMany({
where: { id: { in: ids } },
include: productInclude
});
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
} else {
products = await prisma.product.findMany({
include: productInclude,
take: limit
});
}
populatedPayload.products = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations?.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return { ...p, name: nameObj, description: descObj, details: detailsObj };
}
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
});
} else if (section.type === 'CATEGORIES') {
let categories: any[] = [];
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
@@ -328,13 +388,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
})
.use(adminOnly)
.post("/", async ({ body, set }) => {
const data = body as any;
const operation = data?.id ? "update" : "create";
try {
const data = body as any;
const { id, slug, title, isPublished, status, metadata } = data;
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
set.status = 400;
return { error: "Reserved slug cannot be used" };
return {
success: false,
operation,
pageId: id ? Number(id) : null,
slug,
title,
error: "Reserved slug cannot be used",
diagnostic: "The requested slug is reserved and cannot be used for dynamic pages."
};
}
let finalIsPublished = true;
@@ -344,20 +413,39 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
finalIsPublished = isPublished;
}
if (id) {
return await prisma.page.update({
const page = id
? await prisma.page.update({
where: { id: Number(id) },
data: { slug, title, isPublished: finalIsPublished }
});
} else {
return await prisma.page.create({
})
: await prisma.page.create({
data: { slug, title, isPublished: finalIsPublished }
});
}
return {
success: true,
operation,
pageId: page.id,
slug: page.slug,
title: page.title,
isPublished: page.isPublished,
page,
diagnostic: operation === "create"
? "Page created successfully."
: "Page updated successfully."
};
} catch (err) {
console.error("[Pages] Error:", err);
set.status = 500;
return { error: "Failed to save page" };
return {
success: false,
operation,
pageId: data?.id ? Number(data.id) : null,
slug: data?.slug,
title: data?.title,
error: "Failed to save page",
diagnostic: err instanceof Error ? err.message : "Unexpected error while saving page."
};
}
}, {
body: t.Object({
+97 -44
View File
@@ -55,92 +55,96 @@ const localizedStringListSchema = {
items: localizedStringSchema
};
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
const localizedStringListExample = `[ ${localizedStringExample} ]`;
const SECTION_TOOL_CONFIGS = [
{
name: "update_hero_article",
sectionType: "HERO_ARTICLE",
description: "Update a HERO_ARTICLE section with structured editorial fields.",
description: `Update a HERO_ARTICLE section. Use localized objects for title, subtitle, author, and category. Use plain strings for image URLs, overlayColor, publishDate/date, and buttonLink. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample}, "imageUrl": "https://..." }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: "Localized article title" },
subtitle: { ...localizedStringSchema, description: "Localized subtitle" },
author: { ...localizedStringSchema, description: "Localized author name" },
category: { ...localizedStringSchema, description: "Localized category or tag" },
publishDate: { type: "string", description: "Publish date or display date" },
date: { type: "string", description: "Alternative date field accepted by backend" },
imageUrl: { type: "string", description: "Main image URL" },
backgroundImageUrl: { type: "string", description: "Background image URL" },
overlayColor: { type: "string", description: "Overlay color in hex or rgba" }
title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` },
subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` },
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` },
publishDate: { type: "string", description: "Plain string publish date or display date" },
date: { type: "string", description: "Plain string alternative date field accepted by backend" },
imageUrl: { type: "string", description: "Plain string main image URL" },
backgroundImageUrl: { type: "string", description: "Plain string background image URL" },
overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" }
},
required: ["sectionId"]
},
{
name: "update_rich_text",
sectionType: "RICH_TEXT",
description: "Update a RICH_TEXT section with localized title and content.",
description: `Update a RICH_TEXT section. Title is optional and localized. Content is required and may be HTML or plain text per language. Example payload: { "sectionId": 34, "title": ${localizedStringExample}, "content": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: "Localized title displayed above the content" },
content: { ...localizedStringSchema, description: "Localized HTML or plain text content" }
title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` },
content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` }
},
required: ["sectionId", "content"]
},
{
name: "update_scent_notes_breakdown",
sectionType: "SCENT_NOTES_BREAKDOWN",
description: "Update a SCENT_NOTES_BREAKDOWN section with localized title, description, and note groups.",
description: `Update a SCENT_NOTES_BREAKDOWN section. Title and description are localized. Each note item can be a plain string or a localized object. Use either heartNotes or middleNotes; the backend mirrors whichever one you provide so both stay in sync. Example payload: { "sectionId": 40, "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: "Localized title" },
description: { ...localizedStringSchema, description: "Localized supporting description" },
topNotes: { type: "array", items: localizedStringSchema, description: "Top note values" },
heartNotes: { type: "array", items: localizedStringSchema, description: "Heart note values" },
middleNotes: { type: "array", items: localizedStringSchema, description: "Middle note values" },
baseNotes: { type: "array", items: localizedStringSchema, description: "Base note values" }
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` },
heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` }
},
required: ["sectionId"]
},
{
name: "update_quote_callout",
sectionType: "QUOTE_CALLOUT",
description: "Update a QUOTE_CALLOUT section with a localized quote, author, and role.",
description: `Update a QUOTE_CALLOUT section. Quote is required and localized. Author and role are optional localized fields. Example payload: { "sectionId": 48, "quote": ${localizedStringExample}, "author": ${localizedStringExample}, "role": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
quote: { ...localizedStringSchema, description: "Localized quote text" },
author: { ...localizedStringSchema, description: "Localized author name" },
role: { ...localizedStringSchema, description: "Localized author role or title" }
quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` },
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` }
},
required: ["sectionId"]
},
{
name: "update_product_carousel",
sectionType: "PRODUCT_CAROUSEL",
description: "Update a PRODUCT_CAROUSEL section with localized copy and carousel options.",
description: `Update a PRODUCT_CAROUSEL section. Title and description are localized. collectionId, limit, showPrice, showAddToCart, and products are plain values. If products is omitted, backend can auto-populate products on read-back using collectionId/type/productIds/limit. Example payload: { "sectionId": 52, "title": ${localizedStringExample}, "collectionId": "summer", "limit": 6, "showPrice": true }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: "Localized title" },
description: { ...localizedStringSchema, description: "Localized supporting description" },
collectionId: { type: "string", description: "Optional collection identifier" },
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
collectionId: { type: "string", description: "Optional plain string collection identifier" },
limit: { type: "number", description: "Maximum number of products to show" },
showPrice: { type: "boolean", description: "Show product price" },
showAddToCart: { type: "boolean", description: "Show add to cart button" }
showAddToCart: { type: "boolean", description: "Show add to cart button" },
products: { type: "array", description: "Optional explicit product list. Omit this to let backend auto-populate on read-back.", items: { type: "object" } }
},
required: ["sectionId"]
},
{
name: "update_accordion",
sectionType: "ACCORDION",
description: "Update an ACCORDION section with categories, questions, and localized answers.",
description: `Update an ACCORDION section. title is optional and localized. categories is required. Each category needs a localized name, optional icon, and at least one question. Each question needs localized q and a fields. Example payload: { "sectionId": 60, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: "Localized section title" },
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
categories: {
type: "array",
description: "Accordion categories",
items: {
type: "object",
properties: {
name: { ...localizedStringSchema, description: "Localized category name" },
name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` },
icon: { type: "string", description: "Optional icon or emoji" },
questions: {
type: "array",
@@ -148,8 +152,8 @@ const SECTION_TOOL_CONFIGS = [
items: {
type: "object",
properties: {
q: { ...localizedStringSchema, description: "Localized question" },
a: { ...localizedStringSchema, description: "Localized answer" }
q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` },
a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` }
},
required: ["q", "a"]
}
@@ -164,25 +168,25 @@ const SECTION_TOOL_CONFIGS = [
{
name: "update_contact_info",
sectionType: "CONTACT_INFO",
description: "Update a CONTACT_INFO section with localized address, email, line, and hours.",
description: `Update a CONTACT_INFO section. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: "Localized section title" },
address: { ...localizedStringSchema, description: "Localized address" },
email: { ...localizedStringSchema, description: "Localized email" },
line: { ...localizedStringSchema, description: "Localized Line ID or contact" },
hours: { ...localizedStringSchema, description: "Localized business hours" }
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` },
email: { type: "string", description: "Plain string email address" },
line: { type: "string", description: "Plain string Line ID or contact" },
hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` }
},
required: ["sectionId", "address", "email", "line", "hours"]
},
{
name: "update_contact_form",
sectionType: "CONTACT_FORM",
description: "Update a CONTACT_FORM section with localized title and subtitle.",
description: `Update a CONTACT_FORM section. title and subtitle are localized; this block has no extra fields. Example payload: { "sectionId": 73, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: "Localized form title" },
subtitle: { ...localizedStringSchema, description: "Localized form subtitle" }
title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` },
subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` }
},
required: ["sectionId"]
}
@@ -203,6 +207,13 @@ function buildSectionPayload(params: any, fields: readonly string[]) {
}
function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record<string, any>) {
const notes: string[] = [];
if (sectionType === "SCENT_NOTES_BREAKDOWN") {
notes.push("heartNotes and middleNotes are mirrored automatically by the backend");
}
if (sectionType === "PRODUCT_CAROUSEL") {
notes.push("If products was omitted, the backend may populate products on read-back");
}
return {
content: [
{
@@ -214,6 +225,8 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated
sectionType,
sectionId: updatedSection?.id,
updatedFields: Object.keys(payload),
appliedPayload: payload,
notes,
section: updatedSection
},
null,
@@ -404,6 +417,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id", "lang", "title"]
}
},
{
name: "update_page_translations",
description: "Add or update multiple page title translations in one call. Use this when you want to set several languages at once instead of calling update_page_translation repeatedly.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Page ID" },
translations: {
type: "object",
description: "Language-code map for the page title, for example { \"th\": \"...\", \"en\": \"...\" }. Each key becomes or updates one locale."
}
},
required: ["id", "translations"]
}
},
{
name: "update_hero_translation",
description: "Add or update translations for a specific hero.",
@@ -827,6 +855,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_page_translations": {
const { id, translations } = params as any;
const res = await api.patch(`/pages/${id}/translations`, {
title: translations
});
return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: id, translations: res.data?.translations ?? res.data, page: res.data?.page ?? res.data }, null, 2) }] };
}
case "update_hero_translation": {
const { id, lang, tagline, title, description, buttonText } = params as any;
const payload: any = {};
@@ -984,7 +1019,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
payload.id = params.id;
}
const res = await api.post("/pages", payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
const pageData = res.data?.page ?? res.data;
const normalized = {
success: res.data?.success ?? ((res.status >= 200) && (res.status < 300)),
operation: res.data?.operation ?? (params.id ? "update" : "create"),
pageId: res.data?.pageId ?? pageData?.id ?? null,
slug: res.data?.slug ?? pageData?.slug ?? params.slug,
title: res.data?.title ?? pageData?.title ?? params.title,
isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? true,
diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."),
page: pageData,
};
return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
}
case "add_page_section": {
const payload: any = {
@@ -1019,6 +1065,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId"));
if (config.sectionType === "SCENT_NOTES_BREAKDOWN") {
if (payload.heartNotes !== undefined && payload.middleNotes === undefined) {
payload.middleNotes = payload.heartNotes;
} else if (payload.middleNotes !== undefined && payload.heartNotes === undefined) {
payload.heartNotes = payload.middleNotes;
}
}
const res = await api.put(`/pages/sections/${params.sectionId}`, {
type: config.sectionType,
...payload
@@ -2,9 +2,19 @@
import '../lib/i18n';
import CartDrawer from './CartDrawer';
import ScrollToTop from './ScrollToTop';
import { ReactNode } from 'react';
import { useEffect, type ReactNode } from 'react';
import i18n from '../lib/i18n';
export default function ClientProviders({ children }: { children: ReactNode }) {
useEffect(() => {
const savedLang = localStorage.getItem('homaroma_lang')
// Hydrate from the user's persisted language only after the first client render.
if (savedLang && savedLang !== i18n.language) {
void i18n.changeLanguage(savedLang)
}
}, [])
return (
<>
<ScrollToTop />
+13 -6
View File
@@ -1,4 +1,6 @@
"use client";
import { useState, useEffect } from 'react'
import Link from 'next/link'
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
import { useTranslation } from 'react-i18next'
@@ -20,12 +22,17 @@ export default function Footer({
}: FooterProps) {
const { t } = useTranslation()
const lang = useLang()
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// Before mount: always 'th' to match SSR output → no hydration mismatch
// After mount: real user language → correct content
const effectiveLang = mounted ? lang : 'th'
// Translate a menu item label using its translations array or i18n key map
const translateMenuLabel = (item: MenuItem): string => {
// Use outer `lang` from closure — hooks cannot be called inside nested functions
if (lang !== 'en' && item.translations && item.translations.length > 0) {
const trans = item.translations.find((tr: any) => tr.langCode === lang)
if (effectiveLang !== 'en' && item.translations && item.translations.length > 0) {
const trans = item.translations.find((tr: any) => tr.langCode === effectiveLang)
if (trans?.label) return trans.label
}
const map: Record<string, string> = {
@@ -56,7 +63,7 @@ export default function Footer({
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
</div>
<p className="text-sm leading-relaxed text-gray-600">
{settings?.footerDescription || t('footer.catchphrase')}
{resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}
</p>
<div className="flex gap-4">
{hasSocialLinks ? (
@@ -135,7 +142,7 @@ export default function Footer({
<div className="flex flex-col space-y-2 text-sm text-gray-600">
{categories.map((c) => (
<Link key={c.id} href={`/category/${c.id}`} className="transition-colors hover:text-pop-pink">
{resolveLocalized(c.name)}
{resolveLocalized(c.name, effectiveLang)}
</Link>
))}
</div>
@@ -147,7 +154,7 @@ export default function Footer({
{settings?.footerSupportHeading
? (typeof settings.footerSupportHeading === 'string'
? settings.footerSupportHeading
: settings.footerSupportHeading[lang]
: settings.footerSupportHeading[effectiveLang]
|| settings.footerSupportHeading.en
|| 'Support & Legal')
: t('footer.support', 'Support & Legal')}
@@ -165,7 +172,7 @@ export default function Footer({
{settings?.footerCopyrightText
? (typeof settings.footerCopyrightText === 'string'
? settings.footerCopyrightText
: settings.footerCopyrightText[lang]
: settings.footerCopyrightText[effectiveLang]
|| settings.footerCopyrightText.en
|| `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`)
: `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`}
+11 -10
View File
@@ -30,13 +30,14 @@ const LANGUAGES = [
]
function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
const categoryLinks = categories.map((category) => ({
label: {
th: resolveLocalized(category.name, 'th') || resolveLocalized(category.name),
en: resolveLocalized(category.name, 'en') || resolveLocalized(category.name),
},
url: `/category/${category.id}`,
}))
const categoryLinks = categories.map((category) => {
// category.name is a multilingual Record<lang,string> when fetched with lang=all
const label: Record<string, string> =
typeof category.name === 'string'
? { th: category.name, en: category.name }
: (category.name as Record<string, string>)
return { label, url: `/category/${category.id}` }
})
return [
{ id: 'home', label: { th: 'หน้าหลัก', en: 'Home' }, url: '/' },
@@ -141,7 +142,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
}
const iconButtonClass = 'rounded-full p-1.5 text-pop-black/65 transition-all hover:bg-pop-bg hover:text-pop-pink md:p-2'
const activeLanguage = LANGUAGES.find((language) => i18n.language.startsWith(language.code)) ?? LANGUAGES[0]
const activeLanguage = LANGUAGES.find((language) => lang.startsWith(language.code) || language.code.startsWith(lang)) ?? LANGUAGES[0]
return (
<>
@@ -220,7 +221,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
<div className="pointer-events-none absolute right-0 top-full w-36 pt-2 opacity-0 transition-all duration-300 group-hover:pointer-events-auto group-hover:opacity-100">
<div className="space-y-0.5 overflow-hidden rounded-xl border border-pop-black/10 bg-white/95 p-1.5 shadow-2xl backdrop-blur-xl">
{LANGUAGES.map((language) => {
const isActive = i18n.language.startsWith(language.code)
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
return (
<button
key={language.code}
@@ -365,7 +366,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
</div>
<div className="space-y-1">
{LANGUAGES.map((language) => {
const isActive = i18n.language.startsWith(language.code)
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
return (
<button
key={language.code}
@@ -4,7 +4,6 @@ import Link from 'next/link'
import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types'
import { resolveLocalized } from '../lib/localization'
import { useTranslation } from 'react-i18next'
import { useLang } from '../hooks/useLang'
import WishlistButton from './WishlistButton'
@@ -13,7 +12,6 @@ interface ProductCardProps {
}
export default function ProductCard({ product }: ProductCardProps) {
const { i18n } = useTranslation()
const lang = useLang()
const categoryName = resolveLocalized(product.category.name, lang)
const productName = resolveLocalized(product.name, lang) || product.scent.name
+23 -10
View File
@@ -1,21 +1,34 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import i18n from '../lib/i18n'
/**
* Returns the current i18n language code (2-char) in a hydration-safe way.
* Returns a hydration-safe 2-char language code.
*
* On the server and during initial client render the value is always 'th'
* (matching the SSR output). After hydration it updates to the user's saved
* language so all components re-render with the correct locale without
* triggering a hydration mismatch.
* WHY this pattern is necessary:
* - i18n.ts reads localStorage synchronously at module-load time (before React hydrates)
* - So i18n.language may already be 'ja', 'ko', etc. when client components first render
* - useTranslation() subscribes to this and causes components to render the user's language
* DURING hydration, but the server rendered 'th' — causing a mismatch error
*
* SOLUTION:
* - Always return 'th' until the component has mounted (matching the server)
* - After mount, sync to the real language and subscribe to future changes
* - Components using this hook NEVER cause a hydration mismatch
*/
export function useLang(): string {
const { i18n } = useTranslation()
const [lang, setLang] = useState('th') // must match server default
// Always 'th' on first render (matches SSR) — updated post-hydration
const [lang, setLang] = useState('th')
useEffect(() => {
setLang(i18n.language ? i18n.language.substring(0, 2) : 'th')
}, [i18n.language])
// Sync immediately after hydration
const current = i18n.language ? i18n.language.substring(0, 2) : 'th'
setLang(current)
// Subscribe to future language changes (user clicks language button)
const handler = (lng: string) => setLang(lng.substring(0, 2))
i18n.on('languageChanged', handler)
return () => { i18n.off('languageChanged', handler) }
}, [])
return lang
}
+1 -4
View File
@@ -8,9 +8,6 @@ import zhTranslation from '../locales/zh/translation.json'
import viTranslation from '../locales/vi/translation.json'
import koTranslation from '../locales/ko/translation.json'
// Ensure we only access localStorage on the client side
const savedLang = typeof window !== 'undefined' ? (localStorage.getItem('homaroma_lang') || 'th') : 'th'
// Check if it's already initialized to prevent re-initialization on HMR
if (!i18n.isInitialized) {
i18n
@@ -24,7 +21,7 @@ if (!i18n.isInitialized) {
vi: { translation: viTranslation },
ko: { translation: koTranslation },
},
lng: savedLang, // default language
lng: 'th',
fallbackLng: 'en',
interpolation: {
escapeValue: false, // react already safes from xss