manual save(2026-01-19 16:32)

This commit is contained in:
SiteAgent Bot
2026-01-19 16:32:22 +08:00
parent a7a56ddd9c
commit 0e7a15d2f8
26 changed files with 1581 additions and 269 deletions

View File

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist', 'src/clientsdk/**']),
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
extends: [ extends: [

View File

@@ -1,10 +1,40 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="th">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-template</title>
<title>Xiang Hunan Kitchen | อาหารหูหนานแท้ในไทย</title>
<meta
name="description"
content="ร้านอาหารหูหนาน (湘菜) รสจัด กลิ่นหอมพริกแห้ง วัตถุดิบคุณภาพ เมนูซิกเนเจอร์ และโปรโมชั่นประจำสัปดาห์"
/>
<meta name="robots" content="index,follow" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Xiang Hunan Kitchen" />
<meta
property="og:description"
content="อาหารหูหนานแท้ในไทย เมนูซิกเนเจอร์ เผ็ดหอมกลมกล่อม"
/>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Restaurant",
"name": "Xiang Hunan Kitchen",
"servesCuisine": ["Hunan", "Chinese"],
"priceRange": "฿฿",
"address": {
"@type": "PostalAddress",
"streetAddress": "(กรอกที่อยู่)",
"addressLocality": "Bangkok",
"addressCountry": "TH"
},
"telephone": "+66 00 000 0000"
}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,17 +1,23 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { Home } from './pages/Home' import { Home } from './pages/Home'
import { Menu } from './pages/Menu'
import { About } from './pages/About'
import { Contact } from './pages/Contact'
import { News } from './pages/News'
import { PostDetail } from './pages/PostDetail' import { PostDetail } from './pages/PostDetail'
import { CategoriesPage } from './pages/Categories' import { NotFound } from './pages/NotFound'
import { CategoryDetail } from './pages/CategoryDetail'
function App() { function App() {
return ( return (
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/posts/:slug" element={<PostDetail />} /> <Route path="/menu" element={<Menu />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/about" element={<About />} />
<Route path="/categories/:slug" element={<CategoryDetail />} /> <Route path="/contact" element={<Contact />} />
<Route path="/news" element={<News />} />
<Route path="/news/:slug" element={<PostDetail />} />
<Route path="*" element={<NotFound />} />
</Routes> </Routes>
</Router> </Router>
) )

View File

@@ -1,13 +1,57 @@
import React from 'react' import React from 'react'
import { SITE } from '../site'
import { useI18n } from '../i18n/useI18n'
export const Footer: React.FC = () => { export const Footer: React.FC = () => {
const { t, locale } = useI18n()
return ( return (
<footer className="bg-gray-50 border-t border-gray-200 py-8 mt-12"> <footer className="mt-14 border-t border-zinc-200 bg-white">
<div className="container mx-auto px-4 text-center text-gray-600"> <div className="mx-auto w-full max-w-6xl px-4 py-10">
<p>Powered by TenantCMS</p> <div className="grid gap-8 md:grid-cols-3">
<p className="text-sm mt-2"> <div>
Using X-Tenant-Slug for multi-tenant authentication <p className="text-lg font-semibold text-zinc-900">{SITE.name}</p>
</p> <p className="mt-2 text-sm text-zinc-600">
{locale === 'zh' ? SITE.taglineZh : SITE.taglineTh}
</p>
</div>
<div>
<p className="text-sm font-semibold text-zinc-900">{t('footer.contact')}</p>
<p className="mt-2 text-sm text-zinc-600">
{t('common.phone')}: <a className="underline" href={`tel:${SITE.phone}`}>
{SITE.phone}
</a>
</p>
<p className="mt-1 text-sm text-zinc-600">Line: {SITE.lineId}</p>
<p className="mt-1 text-sm text-zinc-600">
{locale === 'zh' ? SITE.addressZh : SITE.addressTh}
</p>
</div>
<div>
<p className="text-sm font-semibold text-zinc-900">{t('footer.hours')}</p>
<ul className="mt-2 space-y-1 text-sm text-zinc-600">
{SITE.openingHoursTh.map((row) => (
<li key={row.label} className="flex justify-between gap-3">
<span>{row.label}</span>
<span className="font-medium text-zinc-900">{row.hours}</span>
</li>
))}
</ul>
</div>
</div>
<div className="mt-10 flex flex-col gap-2 border-t border-zinc-200 pt-6 text-sm text-zinc-500 sm:flex-row sm:items-center sm:justify-between">
<p>
© {new Date().getFullYear()} {SITE.name}. All rights reserved.
</p>
<p>
{locale === 'zh'
? '湘菜 · 辣度可调'
: 'อาหารหูหนาน (湘菜) · ปรับระดับความเผ็ดได้'}
</p>
</div>
</div> </div>
</footer> </footer>
) )

Binary file not shown.

View File

@@ -1,19 +1,99 @@
import React from 'react' import React from 'react'
import { Link, NavLink } from 'react-router-dom'
import { SITE } from '../site'
import { useI18n } from '../i18n/useI18n'
const NavItem: React.FC<{ to: string; label: string; end?: boolean }> = ({
to,
label,
end,
}) => {
return (
<NavLink
to={to}
end={end}
className={({ isActive }) =>
[
'rounded-xl px-3 py-2 text-sm font-semibold transition',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900/20',
isActive
? 'bg-zinc-900 text-white'
: 'text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900',
].join(' ')
}
>
{label}
</NavLink>
)
}
export const Header: React.FC = () => { export const Header: React.FC = () => {
const { locale, setLocale, t } = useI18n()
return ( return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-10"> <header className="sticky top-0 z-10 border-b border-zinc-200 bg-white/80 backdrop-blur">
<div className="container mx-auto px-4 py-4"> <div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-6 px-4 py-4">
<div className="flex items-center justify-between"> <Link to="/" className="flex items-baseline gap-2" aria-label={t('nav.home')}>
<h1 className="text-2xl font-bold text-gray-900"> <span className="text-lg font-semibold tracking-tight text-zinc-900">
TenantCMS <span className="text-blue-600">Demo</span> {SITE.name}
</h1> </span>
<nav className="flex items-center gap-4"> <span className="hidden text-sm font-semibold text-red-700 sm:inline">
<a href="/" className="text-gray-600 hover:text-gray-900"></a> {locale === 'zh' ? SITE.nameZh : SITE.nameTh}
<a href="/categories" className="text-gray-600 hover:text-gray-900"></a> </span>
</Link>
<div className="flex items-center gap-2">
<nav className="hidden items-center gap-1 md:flex" aria-label="main">
<NavItem to="/" label={t('nav.home')} end />
<NavItem to="/menu" label={t('nav.menu')} />
<NavItem to="/about" label={t('nav.about')} />
<NavItem to="/contact" label={t('nav.contact')} />
<NavItem to="/news" label={t('nav.news')} />
</nav> </nav>
<div
className="flex items-center rounded-xl border border-zinc-200 bg-white p-1"
role="group"
aria-label="Language"
>
<button
type="button"
onClick={() => setLocale('th')}
className={
locale === 'th'
? '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 === 'th'}
>
{t('lang.th')}
</button>
<button
type="button"
onClick={() => setLocale('zh')}
className={
locale === 'zh'
? '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 === 'zh'}
>
{t('lang.zh')}
</button>
</div>
</div> </div>
</div> </div>
<div className="border-t border-zinc-200 bg-white md:hidden">
<nav className="mx-auto flex w-full max-w-6xl flex-wrap items-center gap-1 px-4 py-2" aria-label="main">
<NavItem to="/" label={t('nav.home')} end />
<NavItem to="/menu" label={t('nav.menu')} />
<NavItem to="/about" label={t('nav.about')} />
<NavItem to="/contact" label={t('nav.contact')} />
<NavItem to="/news" label={t('nav.news')} />
</nav>
</div>
</header> </header>
) )
} }

View File

@@ -1,10 +1,13 @@
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom'
import { useI18n } from '../i18n/useI18n'
interface PostCardProps { type PostCardProps = {
title: string title: string
excerpt: string excerpt: string
category?: string category?: string
date: string date: string
to?: string
onClick?: () => void onClick?: () => void
} }
@@ -13,29 +16,65 @@ export const PostCard: React.FC<PostCardProps> = ({
excerpt, excerpt,
category, category,
date, date,
to,
onClick, onClick,
}) => { }) => {
return ( const { t } = useI18n()
<article
className="border border-gray-200 rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer bg-white" const content = (
onClick={onClick} <div className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm transition hover:shadow-md">
> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 mb-2"> {category ? (
{category && ( <span className="rounded-full bg-red-50 px-2 py-1 text-xs font-semibold text-red-700">
<span className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 rounded-full">
{category} {category}
</span> </span>
)} ) : null}
<span className="text-sm text-gray-500">{date}</span> <span className="text-sm text-zinc-500">{date}</span>
</div> </div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">{title}</h2>
<p className="text-gray-600 line-clamp-3">{excerpt}</p> <h3 className="mt-3 text-xl font-semibold leading-snug text-zinc-900">
<div className="mt-4 text-blue-600 text-sm font-medium flex items-center gap-1"> {title}
</h3>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p className="mt-2 line-clamp-3 text-sm text-zinc-600">{excerpt}</p>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
<div className="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-red-700">
{t('card.more')}
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg> </svg>
</div> </div>
</article> </div>
)
const ariaLabel = `${t('card.open')} ${title}`
if (to) {
return (
<Link to={to} className="block" aria-label={ariaLabel}>
{content}
</Link>
)
}
return (
<button
type="button"
onClick={onClick}
className="block w-full text-left"
aria-label={ariaLabel}
>
{content}
</button>
) )
} }

View File

@@ -1,3 +1,5 @@
export const TENANT_SLUG = "zitadel-example" export const TENANT_SLUG = "zitadel-example"
export const TENANT_API_KEY = "tenant_new-tenant_jau52FifQXXfnPufibP4NXXu54tHbWRQ5cEdh27j" export const TENANT_API_KEY = "tenant_new-tenant_jau52FifQXXfnPufibP4NXXu54tHbWRQ5cEdh27j"
// SDK endpoints already include `/api/*`, so baseUrl should not.
export const API_URL = "http://localhost:3000" export const API_URL = "http://localhost:3000"

View File

@@ -1,5 +1,6 @@
export const ENV = { export const ENV = {
VITE_TENANT_SLUG: "zitadel-example", VITE_TENANT_SLUG: "zitadel-example",
VITE_TENANT_API_KEY: "tenant_new-tenant_jau52FifQXXfnPufibP4NXXu54tHbWRQ5cEdh27j", VITE_TENANT_API_KEY: "tenant_new-tenant_jau52FifQXXfnPufibP4NXXu54tHbWRQ5cEdh27j",
VITE_API_URL: "http://localhost:3000/api", // SDK routes already include `/api/*`, so baseUrl should not.
VITE_API_URL: "http://localhost:3000",
} }

45
src/i18n/I18nProvider.tsx Normal file
View File

@@ -0,0 +1,45 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { DEFAULT_LOCALE, TRANSLATIONS, type Locale } from './translations'
import { I18nContext, type I18nContextValue } from './context'
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
return DEFAULT_LOCALE
})
const setLocale = useCallback((next: Locale) => {
setLocaleState(next)
window.localStorage.setItem(STORAGE_KEY, next)
}, [])
const t = useCallback(
(key: string) => {
return TRANSLATIONS[locale][key] ?? TRANSLATIONS[DEFAULT_LOCALE][key] ?? key
},
[locale],
)
useEffect(() => {
document.documentElement.lang = locale
document.title =
locale === 'zh'
? '湘味小馆 | 正宗湘菜在泰国'
: 'Xiang Hunan Kitchen | อาหารหูหนานแท้ในไทย'
}, [locale])
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t,
}),
[locale, setLocale, t],
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}

10
src/i18n/context.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createContext } from 'react'
import type { Locale } from './translations'
export type I18nContextValue = {
locale: Locale
setLocale: (locale: Locale) => void
t: (key: string) => string
}
export const I18nContext = createContext<I18nContextValue | null>(null)

174
src/i18n/translations.ts Normal file
View File

@@ -0,0 +1,174 @@
export type Locale = 'th' | 'zh'
export const DEFAULT_LOCALE: Locale = 'th'
export type Translations = Record<string, string>
export const TRANSLATIONS: Record<Locale, Translations> = {
th: {
'nav.home': 'หน้าแรก',
'nav.menu': 'เมนู',
'nav.about': 'เกี่ยวกับ',
'nav.contact': 'ติดต่อ',
'nav.news': 'ข่าวสาร',
'lang.th': 'ไทย',
'lang.zh': '中文',
'home.hero.title': 'อาหารหูหนาน (湘菜) รสจัด เผ็ดหอม กลมกล่อม',
'home.hero.desc':
'ร้านอาหารหูหนาน (湘菜) ที่เน้นรสเผ็ดหอม กลิ่นเครื่องเทศชัดเจน เหมาะกับทั้งมื้อครอบครัวและสังสรรค์กับเพื่อน',
'home.hero.cta.menu': 'ดูเมนู',
'home.hero.cta.contact': 'จองโต๊ะ / ติดต่อ',
'home.section.latestNews': 'ข่าวสารล่าสุด',
'home.section.latestNews.desc': 'โปรโมชั่น เมนูใหม่ และข่าวกิจกรรมของร้าน',
'home.section.latestNews.more': 'ดูทั้งหมด',
'home.emptyNews': 'ยังไม่มีข่าวสารในตอนนี้',
'home.cta.title': 'พร้อมชิมรสหูหนานแท้แล้วหรือยัง?',
'home.cta.desc':
'จองโต๊ะง่าย ๆ ผ่านโทรศัพท์หรือไลน์ แล้วมาสัมผัสความเผ็ดหอมแบบหูหนานได้เลย',
'home.cta.call': 'โทรจองโต๊ะ',
'home.cta.map': 'ดูแผนที่และเวลาเปิดร้าน',
'home.feature.1.title': 'เผ็ดหอมแบบหูหนาน',
'home.feature.1.desc': 'พริกแห้งและเครื่องเทศคั่วสด กลิ่นชัด รสมีมิติ',
'home.feature.2.title': 'เหมาะกับการแชร์',
'home.feature.2.desc': 'เมนูหลายจาน ทานร่วมกันได้ทั้งครอบครัวและเพื่อนฝูง',
'home.feature.3.title': 'จองโต๊ะง่าย',
'home.feature.3.desc': 'โทรหรือทักไลน์เพื่อจองโต๊ะและขอแนะนำเมนูได้ทันที',
'menu.title': 'เมนู',
'menu.subtitle':
'รสชาติหูหนานแท้ กลิ่นพริกแห้งและเครื่องเทศชัดเจน ปรับระดับความเผ็ดได้ตามชอบ',
'menu.allergy.title': 'มีอาการแพ้อาหาร?',
'menu.allergy.desc':
'แจ้งพนักงานก่อนสั่งอาหาร เราช่วยปรับสูตรและแนะนำเมนูที่เหมาะกับคุณได้',
'menu.allergy.call': 'โทรสอบถาม',
'menu.allergy.directions': 'ดูวิธีเดินทาง',
'about.title': 'เรื่องราวของเรา',
'about.subtitle':
'เราอยากให้ทุกคนได้สัมผัสเสน่ห์ของอาหารหูหนาน (湘菜) ที่โดดเด่นด้วยกลิ่นพริกแห้งและเครื่องเทศ รสเผ็ดหอมแต่กลมกล่อม พร้อมบริการเป็นกันเอง',
'about.occasions': 'เหมาะกับโอกาสไหน?',
'about.cta.title': 'อยากให้ช่วยแนะนำเมนู?',
'about.cta.desc':
'โทรหรือทักไลน์ได้เลย เราแนะนำเมนูตามจำนวนคนและระดับความเผ็ดที่ชอบ',
'about.cta.call': 'โทรจองโต๊ะ',
'about.cta.contact': 'ดูข้อมูลติดต่อ',
'contact.title': 'ติดต่อและการเดินทาง',
'contact.subtitle':
'จองโต๊ะ สอบถามเมนู หรือขอคำแนะนำระดับความเผ็ด ติดต่อเราได้ทุกช่องทาง',
'contact.shopInfo': 'ข้อมูลร้าน',
'contact.phone': 'โทร',
'contact.line': 'ไลน์',
'contact.address': 'ที่อยู่',
'contact.hours': 'เวลาเปิด-ปิด',
'contact.mapTitle': 'แผนที่ร้าน',
'contact.takeaway.title': 'อยากสั่งกลับบ้าน?',
'contact.takeaway.desc':
'โทรสั่งล่วงหน้าเพื่อความรวดเร็ว หรือทักไลน์แจ้งเวลารับได้เลย',
'contact.takeaway.call': 'โทรสั่งอาหาร',
'contact.takeaway.line': 'ทักไลน์',
'news.title': 'ข่าวสารและโปรโมชั่น',
'news.subtitle': 'อัปเดตเมนูใหม่ โปรโมชั่น และกิจกรรมของทางร้าน',
'news.empty': 'ยังไม่มีข่าวสารในตอนนี้',
'post.back': 'กลับหน้าข่าวสาร',
'notFound.title': 'ไม่พบหน้านี้',
'notFound.desc': 'ลิงก์อาจไม่ถูกต้อง หรือหน้านี้ถูกย้ายไปแล้ว',
'notFound.back': 'กลับหน้าแรก',
'card.more': 'ดูรายละเอียด',
'card.open': 'เปิด',
'common.phone': 'โทร',
'common.hours': 'เวลาเปิด-ปิด',
'common.call': 'โทร',
'common.contact': 'ติดต่อ',
'common.loadFailed': 'โหลดข้อมูลไม่สำเร็จ',
'footer.contact': 'ติดต่อ',
'footer.hours': 'เวลาเปิด-ปิด',
'home.hoursPreview': '11:0022:00',
},
zh: {
'nav.home': '首页',
'nav.menu': '菜单',
'nav.about': '关于我们',
'nav.contact': '联系/地址',
'nav.news': '新闻/活动',
'lang.th': 'ไทย',
'lang.zh': '中文',
'home.hero.title': '正宗湘菜(湘菜) 香辣鲜香,层次分明',
'home.hero.desc':
'面向泰国食客的湘菜餐厅:干辣椒与香辛料的香气突出,适合家庭聚餐与朋友小聚,辣度可按需调整。',
'home.hero.cta.menu': '查看菜单',
'home.hero.cta.contact': '订位/联系',
'home.section.latestNews': '最新新闻',
'home.section.latestNews.desc': '优惠活动、新菜上线与店内动态',
'home.section.latestNews.more': '查看全部',
'home.emptyNews': '暂无新闻',
'home.cta.title': '准备好来一口地道湘味了吗?',
'home.cta.desc': '电话或 Line 轻松订位,来体验香辣过瘾的湘菜。',
'home.cta.call': '电话订位',
'home.cta.map': '查看地图与营业时间',
'home.feature.1.title': '湘味重香重辣',
'home.feature.1.desc': '干辣椒与香辛料的复合香气突出,层次更丰富。',
'home.feature.2.title': '更适合分享',
'home.feature.2.desc': '多道菜拼桌更过瘾,适合家庭与朋友聚餐。',
'home.feature.3.title': '订位更方便',
'home.feature.3.desc': '电话或 Line 一键订位,也可按口味推荐菜品。',
'menu.title': '菜单',
'menu.subtitle': '湘菜重香重辣,干辣椒香气明显;可按口味调整辣度。',
'menu.allergy.title': '有过敏/忌口?',
'menu.allergy.desc': '下单前告知店员,我们可协助调整做法并推荐合适菜品。',
'menu.allergy.call': '电话咨询',
'menu.allergy.directions': '查看路线',
'about.title': '我们的故事',
'about.subtitle':
'我们希望让更多人体验湘菜的魅力:干辣椒与香辛料的复合香气,香辣但不失平衡,并以轻松友好的服务呈现。',
'about.occasions': '适合哪些场景?',
'about.cta.title': '不知道怎么点?',
'about.cta.desc': '电话或 Line 联系我们,按人数与辣度偏好帮你配菜。',
'about.cta.call': '电话订位',
'about.cta.contact': '查看联系方式',
'contact.title': '联系与到店',
'contact.subtitle': '订位、咨询菜单、辣度建议,都可以随时联系我们。',
'contact.shopInfo': '门店信息',
'contact.phone': '电话',
'contact.line': 'Line',
'contact.address': '地址',
'contact.hours': '营业时间',
'contact.mapTitle': '门店地图',
'contact.takeaway.title': '想打包带走?',
'contact.takeaway.desc': '建议提前电话下单,或 Line 告知取餐时间更省心。',
'contact.takeaway.call': '电话下单',
'contact.takeaway.line': 'Line 联系',
'news.title': '新闻与活动',
'news.subtitle': '更新优惠、活动与新品信息',
'news.empty': '暂无新闻',
'post.back': '返回新闻列表',
'notFound.title': '页面不存在',
'notFound.desc': '链接可能不正确,或页面已被移动。',
'notFound.back': '返回首页',
'card.more': '查看详情',
'card.open': '打开',
'common.phone': '电话',
'common.hours': '营业时间',
'common.call': '拨打电话',
'common.contact': '联系',
'common.loadFailed': '加载失败',
'footer.contact': '联系',
'footer.hours': '营业时间',
'home.hoursPreview': '11:0022:00',
},
}

10
src/i18n/useI18n.ts Normal file
View File

@@ -0,0 +1,10 @@
import { useContext } from 'react'
import { I18nContext } from './context'
export const useI18n = () => {
const ctx = useContext(I18nContext)
if (!ctx) {
throw new Error('useI18n must be used within I18nProvider')
}
return ctx
}

View File

@@ -2,7 +2,32 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
:root {
color-scheme: light;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Noto Sans Thai",
"Noto Sans",
"Helvetica Neue",
Arial,
"Apple Color Emoji",
"Segoe UI Emoji";
background: #fafafa;
}
a:not([class]) {
color: inherit;
}
* {
scroll-behavior: smooth;
} }

View File

@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { I18nProvider } from './i18n/I18nProvider'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <I18nProvider>
<App />
</I18nProvider>
</StrictMode>, </StrictMode>,
) )

133
src/pages/About.tsx Normal file
View File

@@ -0,0 +1,133 @@
import React from 'react'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { SITE } from '../site'
import { useI18n } from '../i18n/useI18n'
const VALUES = {
th: [
{
title: 'รสชาติหูหนานแท้',
description:
'เน้นกลิ่นเครื่องเทศ พริกแห้ง และการผัดไฟแรง ให้รสเผ็ดหอมมีมิติ',
},
{
title: 'วัตถุดิบสดใหม่',
description:
'คัดวัตถุดิบคุณภาพ ทำซอสและน้ำพริกในครัวทุกวัน เพื่อรสชาติที่สม่ำเสมอ',
},
{
title: 'ปรับระดับความเผ็ดได้',
description:
'เราช่วยปรับความเผ็ดและแนะนำเมนูที่เหมาะกับคนทานเผ็ดน้อยหรือมาก',
},
],
zh: [
{
title: '正宗湘味',
description: '重香重辣,干辣椒与香辛料香气更突出,锅气十足。',
},
{
title: '新鲜食材',
description: '精选优质食材,酱料与辣酱每日现做,味道更稳定。',
},
{
title: '辣度可调',
description: '可按口味调整辣度,也能为不太能吃辣的朋友推荐菜品。',
},
],
} as const
export const About: React.FC = () => {
const { t, locale } = useI18n()
const isZh = locale === 'zh'
const list = isZh ? VALUES.zh : VALUES.th
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<header className="grid gap-8 lg:grid-cols-2 lg:items-center">
<div>
<p className="text-sm font-semibold tracking-wide text-red-700">
{isZh ? SITE.nameZh : SITE.nameTh}
</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
{t('about.title')}
</h1>
<p className="mt-4 text-zinc-600">{t('about.subtitle')}</p>
</div>
<div className="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm">
<img
src="https://images.unsplash.com/photo-1552566626-52f8b828add9?auto=format&fit=crop&w=1400&q=80"
alt={isZh ? '中餐厅用餐环境' : 'บรรยากาศร้านอาหารจีน'}
className="h-72 w-full object-cover lg:h-96"
loading="lazy"
/>
</div>
</header>
<section className="mt-10 grid gap-6 md:grid-cols-3">
{list.map((item) => (
<div
key={item.title}
className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm"
>
<h2 className="text-lg font-semibold">{item.title}</h2>
<p className="mt-2 text-sm text-zinc-600">{item.description}</p>
</div>
))}
</section>
<section className="mt-10 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-zinc-200">
<h2 className="text-2xl font-semibold">{t('about.occasions')}</h2>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl bg-zinc-50 p-5">
<h3 className="font-semibold">
{isZh ? '家庭聚餐' : 'มื้อครอบครัว'}
</h3>
<p className="mt-1 text-sm text-zinc-600">
{isZh
? '多道菜拼桌更满足,适合不同辣度偏好。'
: 'เมนูแชร์กันหลายจาน ทั้งเผ็ดและไม่เผ็ด ทานได้ทุกวัย'}
</p>
</div>
<div className="rounded-2xl bg-zinc-50 p-5">
<h3 className="font-semibold">
{isZh ? '同事聚会 / 商务会面' : 'เลี้ยงทีม / พบปะลูกค้า'}
</h3>
<p className="mt-1 text-sm text-zinc-600">
{isZh
? '氛围轻松,可按人数推荐配菜与组合。'
: 'บรรยากาศสบาย ๆ จัดโต๊ะได้หลายขนาด พร้อมแนะนำเซตเมนู'}
</p>
</div>
</div>
</section>
<section className="mt-10 rounded-2xl bg-zinc-900 px-6 py-8 text-white">
<h2 className="text-2xl font-semibold">{t('about.cta.title')}</h2>
<p className="mt-2 max-w-2xl text-zinc-200">{t('about.cta.desc')}</p>
<div className="mt-5 flex flex-wrap gap-3">
<a
href={`tel:${SITE.phone}`}
className="inline-flex items-center justify-center rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900"
>
{t('about.cta.call')}
</a>
<a
href="/contact"
className="inline-flex items-center justify-center rounded-xl border border-white/30 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10"
>
{t('about.cta.contact')}
</a>
</div>
</section>
</main>
<Footer />
</div>
)
}

View File

@@ -1,10 +1,16 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
import { Categories } from '../clientsdk/sdk.gen' import { Categories } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client' import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer' import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config' import type { Category } from '../clientsdk/types.gen'
import { TENANT_API_KEY, TENANT_SLUG, API_URL } from '../config'
type ListResponse<T> = { docs: T[] }
type ListCategoriesResult = { data?: ListResponse<Category> }
const client = createClient({ const client = createClient({
baseUrl: API_URL, baseUrl: API_URL,
@@ -16,7 +22,7 @@ const client = createClient({
}) })
export const CategoriesPage: React.FC = () => { export const CategoriesPage: React.FC = () => {
const [categories, setCategories] = useState<any[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -26,17 +32,17 @@ export const CategoriesPage: React.FC = () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
const response = await Categories.listCategories({ const response = (await Categories.listCategories({
client, client,
query: { query: {
limit: 100, limit: 100,
}, },
}) })) as ListCategoriesResult
setCategories((response as any)?.data?.docs || []) setCategories(response.data?.docs ?? [])
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败') setError(err instanceof Error ? err.message : 'โหลดข้อมูลไม่สำเร็จ')
console.error('获取分类失败:', err) console.error('Fetch categories failed:', err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -46,46 +52,52 @@ export const CategoriesPage: React.FC = () => {
}, []) }, [])
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main className="mx-auto w-full max-w-6xl px-4 py-10">
<section className="mb-12"> <header className="mb-10">
<h2 className="text-3xl font-bold text-gray-900 mb-2">📂 </h2> <h1 className="text-4xl font-semibold tracking-tight"></h1>
<p className="text-gray-600"></p> <p className="mt-3 max-w-2xl text-zinc-600">
</section> CMS ()
</p>
</header>
{error && ( {error ? (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6"> <div
<strong></strong> {error} role="alert"
className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-800"
>
{error}
</div> </div>
)} ) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{loading {loading
? Array.from({ length: 6 }).map((_, i) => ( ? Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white p-6 rounded-lg shadow-sm animate-pulse"> <div
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div> key={i}
<div className="h-4 bg-gray-200 rounded w-1/2"></div> className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm"
>
<div className="h-6 w-3/4 rounded bg-zinc-200/70" />
<div className="mt-3 h-4 w-1/2 rounded bg-zinc-200/70" />
</div> </div>
)) ))
: categories.map((category) => ( : categories.map((category) => (
<a <Link
key={category.id} key={category.id}
href={`/categories/${category.slug}`} to={`/categories/${category.slug}`}
className="bg-white p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow" className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm transition hover:shadow-md"
> >
<h3 className="text-xl font-semibold text-gray-900 mb-2">{category.title}</h3> <h2 className="text-lg font-semibold">{category.title}</h2>
<p className="text-sm text-gray-600"></p> <p className="mt-2 text-sm text-zinc-600"></p>
</a> </Link>
))} ))}
</div> </div>
{!loading && categories.length === 0 && !error && ( {!loading && categories.length === 0 && !error ? (
<div className="text-center py-12"> <div className="py-12 text-center text-zinc-600"></div>
<p className="text-gray-500 text-lg"></p> ) : null}
</div>
)}
</main> </main>
<Footer /> <Footer />

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
@@ -7,7 +7,15 @@ import { PostCardSkeleton } from '../components/PostCardSkeleton'
import { Posts, Categories } from '../clientsdk/sdk.gen' import { Posts, Categories } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client' import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer' import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config' import type { Category, Post } from '../clientsdk/types.gen'
import { TENANT_API_KEY, TENANT_SLUG, API_URL } from '../config'
import { isCategory } from '../utils/payload'
type ListResponse<T> = { docs: T[] }
type ListPostsResult = { data?: ListResponse<Post> }
type ListCategoriesResult = { data?: ListResponse<Category> }
const client = createClient({ const client = createClient({
baseUrl: API_URL, baseUrl: API_URL,
@@ -18,10 +26,26 @@ const client = createClient({
}, },
}) })
const stripHtml = (html: string): string => {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export const CategoryDetail: React.FC = () => { export const CategoryDetail: React.FC = () => {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const [posts, setPosts] = useState<any[]>([])
const [category, setCategory] = useState<any>(null) const [posts, setPosts] = useState<Post[]>([])
const [category, setCategory] = useState<Category | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -34,7 +58,6 @@ export const CategoryDetail: React.FC = () => {
setError(null) setError(null)
const [categoriesRes, postsRes] = await Promise.all([ const [categoriesRes, postsRes] = await Promise.all([
// Use listCategories with where filter since findCategoryById doesn't support slug lookup
Categories.listCategories({ Categories.listCategories({
client, client,
query: { query: {
@@ -45,31 +68,27 @@ export const CategoryDetail: React.FC = () => {
}, },
limit: 1, limit: 1,
}, },
}), }) as Promise<ListCategoriesResult>,
Posts.listPosts({ Posts.listPosts({
client, client,
query: { query: {
limit: 100, limit: 100,
sort: '-createdAt', sort: '-createdAt',
}, },
}), }) as Promise<ListPostsResult>,
]) ])
const categoryDocs = (categoriesRes as any)?.data?.docs || [] setCategory(categoriesRes.data?.docs?.[0] ?? null)
if (categoryDocs[0]) {
setCategory(categoryDocs[0])
}
const allDocs = (postsRes as any)?.data?.docs || [] const allDocs = postsRes.data?.docs ?? []
// categories is an array, check if any category in the array matches the slug const categoryPosts = allDocs.filter((post) =>
const categoryPosts = allDocs.filter((post: any) => post.categories?.some((cat) => isCategory(cat) && cat.slug === slug),
post.categories?.some((cat: any) => cat.slug === slug)
) )
setPosts(categoryPosts) setPosts(categoryPosts)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败') setError(err instanceof Error ? err.message : 'โหลดข้อมูลไม่สำเร็จ')
console.error('获取数据失败:', err) console.error('Fetch data failed:', err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -78,68 +97,51 @@ export const CategoryDetail: React.FC = () => {
fetchData() fetchData()
}, [slug]) }, [slug])
const stripHtml = (html: string): string => { const items = useMemo(() => posts, [posts])
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: any): string | undefined => {
// categories is an array, get the first one
return post.categories?.[0]?.title
}
const handlePostClick = (postSlug: string) => {
window.location.href = `/posts/${postSlug}`
}
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main className="mx-auto w-full max-w-6xl px-4 py-10">
<section className="mb-12"> <header className="mb-10">
<h2 className="text-3xl font-bold text-gray-900 mb-2"> <h1 className="text-4xl font-semibold tracking-tight">
📂 {category?.title || '分类'} {category?.title ?? 'หมวดหมู่'}
</h2> </h1>
<p className="text-gray-600"></p> <p className="mt-3 max-w-2xl text-zinc-600">
</section>
</p>
</header>
{error && ( {error ? (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6"> <div
<strong></strong> {error} role="alert"
className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-800"
>
{error}
</div> </div>
)} ) : null}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <section className="mt-6 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{loading {loading
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />) ? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />)
: posts.map((post) => ( : items.map((post) => (
<PostCard <PostCard
key={post.id} key={post.id}
title={post.title} title={post.title}
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)} excerpt={stripHtml(post.content_html ?? '') || post.title}
category={getCategoryTitle(post)} category={category?.title}
date={formatDate(post.createdAt)} date={formatDate(post.createdAt)}
onClick={() => handlePostClick(post.slug)} to={`/news/${post.slug}`}
/> />
))} ))}
</div> </section>
{!loading && posts.length === 0 && !error && ( {!loading && items.length === 0 && !error ? (
<div className="text-center py-12"> <div className="py-12 text-center text-zinc-600">
<p className="text-gray-500 text-lg"></p>
</div> </div>
)} ) : null}
</main> </main>
<Footer /> <Footer />

130
src/pages/Contact.tsx Normal file
View File

@@ -0,0 +1,130 @@
import React from 'react'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { SITE } from '../site'
import { useI18n } from '../i18n/useI18n'
export const Contact: React.FC = () => {
const { t, locale } = useI18n()
const isZh = locale === 'zh'
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<header className="mb-10">
<p className="text-sm font-semibold tracking-wide text-red-700">
{isZh ? SITE.nameZh : SITE.nameTh}
</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
{t('contact.title')}
</h1>
<p className="mt-3 max-w-2xl text-zinc-600">{t('contact.subtitle')}</p>
</header>
<section className="grid gap-6 lg:grid-cols-5">
<div className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm lg:col-span-2">
<h2 className="text-xl font-semibold">{t('contact.shopInfo')}</h2>
<dl className="mt-5 space-y-4 text-sm">
<div>
<dt className="font-semibold text-zinc-900">{t('contact.phone')}</dt>
<dd className="mt-1">
<a className="text-red-700 underline" href={`tel:${SITE.phone}`}>
{SITE.phone}
</a>
</dd>
</div>
<div>
<dt className="font-semibold text-zinc-900">{t('contact.line')}</dt>
<dd className="mt-1">
<a
className="text-red-700 underline"
href={`https://line.me/R/ti/p/${encodeURIComponent(SITE.lineId)}`}
target="_blank"
rel="noreferrer"
>
{SITE.lineId}
</a>
</dd>
</div>
<div>
<dt className="font-semibold text-zinc-900">{t('contact.address')}</dt>
<dd className="mt-1 text-zinc-600">
{isZh ? SITE.addressZh : SITE.addressTh}
</dd>
</div>
<div>
<dt className="font-semibold text-zinc-900">{t('contact.hours')}</dt>
<dd className="mt-2 space-y-1 text-zinc-600">
{SITE.openingHoursTh.map((row) => (
<div key={row.label} className="flex justify-between gap-3">
<span>{row.label}</span>
<span className="font-medium text-zinc-900">{row.hours}</span>
</div>
))}
</dd>
</div>
</dl>
<div className="mt-6 flex flex-wrap gap-3">
<a
href={SITE.social.facebook}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
>
Facebook
</a>
<a
href={SITE.social.instagram}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
>
Instagram
</a>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm lg:col-span-3">
<iframe
title={t('contact.mapTitle')}
src={SITE.mapEmbedUrl}
className="h-[420px] w-full"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</section>
<section className="mt-10 rounded-2xl bg-zinc-900 px-6 py-8 text-white">
<h2 className="text-2xl font-semibold">{t('contact.takeaway.title')}</h2>
<p className="mt-2 max-w-2xl text-zinc-200">{t('contact.takeaway.desc')}</p>
<div className="mt-5 flex flex-wrap gap-3">
<a
href={`tel:${SITE.phone}`}
className="inline-flex items-center justify-center rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900"
>
{t('contact.takeaway.call')}
</a>
<a
href={`https://line.me/R/ti/p/${encodeURIComponent(SITE.lineId)}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-xl border border-white/30 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10"
>
{t('contact.takeaway.line')}
</a>
</div>
</section>
</main>
<Footer />
</div>
)
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
import { PostCard } from '../components/PostCard' import { PostCard } from '../components/PostCard'
@@ -6,7 +7,15 @@ import { PostCardSkeleton } from '../components/PostCardSkeleton'
import { Posts } from '../clientsdk/sdk.gen' import { Posts } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client' import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer' import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config' import type { Post } from '../clientsdk/types.gen'
import { TENANT_API_KEY, TENANT_SLUG, API_URL } from '../config'
import { SITE } from '../site'
import { isCategory } from '../utils/payload'
import { useI18n } from '../i18n/useI18n'
type ListResponse<T> = { docs: T[] }
type ListPostsResult = { data?: ListResponse<Post> }
const client = createClient({ const client = createClient({
baseUrl: API_URL, baseUrl: API_URL,
@@ -17,8 +26,35 @@ const client = createClient({
}, },
}) })
const stripHtml = (html: string): string => {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: Post): string | undefined => {
const first = post.categories?.[0]
return isCategory(first) ? first.title : undefined
}
const FEATURED_KEYS = [
{ title: 'home.feature.1.title', description: 'home.feature.1.desc' },
{ title: 'home.feature.2.title', description: 'home.feature.2.desc' },
{ title: 'home.feature.3.title', description: 'home.feature.3.desc' },
] as const
export const Home: React.FC = () => { export const Home: React.FC = () => {
const [posts, setPosts] = useState<any[]>([]) const { t, locale } = useI18n()
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -28,86 +64,171 @@ export const Home: React.FC = () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
const response = await Posts.listPosts({ const response = (await Posts.listPosts({
client, client,
query: { query: {
limit: 10, limit: 6,
sort: '-createdAt', sort: '-createdAt',
}, },
}) })) as ListPostsResult
setPosts((response as any)?.data?.docs || []) setPosts(response.data?.docs ?? [])
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败') setError(err instanceof Error ? err.message : t('common.loadFailed'))
console.error('获取文章失败:', err) console.error('Fetch posts failed:', err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
fetchPosts() fetchPosts()
}, []) }, [t])
const stripHtml = (html: string): string => { const items = useMemo(() => posts, [posts])
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: any): string | undefined => {
// categories is an array, get the first one
return post.categories?.[0]?.title
}
const handlePostClick = (slug: string) => {
window.location.href = `/posts/${slug}`
}
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main className="mx-auto w-full max-w-6xl px-4">
<section className="mb-12"> <section className="grid gap-10 py-12 lg:grid-cols-2 lg:items-center">
<h2 className="text-3xl font-bold text-gray-900 mb-2">📚 </h2> <div>
<p className="text-gray-600"></p> <p className="text-sm font-semibold tracking-wide text-red-700">
{locale === 'zh' ? SITE.nameZh : SITE.nameTh}
</p>
<h1 className="mt-3 text-5xl font-semibold tracking-tight">
{t('home.hero.title')}
</h1>
<p className="mt-4 max-w-xl text-zinc-600">{t('home.hero.desc')}</p>
<div className="mt-7 flex flex-wrap gap-3">
<Link
to="/menu"
className="inline-flex items-center justify-center rounded-xl bg-zinc-900 px-5 py-3 text-sm font-semibold text-white"
>
{t('home.hero.cta.menu')}
</Link>
<Link
to="/contact"
className="inline-flex items-center justify-center rounded-xl border border-zinc-200 bg-white px-5 py-3 text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
>
{t('home.hero.cta.contact')}
</Link>
</div>
<dl className="mt-8 grid max-w-xl grid-cols-2 gap-4 text-sm">
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
<dt className="font-semibold text-zinc-900">{t('common.phone')}</dt>
<dd className="mt-1 text-zinc-600">
<a className="underline" href={`tel:${SITE.phone}`}>
{SITE.phone}
</a>
</dd>
</div>
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
<dt className="font-semibold text-zinc-900">{t('common.hours')}</dt>
<dd className="mt-1 text-zinc-600">
{t('home.hoursPreview')}
</dd>
</div>
</dl>
</div>
<div className="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm">
<img
src="https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=1400&q=80"
alt="อาหารจีนรสจัดเสิร์ฟบนโต๊ะ"
className="h-80 w-full object-cover lg:h-[520px]"
loading="lazy"
/>
</div>
</section> </section>
{error && ( <section className="grid gap-6 md:grid-cols-3">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6"> {FEATURED_KEYS.map((item) => (
<strong></strong> {error} <div
</div> key={item.title}
)} className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm"
>
<h2 className="text-lg font-semibold">{t(item.title)}</h2>
<p className="mt-2 text-sm text-zinc-600">{t(item.description)}</p>
</div>
))}
</section>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <section className="mt-12" aria-labelledby="latest-news">
{loading <div className="flex items-end justify-between gap-4">
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />) <div>
: posts.map((post) => ( <h2 id="latest-news" className="text-2xl font-semibold tracking-tight">
<PostCard {t('home.section.latestNews')}
key={post.id} </h2>
title={post.title} <p className="mt-2 text-sm text-zinc-600">
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)} {t('home.section.latestNews.desc')}
category={getCategoryTitle(post)} </p>
date={formatDate(post.createdAt)} </div>
onClick={() => handlePostClick(post.slug)} <Link
/> to="/news"
))} className="text-sm font-semibold text-red-700 hover:underline"
</div> >
{t('home.section.latestNews.more')}
{!loading && posts.length === 0 && !error && ( </Link>
<div className="text-center py-12">
<p className="text-gray-500 text-lg"></p>
</div> </div>
)}
{error ? (
<div
role="alert"
className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-800"
>
{error}
</div>
) : null}
<div className="mt-6 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{loading
? Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))
: items.map((post) => (
<PostCard
key={post.id}
title={post.title}
excerpt={stripHtml(post.content_html ?? '') || post.title}
category={getCategoryTitle(post)}
date={formatDate(post.createdAt)}
to={`/news/${post.slug}`}
/>
))}
</div>
{!loading && items.length === 0 && !error ? (
<div className="py-12 text-center text-zinc-600">
{t('home.emptyNews')}
</div>
) : null}
</section>
<section className="mt-14 rounded-3xl bg-zinc-900 px-6 py-10 text-white">
<h2 className="text-3xl font-semibold tracking-tight">
{t('home.cta.title')}
</h2>
<p className="mt-3 max-w-2xl text-zinc-200">{t('home.cta.desc')}</p>
<div className="mt-6 flex flex-wrap gap-3">
<a
href={`tel:${SITE.phone}`}
className="inline-flex items-center justify-center rounded-xl bg-white px-5 py-3 text-sm font-semibold text-zinc-900"
>
{t('home.cta.call')}
</a>
<Link
to="/contact"
className="inline-flex items-center justify-center rounded-xl border border-white/30 px-5 py-3 text-sm font-semibold text-white hover:bg-white/10"
>
{t('home.cta.map')}
</Link>
</div>
</section>
<div className="h-14" />
</main> </main>
<Footer /> <Footer />

243
src/pages/Menu.tsx Normal file
View File

@@ -0,0 +1,243 @@
import React from 'react'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { SITE } from '../site'
import { useI18n } from '../i18n/useI18n'
type MenuItem = {
nameTh: string
nameZh: string
nameEn?: string
descriptionTh: string
descriptionZh: string
spicyLevel: 0 | 1 | 2 | 3
priceThb?: number
}
const formatPrice = (priceThb?: number) => {
if (!priceThb) return undefined
return new Intl.NumberFormat('th-TH', {
style: 'currency',
currency: 'THB',
maximumFractionDigits: 0,
}).format(priceThb)
}
const getSpicyLabel = (locale: 'th' | 'zh', level: MenuItem['spicyLevel']) => {
return locale === 'zh' ? `辣度 ${level}/3` : `ระดับความเผ็ด ${level}/3`
}
const SpicyDots: React.FC<{ level: MenuItem['spicyLevel']; locale: 'th' | 'zh' }> = ({
level,
locale,
}) => {
const dots = Array.from({ length: 3 }, (_, i) => i < level)
return (
<div className="flex items-center gap-1" aria-label={getSpicyLabel(locale, level)}>
{dots.map((on, idx) => (
<span
key={idx}
className={
on
? 'h-2 w-2 rounded-full bg-red-600'
: 'h-2 w-2 rounded-full bg-zinc-300'
}
/>
))}
</div>
)
}
const SECTIONS: Array<{
titleTh: string
titleZh: string
subtitleTh: string
subtitleZh: string
items: MenuItem[]
}> = [
{
titleTh: 'เมนูซิกเนเจอร์',
titleZh: '招牌菜',
subtitleTh: 'จานที่ลูกค้ากลับมาซ้ำบ่อยที่สุด',
subtitleZh: '回头客点得最多的招牌必点',
items: [
{
nameTh: 'หมูผัดพริกแห้งหูหนาน',
nameZh: '湘味干辣椒小炒肉',
nameEn: 'Hunan Stir-fried Pork',
descriptionTh: 'พริกแห้งหอม ๆ ผัดไฟแรง รสเผ็ดหอมเค็มหวานกำลังดี',
descriptionZh: '干辣椒香气十足,猛火快炒,香辣开胃。',
spicyLevel: 3,
priceThb: 220,
},
{
nameTh: 'ปลากะพงนึ่งพริกสด',
nameZh: '鲜椒蒸鲈鱼',
nameEn: 'Steamed Fish with Chili',
descriptionTh: 'เนื้อปลานุ่ม ซอสพริกสดจัดจ้าน หอมกระเทียมต้นหอม',
descriptionZh: '鱼肉细嫩,鲜椒酱汁香辣,蒜香葱香更提味。',
spicyLevel: 2,
priceThb: 420,
},
{
nameTh: 'ไก่ผัดขิงหูหนาน',
nameZh: '湘味姜爆鸡',
nameEn: 'Hunan Ginger Chicken',
descriptionTh: 'ขิงสดเผ็ดร้อน หอมกลิ่นกระทะ ทานกับข้าวสวยร้อน ๆ',
descriptionZh: '鲜姜爆香,锅气十足,配米饭特别下饭。',
spicyLevel: 1,
priceThb: 190,
},
],
},
{
titleTh: 'กับข้าว',
titleZh: '热菜',
subtitleTh: 'ปรับระดับความเผ็ดได้',
subtitleZh: '可按口味调整辣度',
items: [
{
nameTh: 'ผัดผักรวมซอสกระเทียม',
nameZh: '蒜香清炒时蔬',
descriptionTh: 'ผักกรอบหวาน ผัดซอสกระเทียมหอม ๆ',
descriptionZh: '时蔬清甜爽脆,蒜香提味。',
spicyLevel: 0,
priceThb: 140,
},
{
nameTh: 'เต้าหู้ผัดซอสพริกหูหนาน',
nameZh: '湘味椒香炒豆腐',
descriptionTh: 'เต้าหู้นุ่ม ซอสพริกหูหนานเข้มข้น',
descriptionZh: '豆腐嫩滑,湘味辣酱浓郁入味。',
spicyLevel: 2,
priceThb: 160,
},
{
nameTh: 'ซี่โครงหมูอบพริกไทยดำ',
nameZh: '黑椒焖排骨',
descriptionTh: 'ซี่โครงนุ่ม ๆ ซอสพริกไทยดำเข้มข้น',
descriptionZh: '排骨软烂入味,黑胡椒酱浓香。',
spicyLevel: 1,
priceThb: 260,
},
],
},
{
titleTh: 'ข้าวและเส้น',
titleZh: '饭/面',
subtitleTh: 'จานอิ่มเร็ว เหมาะกับมื้อกลางวัน',
subtitleZh: '快速饱腹,午餐首选',
items: [
{
nameTh: 'ข้าวผัดหูหนาน',
nameZh: '湘味炒饭',
descriptionTh: 'ข้าวผัดไฟแรง กลิ่นกระทะชัด ใส่ผักและเนื้อสัตว์ตามเลือก',
descriptionZh: '大火炒制锅气足,可选配菜与肉类。',
spicyLevel: 1,
priceThb: 120,
},
{
nameTh: 'หมี่ผัดซอสเผ็ดหูหนาน',
nameZh: '湘味香辣炒面',
descriptionTh: 'เส้นเหนียวนุ่ม ซอสเผ็ดหอม ท็อปด้วยงาขาว',
descriptionZh: '面条劲道,香辣酱汁开胃,撒白芝麻更香。',
spicyLevel: 2,
priceThb: 150,
},
],
},
]
export const Menu: React.FC = () => {
const { t, locale } = useI18n()
const isZh = locale === 'zh'
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<header className="mb-10">
<p className="text-sm font-semibold tracking-wide text-red-700">
{isZh ? SITE.nameZh : SITE.nameTh}
</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
{t('menu.title')}
</h1>
<p className="mt-3 max-w-2xl text-zinc-600">{t('menu.subtitle')}</p>
</header>
<section className="grid gap-6 lg:grid-cols-3">
{SECTIONS.map((section) => (
<div
key={section.titleTh}
className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm"
>
<h2 className="text-xl font-semibold">
{isZh ? section.titleZh : section.titleTh}
</h2>
<p className="mt-1 text-sm text-zinc-600">
{isZh ? section.subtitleZh : section.subtitleTh}
</p>
<div className="mt-5 space-y-4">
{section.items.map((item) => (
<article
key={item.nameTh}
className="rounded-xl border border-zinc-100 bg-zinc-50/40 p-4"
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold leading-snug">
{(isZh ? item.nameZh : item.nameTh)}{' '}
{item.nameEn ? (
<span className="font-normal text-zinc-500">
({item.nameEn})
</span>
) : null}
</h3>
<p className="mt-1 text-sm text-zinc-600">
{isZh ? item.descriptionZh : item.descriptionTh}
</p>
</div>
<div className="shrink-0 text-right">
<SpicyDots level={item.spicyLevel} locale={locale} />
{formatPrice(item.priceThb) ? (
<p className="mt-2 text-sm font-semibold text-zinc-900">
{formatPrice(item.priceThb)}
</p>
) : null}
</div>
</div>
</article>
))}
</div>
</div>
))}
</section>
<section className="mt-10 rounded-2xl bg-zinc-900 px-6 py-8 text-white">
<h2 className="text-2xl font-semibold">{t('menu.allergy.title')}</h2>
<p className="mt-2 max-w-2xl text-zinc-200">{t('menu.allergy.desc')}</p>
<div className="mt-5 flex flex-wrap gap-3">
<a
href={`tel:${SITE.phone}`}
className="inline-flex items-center justify-center rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900"
>
{t('menu.allergy.call')}
</a>
<a
href="/contact"
className="inline-flex items-center justify-center rounded-xl border border-white/30 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10"
>
{t('menu.allergy.directions')}
</a>
</div>
</section>
</main>
<Footer />
</div>
)
}

123
src/pages/News.tsx Normal file
View File

@@ -0,0 +1,123 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { PostCard } from '../components/PostCard'
import { PostCardSkeleton } from '../components/PostCardSkeleton'
import { Posts } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer'
import type { 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[] }
type ListPostsResult = { data?: ListResponse<Post> }
const client = createClient({
baseUrl: API_URL,
querySerializer: customQuerySerializer,
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
})
const stripHtml = (html: string): string => {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: Post): string | undefined => {
const first = post.categories?.[0]
return isCategory(first) ? first.title : undefined
}
export const News: React.FC = () => {
const { t } = useI18n()
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true)
setError(null)
const response = (await Posts.listPosts({
client,
query: {
limit: 12,
sort: '-createdAt',
},
})) as ListPostsResult
setPosts(response.data?.docs ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : t('common.loadFailed'))
console.error('Fetch posts failed:', err)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [t])
const items = useMemo(() => posts, [posts])
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<header className="mb-10" aria-label={t('news.title')}>
<h1 className="text-4xl font-semibold tracking-tight">{t('news.title')}</h1>
<p className="mt-3 max-w-2xl text-zinc-600">{t('news.subtitle')}</p>
</header>
{error ? (
<div
role="alert"
className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-800"
>
{error}
</div>
) : null}
<section className="mt-6 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{loading
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />)
: items.map((post) => (
<PostCard
key={post.id}
title={post.title}
excerpt={stripHtml(post.content_html ?? '') || post.title}
category={getCategoryTitle(post)}
date={formatDate(post.createdAt)}
to={`/news/${post.slug}`}
/>
))}
</section>
{!loading && items.length === 0 && !error ? (
<div className="py-14 text-center text-zinc-600">{t('news.empty')}</div>
) : null}
</main>
<Footer />
</div>
)
}

28
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,28 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { useI18n } from '../i18n/useI18n'
export const NotFound: React.FC = () => {
const { t } = useI18n()
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header />
<main className="mx-auto w-full max-w-6xl px-4 py-16">
<h1 className="text-4xl font-semibold tracking-tight">{t('notFound.title')}</h1>
<p className="mt-3 max-w-xl text-zinc-600">{t('notFound.desc')}</p>
<div className="mt-6">
<Link
to="/"
className="inline-flex items-center justify-center rounded-xl bg-zinc-900 px-4 py-2 text-sm font-semibold text-white"
>
{t('notFound.back')}
</Link>
</div>
</main>
<Footer />
</div>
)
}

View File

@@ -1,11 +1,18 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
import { Posts } from '../clientsdk/sdk.gen' import { Posts } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client' import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer' import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config' import type { Post } from '../clientsdk/types.gen'
import { TENANT_API_KEY, TENANT_SLUG, API_URL } from '../config'
import { isCategory, isMedia } from '../utils/payload'
import { useI18n } from '../i18n/useI18n'
type ListResponse<T> = { docs: T[] }
type ListPostsResult = { data?: ListResponse<Post> }
const client = createClient({ const client = createClient({
baseUrl: API_URL, baseUrl: API_URL,
@@ -16,9 +23,24 @@ const client = createClient({
}, },
}) })
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: Post): string | undefined => {
const first = post.categories?.[0]
return isCategory(first) ? first.title : undefined
}
export const PostDetail: React.FC = () => { export const PostDetail: React.FC = () => {
const { t } = useI18n()
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const [post, setPost] = useState<any>(null) const [post, setPost] = useState<Post | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -30,8 +52,7 @@ export const PostDetail: React.FC = () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
// Use listPosts with where filter since findPostById doesn't support slug lookup const response = (await Posts.listPosts({
const response = await Posts.listPosts({
client, client,
query: { query: {
where: { where: {
@@ -41,47 +62,42 @@ export const PostDetail: React.FC = () => {
}, },
limit: 1, limit: 1,
}, },
}) })) as ListPostsResult
const docs = (response as any)?.data?.docs || [] setPost(response.data?.docs?.[0] ?? null)
setPost(docs[0] || null)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败') setError(err instanceof Error ? err.message : t('common.loadFailed'))
console.error('获取文章失败:', err) console.error('Fetch post failed:', err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
fetchPost() fetchPost()
}, [slug]) }, [slug, t])
const getCategoryTitle = (p: any): string | undefined => { const hero = useMemo(() => {
// categories is an array, get the first one if (!post) return undefined
return p?.categories?.[0]?.title return isMedia(post.heroImage) ? post.heroImage : undefined
} }, [post])
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main className="mx-auto w-full max-w-3xl px-4 py-10">
<div className="animate-pulse"> <div
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4"></div> className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-10"
<div className="h-4 bg-gray-200 rounded w-1/4 mb-6"></div> aria-label="กำลังโหลด"
<div className="space-y-3"> >
<div className="h-4 bg-gray-200 rounded"></div> <div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded"></div> <div className="h-8 rounded bg-zinc-200/70" />
<div className="h-4 bg-gray-200 rounded w-3/4"></div> <div className="mt-4 h-4 w-1/3 rounded bg-zinc-200/70" />
<div className="mt-8 space-y-3">
<div className="h-4 rounded bg-zinc-200/70" />
<div className="h-4 rounded bg-zinc-200/70" />
<div className="h-4 w-3/4 rounded bg-zinc-200/70" />
</div>
</div> </div>
</div> </div>
</main> </main>
@@ -92,17 +108,19 @@ export const PostDetail: React.FC = () => {
if (error || !post) { if (error || !post) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main className="mx-auto w-full max-w-6xl px-4 py-16">
<div className="text-center py-12"> <div className="rounded-2xl border border-zinc-200 bg-white p-8 text-center shadow-sm">
<p className="text-red-600 text-lg">{error || '文章不存在'}</p> <p className="text-red-700">{error || 'ไม่พบข้อมูลข่าวสาร'}</p>
<button <div className="mt-6">
onClick={() => window.location.href = '/'} <Link
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" to="/news"
> className="inline-flex items-center justify-center rounded-xl bg-zinc-900 px-4 py-2 text-sm font-semibold text-white"
>
</button> {t('post.back')}
</Link>
</div>
</div> </div>
</main> </main>
<Footer /> <Footer />
@@ -111,43 +129,44 @@ export const PostDetail: React.FC = () => {
} }
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header /> <Header />
<main className="container mx-auto px-4 py-8 max-w-4xl"> <main className="mx-auto w-full max-w-3xl px-4 py-10">
<article> <article className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-10">
<header className="mb-8"> <header>
{getCategoryTitle(post) && ( {getCategoryTitle(post) ? (
<span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm mb-4"> <p className="text-sm font-semibold text-red-700">
{getCategoryTitle(post)} {getCategoryTitle(post)}
</span> </p>
)} ) : null}
<h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1> <h1 className="mt-2 text-4xl font-semibold tracking-tight">
<div className="flex items-center text-gray-600 text-sm"> {post.title}
<span>{formatDate(post.createdAt)}</span> </h1>
</div> <p className="mt-3 text-sm text-zinc-500">{formatDate(post.createdAt)}</p>
</header> </header>
{post.heroImage && ( {hero?.url ? (
<img <img
src={post.heroImage.url} src={hero.url}
alt={post.heroImage.alt || post.title} alt={hero.alt || post.title}
className="w-full h-auto rounded-lg mb-8" className="mt-6 h-auto w-full rounded-2xl"
loading="lazy"
/> />
)} ) : null}
<div <div
className="prose prose-lg max-w-none" className="prose prose-zinc mt-8 max-w-none prose-headings:scroll-mt-24"
dangerouslySetInnerHTML={{ __html: post.content_html || '' }} dangerouslySetInnerHTML={{ __html: post.content_html || '' }}
/> />
<div className="mt-12 pt-8 border-t border-gray-200"> <div className="mt-10 border-t border-zinc-200 pt-6">
<button <Link
onClick={() => window.location.href = '/'} to="/news"
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300" className="inline-flex items-center justify-center rounded-xl bg-zinc-900 px-4 py-2 text-sm font-semibold text-white"
> >
{t('post.back')}
</button> </Link>
</div> </div>
</article> </article>
</main> </main>

21
src/site.ts Normal file
View File

@@ -0,0 +1,21 @@
export const SITE = {
name: 'Xiang Hunan Kitchen',
nameTh: 'เซียง หูหนาน คิทเช่น',
nameZh: '湘味小馆',
taglineTh: 'อาหารหูหนาน (湘菜) รสจัด เผ็ดหอม กลมกล่อม',
taglineZh: '正宗湘菜 香辣鲜香,层次分明',
phone: '+66 00 000 0000',
lineId: '@xianghunan',
addressTh: 'กรอกที่อยู่ร้าน (เช่น สุขุมวิท กรุงเทพฯ)',
addressZh: '请填写门店地址(例如:曼谷 素坤逸)',
openingHoursTh: [
{ label: 'จันทร์–ศุกร์', hours: '11:0022:00' },
{ label: 'เสาร์–อาทิตย์', hours: '10:3022:30' },
],
mapEmbedUrl:
'https://www.google.com/maps?q=Bangkok&output=embed',
social: {
facebook: 'https://facebook.com/',
instagram: 'https://instagram.com/',
},
} as const

11
src/utils/payload.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Category, Media } from '../clientsdk/types.gen'
export const isCategory = (value: unknown): value is Category => {
if (!value || typeof value !== 'object') return false
return 'title' in value
}
export const isMedia = (value: unknown): value is Media => {
if (!value || typeof value !== 'object') return false
return 'url' in value
}