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

@@ -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