x
This commit is contained in:
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 />
|
||||
|
||||
@@ -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')}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user