manual save(2026-01-25 21:50)

This commit is contained in:
SiteAgent Bot
2026-01-25 21:50:22 +08:00
parent 0e7a15d2f8
commit 0f2840d59d
10 changed files with 166 additions and 26 deletions

View File

@@ -49,7 +49,9 @@ export const Footer: React.FC = () => {
<p>
{locale === 'zh'
? '湘菜 · 辣度可调'
: 'อาหารหูหนาน (湘菜) · ปรับระดับความเผ็ดได้'}
: locale === 'en'
? 'Hunan cuisine · Adjustable spice'
: 'อาหารหูหนาน (湘菜) · ปรับระดับความเผ็ดได้'}
</p>
</div>
</div>

View File

@@ -56,7 +56,7 @@ export const Header: React.FC = () => {
role="group"
aria-label="Language"
>
<button
<button
type="button"
onClick={() => setLocale('th')}
className={
@@ -80,6 +80,18 @@ export const Header: React.FC = () => {
>
{t('lang.zh')}
</button>
<button
type="button"
onClick={() => setLocale('en')}
className={
locale === 'en'
? 'rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white'
: 'rounded-lg px-3 py-1.5 text-sm font-semibold text-zinc-700 hover:bg-zinc-100'
}
aria-pressed={locale === 'en'}
>
{t('lang.en')}
</button>
</div>
</div>
</div>
@@ -96,4 +108,3 @@ export const Header: React.FC = () => {
</header>
)
}

View File

@@ -7,7 +7,7 @@ const STORAGE_KEY = 'locale'
export const I18nProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [locale, setLocaleState] = useState<Locale>(() => {
const fromStorage = window.localStorage.getItem(STORAGE_KEY)
if (fromStorage === 'th' || fromStorage === 'zh') return fromStorage
if (fromStorage === 'th' || fromStorage === 'zh' || fromStorage === 'en') return fromStorage
return DEFAULT_LOCALE
})
@@ -28,7 +28,9 @@ export const I18nProvider: React.FC<React.PropsWithChildren> = ({ children }) =>
document.title =
locale === 'zh'
? '湘味小馆 | 正宗湘菜在泰国'
: 'Xiang Hunan Kitchen | อาหารหูหนานแท้ในไทย'
: locale === 'en'
? 'Xiang Hunan Kitchen | Authentic Hunan Cuisine'
: 'Xiang Hunan Kitchen | อาหารหูหนานแท้ในไทย'
}, [locale])
const value = useMemo<I18nContextValue>(
@@ -42,4 +44,3 @@ export const I18nProvider: React.FC<React.PropsWithChildren> = ({ children }) =>
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}

View File

