manual save(2026-01-19 16:32)
This commit is contained in:
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'src/clientsdk/**']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
34
index.html
34
index.html
@@ -1,10 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="th">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -1,17 +1,23 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
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 { CategoriesPage } from './pages/Categories'
|
||||
import { CategoryDetail } from './pages/CategoryDetail'
|
||||
import { NotFound } from './pages/NotFound'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/posts/:slug" element={<PostDetail />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/categories/:slug" element={<CategoryDetail />} />
|
||||
<Route path="/menu" element={<Menu />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
<Route path="/news" element={<News />} />
|
||||
<Route path="/news/:slug" element={<PostDetail />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,57 @@
|
||||
import React from 'react'
|
||||
import { SITE } from '../site'
|
||||
import { useI18n } from '../i18n/useI18n'
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-50 border-t border-gray-200 py-8 mt-12">
|
||||
<div className="container mx-auto px-4 text-center text-gray-600">
|
||||
<p>Powered by TenantCMS</p>
|
||||
<p className="text-sm mt-2">
|
||||
Using X-Tenant-Slug for multi-tenant authentication
|
||||
</p>
|
||||
<footer className="mt-14 border-t border-zinc-200 bg-white">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-10">
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-zinc-900">{SITE.name}</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>
|
||||
</footer>
|
||||
)
|
||||
|
||||
BIN
src/components/Footer.tsx.bak
Normal file
BIN
src/components/Footer.tsx.bak
Normal file
Binary file not shown.
@@ -1,19 +1,99 @@
|
||||
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 = () => {
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
TenantCMS <span className="text-blue-600">Demo</span>
|
||||
</h1>
|
||||
<nav className="flex items-center gap-4">
|
||||
<a href="/" className="text-gray-600 hover:text-gray-900">首页</a>
|
||||
<a href="/categories" className="text-gray-600 hover:text-gray-900">分类</a>
|
||||
<header className="sticky top-0 z-10 border-b border-zinc-200 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-6 px-4 py-4">
|
||||
<Link to="/" className="flex items-baseline gap-2" aria-label={t('nav.home')}>
|
||||
<span className="text-lg font-semibold tracking-tight text-zinc-900">
|
||||
{SITE.name}
|
||||
</span>
|
||||
<span className="hidden text-sm font-semibold text-red-700 sm:inline">
|
||||
{locale === 'zh' ? SITE.nameZh : SITE.nameTh}
|
||||
</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>
|
||||
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useI18n } from '../i18n/useI18n'
|
||||
|
||||
interface PostCardProps {
|
||||
type PostCardProps = {
|
||||
title: string
|
||||
excerpt: string
|
||||
category?: string
|
||||
date: string
|
||||
to?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
@@ -13,29 +16,65 @@ export const PostCard: React.FC<PostCardProps> = ({
|
||||
excerpt,
|
||||
category,
|
||||
date,
|
||||
to,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<article
|
||||
className="border border-gray-200 rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer bg-white"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{category && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 rounded-full">
|
||||
const { t } = useI18n()
|
||||
|
||||
const content = (
|
||||
<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">
|
||||
{category ? (
|
||||
<span className="rounded-full bg-red-50 px-2 py-1 text-xs font-semibold text-red-700">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">{date}</span>
|
||||
) : null}
|
||||
<span className="text-sm text-zinc-500">{date}</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">{title}</h2>
|
||||
<p className="text-gray-600 line-clamp-3">{excerpt}</p>
|
||||
<div className="mt-4 text-blue-600 text-sm font-medium flex items-center gap-1">
|
||||
阅读更多
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
|
||||
<h3 className="mt-3 text-xl font-semibold leading-snug text-zinc-900">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-3 text-sm text-zinc-600">{excerpt}</p>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const TENANT_SLUG = "zitadel-example"
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ENV = {
|
||||
VITE_TENANT_SLUG: "zitadel-example",
|
||||
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
45
src/i18n/I18nProvider.tsx
Normal 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
10
src/i18n/context.ts
Normal 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
174
src/i18n/translations.ts
Normal 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:00–22: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:00–22:00',
|
||||
},
|
||||
}
|
||||
10
src/i18n/useI18n.ts
Normal file
10
src/i18n/useI18n.ts
Normal 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
|
||||
}
|
||||
@@ -2,7 +2,32 @@
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { I18nProvider } from './i18n/I18nProvider'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
133
src/pages/About.tsx
Normal file
133
src/pages/About.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Header } from '../components/Header'
|
||||
import { Footer } from '../components/Footer'
|
||||
import { Categories } from '../clientsdk/sdk.gen'
|
||||
import { createClient } from '../clientsdk/client'
|
||||
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({
|
||||
baseUrl: API_URL,
|
||||
@@ -16,7 +22,7 @@ const client = createClient({
|
||||
})
|
||||
|
||||
export const CategoriesPage: React.FC = () => {
|
||||
const [categories, setCategories] = useState<any[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -26,17 +32,17 @@ export const CategoriesPage: React.FC = () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await Categories.listCategories({
|
||||
const response = (await Categories.listCategories({
|
||||
client,
|
||||
query: {
|
||||
limit: 100,
|
||||
},
|
||||
})
|
||||
})) as ListCategoriesResult
|
||||
|
||||
setCategories((response as any)?.data?.docs || [])
|
||||
setCategories(response.data?.docs ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
console.error('获取分类失败:', err)
|
||||
setError(err instanceof Error ? err.message : 'โหลดข้อมูลไม่สำเร็จ')
|
||||
console.error('Fetch categories failed:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -46,46 +52,52 @@ export const CategoriesPage: React.FC = () => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<section className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">📂 文章分类</h2>
|
||||
<p className="text-gray-600">浏览所有分类</p>
|
||||
</section>
|
||||
<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>
|
||||
<p className="mt-3 max-w-2xl text-zinc-600">
|
||||
หน้านี้เป็นหน้ารวมหมวดหมู่จาก CMS (ยังไม่ได้ผูกเข้ากับเมนูอาหาร)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>错误:</strong> {error}
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-800"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{loading
|
||||
? Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-white p-6 rounded-lg shadow-sm animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
<div
|
||||
key={i}
|
||||
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>
|
||||
))
|
||||
: categories.map((category) => (
|
||||
<a
|
||||
<Link
|
||||
key={category.id}
|
||||
href={`/categories/${category.slug}`}
|
||||
className="bg-white p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow"
|
||||
to={`/categories/${category.slug}`}
|
||||
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>
|
||||
<p className="text-sm text-gray-600">查看该分类下的所有文章</p>
|
||||
</a>
|
||||
<h2 className="text-lg font-semibold">{category.title}</h2>
|
||||
<p className="mt-2 text-sm text-zinc-600">ดูรายการข่าวในหมวดนี้</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && categories.length === 0 && !error && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">暂无分类</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && categories.length === 0 && !error ? (
|
||||
<div className="py-12 text-center text-zinc-600">ยังไม่มีหมวดหมู่</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Header } from '../components/Header'
|
||||
import { Footer } from '../components/Footer'
|
||||
@@ -7,7 +7,15 @@ import { PostCardSkeleton } from '../components/PostCardSkeleton'
|
||||
import { Posts, Categories } from '../clientsdk/sdk.gen'
|
||||
import { createClient } from '../clientsdk/client'
|
||||
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({
|
||||
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 = () => {
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -34,7 +58,6 @@ export const CategoryDetail: React.FC = () => {
|
||||
setError(null)
|
||||
|
||||
const [categoriesRes, postsRes] = await Promise.all([
|
||||
// Use listCategories with where filter since findCategoryById doesn't support slug lookup
|
||||
Categories.listCategories({
|
||||
client,
|
||||
query: {
|
||||
@@ -45,31 +68,27 @@ export const CategoryDetail: React.FC = () => {
|
||||
},
|
||||
limit: 1,
|
||||
},
|
||||
}),
|
||||
}) as Promise<ListCategoriesResult>,
|
||||
Posts.listPosts({
|
||||
client,
|
||||
query: {
|
||||
limit: 100,
|
||||
sort: '-createdAt',
|
||||
},
|
||||
}),
|
||||
}) as Promise<ListPostsResult>,
|
||||
])
|
||||
|
||||
const categoryDocs = (categoriesRes as any)?.data?.docs || []
|
||||
if (categoryDocs[0]) {
|
||||
setCategory(categoryDocs[0])
|
||||
}
|
||||
setCategory(categoriesRes.data?.docs?.[0] ?? null)
|
||||
|
||||
const allDocs = (postsRes as any)?.data?.docs || []
|
||||
// categories is an array, check if any category in the array matches the slug
|
||||
const categoryPosts = allDocs.filter((post: any) =>
|
||||
post.categories?.some((cat: any) => cat.slug === slug)
|
||||
const allDocs = postsRes.data?.docs ?? []
|
||||
const categoryPosts = allDocs.filter((post) =>
|
||||
post.categories?.some((cat) => isCategory(cat) && cat.slug === slug),
|
||||
)
|
||||
|
||||
setPosts(categoryPosts)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
console.error('获取数据失败:', err)
|
||||
setError(err instanceof Error ? err.message : 'โหลดข้อมูลไม่สำเร็จ')
|
||||
console.error('Fetch data failed:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -78,68 +97,51 @@ export const CategoryDetail: React.FC = () => {
|
||||
fetchData()
|
||||
}, [slug])
|
||||
|
||||
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('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}`
|
||||
}
|
||||
const items = useMemo(() => posts, [posts])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<section className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
📂 {category?.title || '分类'}
|
||||
</h2>
|
||||
<p className="text-gray-600">探索该分类下的所有内容</p>
|
||||
</section>
|
||||
<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 ?? 'หมวดหมู่'}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-zinc-600">
|
||||
ข่าวสารทั้งหมดในหมวดนี้
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>错误:</strong> {error}
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-800"
|
||||
>
|
||||
{error}
|
||||
</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
|
||||
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />)
|
||||
: posts.map((post) => (
|
||||
: items.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
title={post.title}
|
||||
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)}
|
||||
category={getCategoryTitle(post)}
|
||||
excerpt={stripHtml(post.content_html ?? '') || post.title}
|
||||
category={category?.title}
|
||||
date={formatDate(post.createdAt)}
|
||||
onClick={() => handlePostClick(post.slug)}
|
||||
to={`/news/${post.slug}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!loading && posts.length === 0 && !error && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">该分类下暂无文章</p>
|
||||
{!loading && items.length === 0 && !error ? (
|
||||
<div className="py-12 text-center text-zinc-600">
|
||||
ยังไม่มีข่าวสารในหมวดนี้
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
130
src/pages/Contact.tsx
Normal file
130
src/pages/Contact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 { Footer } from '../components/Footer'
|
||||
import { PostCard } from '../components/PostCard'
|
||||
@@ -6,7 +7,15 @@ import { PostCardSkeleton } from '../components/PostCardSkeleton'
|
||||
import { Posts } from '../clientsdk/sdk.gen'
|
||||
import { createClient } from '../clientsdk/client'
|
||||
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({
|
||||
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 = () => {
|
||||
const [posts, setPosts] = useState<any[]>([])
|
||||
const { t, locale } = useI18n()
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -27,87 +63,172 @@ export const Home: React.FC = () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await Posts.listPosts({
|
||||
|
||||
const response = (await Posts.listPosts({
|
||||
client,
|
||||
query: {
|
||||
limit: 10,
|
||||
limit: 6,
|
||||
sort: '-createdAt',
|
||||
},
|
||||
})
|
||||
|
||||
setPosts((response as any)?.data?.docs || [])
|
||||
})) as ListPostsResult
|
||||
|
||||
setPosts(response.data?.docs ?? [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
console.error('获取文章失败:', err)
|
||||
setError(err instanceof Error ? err.message : t('common.loadFailed'))
|
||||
console.error('Fetch posts failed:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPosts()
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
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('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}`
|
||||
}
|
||||
const items = useMemo(() => posts, [posts])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<section className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">📚 最新文章</h2>
|
||||
<p className="text-gray-600">探索我们的最新内容</p>
|
||||
<main className="mx-auto w-full max-w-6xl px-4">
|
||||
<section className="grid gap-10 py-12 lg:grid-cols-2 lg:items-center">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>错误:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
<section className="grid gap-6 md:grid-cols-3">
|
||||
{FEATURED_KEYS.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">{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">
|
||||
{loading
|
||||
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />)
|
||||
: posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
title={post.title}
|
||||
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)}
|
||||
category={getCategoryTitle(post)}
|
||||
date={formatDate(post.createdAt)}
|
||||
onClick={() => handlePostClick(post.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && posts.length === 0 && !error && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">暂无文章</p>
|
||||
<section className="mt-12" aria-labelledby="latest-news">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 id="latest-news" className="text-2xl font-semibold tracking-tight">
|
||||
{t('home.section.latestNews')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
{t('home.section.latestNews.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/news"
|
||||
className="text-sm font-semibold text-red-700 hover:underline"
|
||||
>
|
||||
{t('home.section.latestNews.more')}
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
<Footer />
|
||||
|
||||
243
src/pages/Menu.tsx
Normal file
243
src/pages/Menu.tsx
Normal 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
123
src/pages/News.tsx
Normal 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
28
src/pages/NotFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Header } from '../components/Header'
|
||||
import { Footer } from '../components/Footer'
|
||||
import { Posts } from '../clientsdk/sdk.gen'
|
||||
import { createClient } from '../clientsdk/client'
|
||||
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({
|
||||
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 = () => {
|
||||
const { t } = useI18n()
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const [post, setPost] = useState<any>(null)
|
||||
const [post, setPost] = useState<Post | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -29,9 +51,8 @@ export const PostDetail: React.FC = () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
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,
|
||||
query: {
|
||||
where: {
|
||||
@@ -41,47 +62,42 @@ export const PostDetail: React.FC = () => {
|
||||
},
|
||||
limit: 1,
|
||||
},
|
||||
})
|
||||
})) as ListPostsResult
|
||||
|
||||
const docs = (response as any)?.data?.docs || []
|
||||
setPost(docs[0] || null)
|
||||
setPost(response.data?.docs?.[0] ?? null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
console.error('获取文章失败:', err)
|
||||
setError(err instanceof Error ? err.message : t('common.loadFailed'))
|
||||
console.error('Fetch post failed:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPost()
|
||||
}, [slug])
|
||||
}, [slug, t])
|
||||
|
||||
const getCategoryTitle = (p: any): string | undefined => {
|
||||
// categories is an array, get the first one
|
||||
return p?.categories?.[0]?.title
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
const hero = useMemo(() => {
|
||||
if (!post) return undefined
|
||||
return isMedia(post.heroImage) ? post.heroImage : undefined
|
||||
}, [post])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-6"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<main className="mx-auto w-full max-w-3xl px-4 py-10">
|
||||
<div
|
||||
className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-10"
|
||||
aria-label="กำลังโหลด"
|
||||
>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 rounded bg-zinc-200/70" />
|
||||
<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>
|
||||
</main>
|
||||
@@ -92,17 +108,19 @@ export const PostDetail: React.FC = () => {
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 text-lg">{error || '文章不存在'}</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
<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>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/news"
|
||||
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')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
@@ -111,43 +129,44 @@ export const PostDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<Header />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<article>
|
||||
<header className="mb-8">
|
||||
{getCategoryTitle(post) && (
|
||||
<span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm mb-4">
|
||||
<main className="mx-auto w-full max-w-3xl px-4 py-10">
|
||||
<article className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-10">
|
||||
<header>
|
||||
{getCategoryTitle(post) ? (
|
||||
<p className="text-sm font-semibold text-red-700">
|
||||
{getCategoryTitle(post)}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1>
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<span>{formatDate(post.createdAt)}</span>
|
||||
</div>
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-zinc-500">{formatDate(post.createdAt)}</p>
|
||||
</header>
|
||||
|
||||
{post.heroImage && (
|
||||
{hero?.url ? (
|
||||
<img
|
||||
src={post.heroImage.url}
|
||||
alt={post.heroImage.alt || post.title}
|
||||
className="w-full h-auto rounded-lg mb-8"
|
||||
src={hero.url}
|
||||
alt={hero.alt || post.title}
|
||||
className="mt-6 h-auto w-full rounded-2xl"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<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 || '' }}
|
||||
/>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||
<div className="mt-10 border-t border-zinc-200 pt-6">
|
||||
<Link
|
||||
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>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
21
src/site.ts
Normal file
21
src/site.ts
Normal 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:00–22:00' },
|
||||
{ label: 'เสาร์–อาทิตย์', hours: '10:30–22: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
11
src/utils/payload.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user