@@ -1,4 +1,4 @@
export type Locale = 'th' | 'zh'
export type Locale = 'th' | 'zh' | 'en'
export const DEFAULT_LOCALE: Locale = 'th'
@@ -13,6 +13,7 @@ export const TRANSLATIONS: Record<Locale, Translations> = {
'nav.news': 'ข่าวสาร',
'lang.th': 'ไทย',
'lang.zh': '中文',
'lang.en': 'EN',
'home.hero.title': 'อาหารหูหนาน (湘菜) รสจัด เผ็ดหอม กลมกล่อม',
'home.hero.desc':
@@ -87,9 +88,19 @@ export const TRANSLATIONS: Record<Locale, Translations> = {
'common.call': 'โทร',
'common.contact': 'ติดต่อ',
'common.loadFailed': 'โหลดข้อมูลไม่สำเร็จ',
'common.loading': 'กำลังโหลด',
'footer.contact': 'ติดต่อ',
'footer.hours': 'เวลาเปิด-ปิด',
'home.hoursPreview': '11:0022:00',
'post.notFound': 'ไม่พบข้อมูลข่าวสาร',
'categories.title': 'หมวดหมู่',
'categories.subtitle': 'หน้านี้เป็นหน้ารวมหมวดหมู่จาก CMS (ยังไม่ได้ผูกเข้ากับเมนูอาหาร)',
'categories.view': 'ดูรายการข่าวในหมวดนี้',
'categories.empty': 'ยังไม่มีหมวดหมู่',
'categoryDetail.titleFallback': 'หมวดหมู่',
'categoryDetail.subtitle': 'ข่าวสารทั้งหมดในหมวดนี้',
'categoryDetail.empty': 'ยังไม่มีข่าวสารในหมวดนี้',
},
zh: {
'nav.home': '首页',
@@ -99,6 +110,7 @@ export const TRANSLATIONS: Record<Locale, Translations> = {
'nav.news': '新闻/活动',
'lang.th': 'ไทย',
'lang.zh': '中文',
'lang.en': 'EN',
'home.hero.title': '正宗湘菜(湘菜) 香辣鲜香,层次分明',
'home.hero.desc':
@@ -167,8 +179,116 @@ export const TRANSLATIONS: Record<Locale, Translations> = {
'common.call': '拨打电话',
'common.contact': '联系',
'common.loadFailed': '加载失败',
'common.loading': '加载中',
'footer.contact': '联系',
'footer.hours': '营业时间',
'home.hoursPreview': '11:0022:00',
'post.notFound': '未找到该新闻内容',
'categories.title': '分类',
'categories.subtitle': '此页面展示 CMS 中的分类(尚未与菜品菜单关联)',
'categories.view': '查看该分类下的新闻',
'categories.empty': '暂无分类',
'categoryDetail.titleFallback': '分类',
'categoryDetail.subtitle': '该分类下的全部新闻',
'categoryDetail.empty': '该分类下暂无新闻',
},
en: {
'nav.home': 'Home',
'nav.menu': 'Menu',
'nav.about': 'About',
'nav.contact': 'Contact',
'nav.news': 'News',
'lang.th': 'TH',
'lang.zh': '中文',
'lang.en': 'EN',
'home.hero.title': 'Authentic Hunan Cuisine, Bold and Fragrant',
'home.hero.desc':
'A Hunan (Xiang) restaurant in Thailand with aromatic dry chilies and customizable heat - perfect for family meals and catch-ups with friends.',
'home.hero.cta.menu': 'View menu',
'home.hero.cta.contact': 'Reserve / Contact',
'home.section.latestNews': 'Latest news',
'home.section.latestNews.desc': 'Promotions, new dishes, and store updates',
'home.section.latestNews.more': 'See all',
'home.emptyNews': 'No news yet',
'home.cta.title': 'Ready for a real Xiang kick?',
'home.cta.desc':
'Call or message us on Line to reserve a table - we will help you pick the right spice level.',
'home.cta.call': 'Call to reserve',
'home.cta.map': 'See map & hours',
'home.feature.1.title': 'Signature dry-chili aroma',
'home.feature.1.desc': 'Stir-fried over high heat with bold spice and layered fragrance.',
'home.feature.2.title': 'Made for sharing',
'home.feature.2.desc': 'Perfect for sharing plates with friends and family.',
'home.feature.3.title': 'Easy reservations',
'home.feature.3.desc': 'Call or Line us for a table or dish recommendations.',
'menu.title': 'Menu',
'menu.subtitle':
'Bold Hunan flavors with aromatic dry chilies. Spice level can be adjusted to your taste.',
'menu.allergy.title': 'Allergies or dietary needs?',
'menu.allergy.desc':
'Tell our staff before ordering. We can adjust recipes and recommend suitable dishes.',
'menu.allergy.call': 'Call us',
'menu.allergy.directions': 'Directions',
'about.title': 'Our story',
'about.subtitle':
'We want more people in Thailand to experience the charm of Hunan cuisine: fragrant dry chilies, balanced heat, and friendly service.',
'about.occasions': 'Perfect for',
'about.cta.title': 'Not sure what to order?',
'about.cta.desc':
'Call or Line us. Tell us your group size and spice preference - we will suggest a set.',
'about.cta.call': 'Call to reserve',
'about.cta.contact': 'Contact & directions',
'contact.title': 'Contact & directions',
'contact.subtitle': 'Reservations, menu questions, or spice recommendations - reach us anytime.',
'contact.shopInfo': 'Store info',
'contact.phone': 'Phone',
'contact.line': 'Line',
'contact.address': 'Address',
'contact.hours': 'Hours',
'contact.mapTitle': 'Map',
'contact.takeaway.title': 'Want takeaway?',
'contact.takeaway.desc':
'Call ahead for faster pickup, or message us on Line with your pickup time.',
'contact.takeaway.call': 'Call to order',
'contact.takeaway.line': 'Message on Line',
'news.title': 'News & promotions',
'news.subtitle': 'Updates on promotions, events, and new dishes',
'news.empty': 'No news yet',
'post.back': 'Back to news',
'post.notFound': 'Sorry, this post is not available.',
'notFound.title': 'Page not found',
'notFound.desc': 'The link may be incorrect, or the page has been moved.',
'notFound.back': 'Back to home',
'card.more': 'Read more',
'card.open': 'Open',
'common.phone': 'Phone',
'common.hours': 'Hours',
'common.call': 'Call',
'common.contact': 'Contact',
'common.loadFailed': 'Failed to load.',
'common.loading': 'Loading',
'footer.contact': 'Contact',
'footer.hours': 'Hours',
'home.hoursPreview': '11:00-22:00',
'categories.title': 'Categories',
'categories.subtitle':
'This page lists categories from the CMS (not connected to the food menu yet).',
'categories.view': 'View posts in this category',
'categories.empty': 'No categories yet',
'categoryDetail.titleFallback': 'Category',
'categoryDetail.subtitle': 'All posts in this category',
'categoryDetail.empty': 'No posts in this category yet',
},
}

View File

@@ -7,6 +7,7 @@ import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer'
import type { Category } from '../clientsdk/types.gen'
import { TENANT_API_KEY, TENANT_SLUG, API_URL } from '../config'
import { useI18n } from '../i18n/useI18n'
type ListResponse<T> = { docs: T[] }
@@ -22,6 +23,7 @@ const client = createClient({
})
export const CategoriesPage: React.FC = () => {
const { t } = useI18n()
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -41,7 +43,7 @@ export const CategoriesPage: React.FC = () => {
setCategories(response.data?.docs ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : 'โหลดข้อมูลไม่สำเร็จ')
setError(err instanceof Error ? err.message : t('common.loadFailed'))
console.error('Fetch categories failed:', err)
} finally {
setLoading(false)
@@ -49,7 +51,7 @@ export const CategoriesPage: React.FC = () => {
}
fetchCategories()
}, [])
}, [t])
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
@@ -57,9 +59,9 @@ export const CategoriesPage: React.FC = () => {
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<header className="mb-10">
<h1 className="text-4xl font-semibold tracking-tight"></h1>
<h1 className="text-4xl font-semibold tracking-tight">{t('categories.title')}</h1>
<p className="mt-3 max-w-2xl text-zinc-600">
CMS ()
{t('categories.subtitle')}
</p>
</header>
@@ -90,13 +92,13 @@ export const CategoriesPage: React.FC = () => {
className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm transition hover:shadow-md"
>
<h2 className="text-lg font-semibold">{category.title}</h2>
<p className="mt-2 text-sm text-zinc-600"></p>
<p className="mt-2 text-sm text-zinc-600">{t('categories.view')}</p>
</Link>
))}
</div>
{!loading && categories.length === 0 && !error ? (
<div className="py-12 text-center text-zinc-600"></div>
<div className="py-12 text-center text-zinc-600">{t('categories.empty')}</div>
) : null}
</main>

View File

@@ -10,6 +10,7 @@ import { customQuerySerializer } from '../clientsdk/querySerializer'
import type { Category, Post } from '../clientsdk/types.gen'
import { TENANT_API_KEY, TENANT_SLUG, API_URL } from '../config'
import { isCategory } from '../utils/payload'
import { useI18n } from '../i18n/useI18n'
type ListResponse<T> = { docs: T[] }
@@ -34,7 +35,7 @@ const stripHtml = (html: string): string => {
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -43,6 +44,7 @@ const formatDate = (dateString: string): string => {
export const CategoryDetail: React.FC = () => {
const { slug } = useParams<{ slug: string }>()
const { t } = useI18n()
const [posts, setPosts] = useState<Post[]>([])
const [category, setCategory] = useState<Category | null>(null)
@@ -87,7 +89,7 @@ export const CategoryDetail: React.FC = () => {
setPosts(categoryPosts)
} catch (err) {
setError(err instanceof Error ? err.message : 'โหลดข้อมูลไม่สำเร็จ')
setError(err instanceof Error ? err.message : t('common.loadFailed'))
console.error('Fetch data failed:', err)
} finally {
setLoading(false)
@@ -95,7 +97,7 @@ export const CategoryDetail: React.FC = () => {
}
fetchData()
}, [slug])
}, [slug, t])
const items = useMemo(() => posts, [posts])
@@ -106,10 +108,10 @@ export const CategoryDetail: React.FC = () => {
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<header className="mb-10">
<h1 className="text-4xl font-semibold tracking-tight">
{category?.title ?? 'หมวดหมู่'}
{category?.title ?? t('categoryDetail.titleFallback')}
</h1>
<p className="mt-3 max-w-2xl text-zinc-600">
{t('categoryDetail.subtitle')}
</p>
</header>
@@ -139,7 +141,7 @@ export const CategoryDetail: React.FC = () => {
{!loading && items.length === 0 && !error ? (
<div className="py-12 text-center text-zinc-600">
{t('categoryDetail.empty')}
</div>
) : null}
</main>

View File

@@ -34,7 +34,7 @@ const stripHtml = (html: string): string => {
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',

View File

@@ -23,11 +23,13 @@ const formatPrice = (priceThb?: number) => {
}).format(priceThb)
}
const getSpicyLabel = (locale: 'th' | 'zh', level: MenuItem['spicyLevel']) => {
return locale === 'zh' ? `辣度 ${level}/3` : `ระดับความเผ็ด ${level}/3`
const getSpicyLabel = (locale: 'th' | 'zh' | 'en', level: MenuItem['spicyLevel']) => {
if (locale === 'zh') return `辣度 ${level}/3`
if (locale === 'en') return `Spice level ${level}/3`
return `ระดับความเผ็ด ${level}/3`
}
const SpicyDots: React.FC<{ level: MenuItem['spicyLevel']; locale: 'th' | 'zh' }> = ({
const SpicyDots: React.FC<{ level: MenuItem['spicyLevel']; locale: 'th' | 'zh' | 'en' }> = ({
level,
locale,
}) => {

View File

@@ -32,7 +32,7 @@ const stripHtml = (html: string): string => {
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',

View File

@@ -25,7 +25,7 @@ const client = createClient({
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -112,7 +112,7 @@ export const PostDetail: React.FC = () => {
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-16">
<div className="rounded-2xl border border-zinc-200 bg-white p-8 text-center shadow-sm">
<p className="text-red-700">{error || 'ไม่พบข้อมูลข่าวสาร'}</p>
<p className="text-red-700">{error || t('post.notFound')}</p>
<div className="mt-6">
<Link
to="/news"