first commit

This commit is contained in:
“dongming”
2026-01-21 16:08:49 +08:00
commit ea72dd0c3c
57 changed files with 11884 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
VITE_TENANT_SLUG=zitadel-example
VITE_TENANT_API_KEY=tenant_4_PBA8ymVYxHXe_0-Xe7f4akUZZ7nuIygHZupKppem
VITE_API_URL=http://localhost:3000/api

134
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,134 @@
name: Deploy to Cloudflare Pages
on:
push:
tags:
- 'deploy-*' # 只在推送 deploy-* 标签时触发
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Parse tag to env
run: |
# 获取 tag 名称(Gitea Actions 使用 GITHUB_REF_NAME)
TAG_NAME="${GITHUB_REF_NAME}"
echo "TAG_NAME=$TAG_NAME"
# Tag 格式: deploy-{project_name}-{deploymentId_no_dashes}
# 例如: deploy-b7ea026a-cf09-4e31-9f29-b55d7c652b71-123e4567e89b12d3a456426614174000
# 去掉 "deploy-" 前缀
PREFIX="deploy-"
REST="${TAG_NAME#$PREFIX}"
# deploymentId(无破折号)固定是最后32个字符
DEPLOYMENT_ID="${REST: -32}"
# project_name 是剩余部分(去掉最后的 "-" 和 deploymentId)
PROJECT_NAME="${REST%-${DEPLOYMENT_ID}}"
echo "PROJECT_NAME=$PROJECT_NAME" >> "$GITHUB_ENV"
echo "DEPLOYMENT_ID=$DEPLOYMENT_ID" >> "$GITHUB_ENV"
#echo "DOMAIN=${PROJECT_NAME}-preview.turingflowai.com" >> "$GITHUB_ENV"
# 调试输出
echo "Parsed PROJECT_NAME: $PROJECT_NAME"
echo "Parsed DEPLOYMENT_ID: $DEPLOYMENT_ID"
- name: Check toolchain (debug only, 可选)
run: |
node -v || echo "node not found"
npm -v || echo "npm not found"
curl --version || echo "curl not found"
# 已经在 node:20-bookworm-slim 容器内,无需再 setup-node
# - name: Setup Node
# uses: actions/setup-node@v4
# with:
# node-version: '20'
- name: Use CN npm registry
run: |
npm config set registry http://repo.myhuaweicloud.com/repository/npm/
- name: Install dependencies
run: |
npm ci --no-audit --no-fund
- name: Build
run: npm run build
- name: Deploy to Cloudflare Pages
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
PROJECT_NAME: ${{ env.PROJECT_NAME }}
DOMAIN: ${{ env.DOMAIN }}
run: |
set -euo pipefail
echo "[deploy] project: $PROJECT_NAME"
echo "[deploy] domain: $DOMAIN"
# 部署到 Cloudflare Pages (假定构建产物在 dist/)
npx wrangler pages deploy dist \
--project-name "$PROJECT_NAME" \
--branch main
# 绑定自定义域名:<project_name>-preview.turingflowai.com
#curl --fail -X POST \
# "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}/domains" \
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
# -H "Content-Type: application/json" \
# -d '{"name":"'"${DOMAIN}"'"}' \
# || true
- name: Notify Deploy Service (success)
if: success()
env:
DEPLOY_SERVICE_CALLBACK_URL: ${{ secrets.DEPLOY_SERVICE_CALLBACK_URL }}
DEPLOY_SERVICE_TOKEN: ${{ secrets.DEPLOY_SERVICE_TOKEN }}
DEPLOYMENT_ID: ${{ env.DEPLOYMENT_ID }}
run: |
set -euo pipefail
# 获取当前 commit SHA (Gitea Actions 使用 GITHUB_SHA)
COMMIT_SHA="${GITHUB_SHA}"
curl -X POST "$DEPLOY_SERVICE_CALLBACK_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DEPLOY_SERVICE_TOKEN" \
-d '{
"deploymentId": "'"${DEPLOYMENT_ID}"'",
"status": "deployed",
"commitSha": "'"${COMMIT_SHA}"'",
"cfDeploymentId": "",
"errorMessage": null
}'
- name: Notify Deploy Service (failure)
if: failure()
env:
DEPLOY_SERVICE_CALLBACK_URL: ${{ secrets.DEPLOY_SERVICE_CALLBACK_URL }}
DEPLOY_SERVICE_TOKEN: ${{ secrets.DEPLOY_SERVICE_TOKEN }}
DEPLOYMENT_ID: ${{ env.DEPLOYMENT_ID }}
run: |
set -euo pipefail
# 获取当前 commit SHA
COMMIT_SHA="${GITHUB_SHA}"
curl -X POST "$DEPLOY_SERVICE_CALLBACK_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DEPLOY_SERVICE_TOKEN" \
-d '{
"deploymentId": "'"${DEPLOYMENT_ID}"'",
"status": "failed",
"commitSha": "'"${COMMIT_SHA}"'",
"cfDeploymentId": "",
"errorMessage": "see Gitea Actions logs"
}'

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
.claude
docs
.pnpm-store
pnpm-lock.yaml
package-lock.json

116
AGENT.md Normal file
View File

@@ -0,0 +1,116 @@
# Client SDK Usage Guide
This project uses an auto-generated SDK powered by `@hey-api/openapi-ts` to interact with the PayloadCMS multi-tenant backend.
## SDK Structure
- **Core Methods**: `src/clientsdk/sdk.gen.ts` (Contains classes like `Posts`, `Categories`, `Pages`, `Media`)
- **Type Definitions**: `src/clientsdk/types.gen.ts` (Contains interfaces like `Post`, `Category`, `Media`)
- **Query Serializer**: `src/clientsdk/querySerializer.ts` (Handles nested object serialization for PayloadCMS queries)
- **Client Factory**: `src/clientsdk/client/index.ts` (Use `createClient` to initialize a client instance)
## 1. Initialization
You must initialize the client with the correct `baseUrl`, `querySerializer`, and tenant headers.
```typescript
import { createClient } from '../clientsdk/client';
import { customQuerySerializer } from '../clientsdk/querySerializer';
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config';
const client = createClient({
baseUrl: API_URL,
querySerializer: customQuerySerializer, // CRITICAL: Required for nested where queries
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
});
```
## 2. Common Operations
### Fetching a List (with Sorting & Limits)
```typescript
import { Posts } from '../clientsdk/sdk.gen';
const response = await Posts.listPosts({
client,
query: {
limit: 10,
sort: '-createdAt', // Prefix with '-' for descending
},
});
// Access the data
const posts = response.data?.docs || [];
```
### Fetching a Single Document by Slug (Filtering)
PayloadCMS uses a specific `where` query syntax. The `customQuerySerializer` handles the translation to `where[slug][equals]=my-slug`.
```typescript
const response = await Posts.listPosts({
client,
query: {
where: {
slug: {
equals: 'my-article-slug',
},
},
limit: 1,
},
});
const post = response.data?.docs?.[0];
```
### Filtering by Category Slug
```typescript
const response = await Posts.listPosts({
client,
query: {
where: {
'categories.slug': {
equals: 'news',
},
},
},
});
```
## 3. Data Patterns
### Relationships
- **Categories**: In this project, categories are a **many-to-many** relationship. Always treat `post.categories` as an array.
- Correct: `post.categories?.[0]?.title`
- Incorrect: `post.category.title`
- **Media**: Images (like `heroImage`) are objects containing `url`, `alt`, and `sizes`.
- Example: `<img src={post.heroImage?.url} alt={post.heroImage?.alt} />`
### Rich Text (Lexical)
PayloadCMS provides Lexical rich text. We typically use `post.content_html` which is pre-rendered to HTML on the server.
- **React**: `<div dangerouslySetInnerHTML={{ __html: post.content_html }} />`
- **Vue**: `<div v-html="post.content_html" />`
- **Astro**: `<div set:html={post.content_html} />`
## 4. TypeScript Usage
Always use the generated types for better DX and safety.
```typescript
import type { Post, Category, Media } from '../clientsdk/types.gen';
function formatPost(post: Post) {
return {
title: post.title,
date: new Date(post.createdAt).toLocaleDateString()
};
}
```
## 5. Troubleshooting
- **CORS Issues**: Ensure `API_URL` in `config.ts` matches the backend port (default 3000).
- **Empty Results**: Check if the `X-Tenant-Slug` header matches the slug assigned to your content in the CMS admin panel.
- **Nested Query Error**: If you see "Deeply-nested arrays/objects arent supported", verify that you are passing the `customQuerySerializer` to the `createClient` options.

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'src/clientsdk/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

43
index.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<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>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>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "react-template",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.11.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react-swc": "^4.2.2",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

26
src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
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 { NotFound } from './pages/NotFound'
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<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>
)
}
export default App

View File

@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:3000' }));

View File

@@ -0,0 +1,301 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type {
Client,
Config,
RequestOptions,
ResolvedRequestOptions,
} from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<
Request,
Response,
unknown,
ResolvedRequestOptions
>();
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body);
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const url = buildUrl(opts);
return { opts, url };
};
const request: Client['request'] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try {
response = await _fetch(request);
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(
error,
undefined as any,
request,
opts,
)) as unknown;
}
}
finalError = finalError || ({} as unknown);
if (opts.throwOnError) {
throw finalError;
}
// Return error response
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (
response.status === 204 ||
response.headers.get('Content-Length') === '0'
) {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'json':
case 'text':
data = await response[parseAs]();
break;
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result,
};
};
const makeMethodFn =
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn =
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
url,
});
};
return {
buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE'),
},
trace: makeMethodFn('TRACE'),
} as Client;
};

View File

@@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';

View File

@@ -0,0 +1,241 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?:
| 'arrayBuffer'
| 'auto'
| 'blob'
| 'formData'
| 'json'
| 'stream'
| 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
: (
| {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
'method'
>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<
RequestFn,
Config,
MethodFn,
BuildUrlFn,
SseFn
> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);

View File

@@ -0,0 +1,332 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (
contentType: string | null,
): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (
cleanContent.startsWith('application/json') ||
cleanContent.endsWith('+json')
) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) =>
cleanContent.startsWith(type),
)
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
};
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator =
header instanceof Headers
? headersEntries(header)
: Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(
id: number | Interceptor,
fn: Interceptor,
): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
},
object: {
explode: true,
style: 'deepObject',
},
});
const defaultHeaders = {
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});

View File

@@ -0,0 +1,42 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token =
typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};

View File

@@ -0,0 +1,100 @@
// This file is auto-generated by @hey-api/openapi-ts
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};

View File

@@ -0,0 +1,176 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};

View File

@@ -0,0 +1,181 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};

View File

@@ -0,0 +1,136 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (
value: unknown,
): JsonValue | undefined => {
if (value === null) {
return null;
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (
typeof URLSearchParams !== 'undefined' &&
value instanceof URLSearchParams
) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};

View File

@@ -0,0 +1,266 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<
RequestInit,
'method'
> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<
TData = unknown,
TReturn = void,
TNext = unknown,
> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep =
sseSleepFn ??
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok)
throw new Error(
`SSE failed: ${response.status} ${response.statusText}`,
);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(
line.replace(/^retry:\s*/, ''),
10,
);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (
sseMaxRetryAttempts !== undefined &&
attempt >= sseMaxRetryAttempts
) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(
retryDelay * 2 ** (attempt - 1),
sseMaxRetryDelay ?? 30000,
);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
};

View File

@@ -0,0 +1,118 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never]
? { sse?: never }
: { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};

View File

@@ -0,0 +1,143 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}

4
src/clientsdk/index.ts Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
export const customQuerySerializer = (queryParams: any) => {
const search: string[] = [];
const serialize = (name: string, value: any) => {
if (value === undefined || value === null) return;
if (Array.isArray(value)) {
value.forEach((v, i) => {
serialize(`${name}[${i}]`, v);
});
} else if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
Object.entries(value).forEach(([key, v]) => {
serialize(`${name}[${key}]`, v);
});
} else {
const val = value instanceof Date ? value.toISOString() : String(value);
search.push(`${encodeURIComponent(name)}=${encodeURIComponent(val)}`);
}
};
if (queryParams && typeof queryParams === 'object') {
for (const key in queryParams) {
serialize(key, queryParams[key]);
}
}
return search.join('&');
};

1098
src/clientsdk/sdk.gen.ts Normal file

File diff suppressed because one or more lines are too long

6179
src/clientsdk/types.gen.ts Normal file

File diff suppressed because it is too large Load Diff

58
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,58 @@
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="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>
)
}

Binary file not shown.

99
src/components/Header.tsx Normal file
View File

@@ -0,0 +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="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>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { useI18n } from '../i18n/useI18n'
type PostCardProps = {
title: string
excerpt: string
category?: string
date: string
to?: string
onClick?: () => void
}
export const PostCard: React.FC<PostCardProps> = ({
title,
excerpt,
category,
date,
to,
onClick,
}) => {
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>
) : null}
<span className="text-sm text-zinc-500">{date}</span>
</div>
<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>
</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

@@ -0,0 +1,19 @@
import React from 'react'
export const PostCardSkeleton: React.FC = () => {
return (
<div className="border border-gray-200 rounded-lg p-6 shadow-sm bg-white animate-pulse">
<div className="flex items-center gap-2 mb-2">
<div className="w-16 h-5 bg-gray-200 rounded-full"></div>
<div className="w-20 h-4 bg-gray-200 rounded"></div>
</div>
<div className="h-6 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
</div>
<div className="mt-4 h-4 bg-gray-200 rounded w-20"></div>
</div>
)
}

5
src/config.ts Normal file
View File

@@ -0,0 +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"

6
src/env.ts Normal file
View File

@@ -0,0 +1,6 @@
export const ENV = {
VITE_TENANT_SLUG: "zitadel-example",
VITE_TENANT_API_KEY: "tenant_new-tenant_jau52FifQXXfnPufibP4NXXu54tHbWRQ5cEdh27j",
// 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
}

33
src/index.css Normal file
View File

@@ -0,0 +1,33 @@
@import "tailwindcss";
@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;
}

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
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>
<I18nProvider>
<App />
</I18nProvider>
</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>
)
}

106
src/pages/Categories.tsx Normal file
View File

@@ -0,0 +1,106 @@
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 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,
querySerializer: customQuerySerializer,
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
})
export const CategoriesPage: React.FC = () => {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchCategories = async () => {
try {
setLoading(true)
setError(null)
const response = (await Categories.listCategories({
client,
query: {
limit: 100,
},
})) as ListCategoriesResult
setCategories(response.data?.docs ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : 'โหลดข้อมูลไม่สำเร็จ')
console.error('Fetch categories failed:', err)
} finally {
setLoading(false)
}
}
fetchCategories()
}, [])
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">
<h1 className="text-4xl font-semibold tracking-tight"></h1>
<p className="mt-3 max-w-2xl text-zinc-600">
CMS ()
</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}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{loading
? Array.from({ length: 6 }).map((_, i) => (
<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) => (
<Link
key={category.id}
to={`/categories/${category.slug}`}
className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm transition hover:shadow-md"
>
<h2 className="text-lg font-semibold">{category.title}</h2>
<p className="mt-2 text-sm text-zinc-600"></p>
</Link>
))}
</div>
{!loading && categories.length === 0 && !error ? (
<div className="py-12 text-center text-zinc-600"></div>
) : null}
</main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { PostCard } from '../components/PostCard'
import { PostCardSkeleton } from '../components/PostCardSkeleton'
import { Posts, Categories } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer'
import type { Category, Post } from '../clientsdk/types.gen'
import { TENANT_API_KEY, TENANT_SLUG, API_URL } from '../config'
import { isCategory } from '../utils/payload'
type ListResponse<T> = { docs: T[] }
type ListPostsResult = { data?: ListResponse<Post> }
type ListCategoriesResult = { data?: ListResponse<Category> }
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',
})
}
export const CategoryDetail: React.FC = () => {
const { slug } = useParams<{ slug: string }>()
const [posts, setPosts] = useState<Post[]>([])
const [category, setCategory] = useState<Category | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!slug) return
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const [categoriesRes, postsRes] = await Promise.all([
Categories.listCategories({
client,
query: {
where: {
slug: {
equals: slug,
},
},
limit: 1,
},
}) as Promise<ListCategoriesResult>,
Posts.listPosts({
client,
query: {
limit: 100,
sort: '-createdAt',
},
}) as Promise<ListPostsResult>,
])
setCategory(categoriesRes.data?.docs?.[0] ?? null)
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('Fetch data failed:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [slug])
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">
<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
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={category?.title}
date={formatDate(post.createdAt)}
to={`/news/${post.slug}`}
/>
))}
</section>
{!loading && items.length === 0 && !error ? (
<div className="py-12 text-center text-zinc-600">
</div>
) : null}
</main>
<Footer />
</div>
)
}

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>
)
}

237
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,237 @@
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'
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 { 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,
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
}
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 { t, locale } = 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: 6,
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">
<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>
<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>
<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 />
</div>
)
}

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>
)
}

177
src/pages/PostDetail.tsx Normal file
View File

@@ -0,0 +1,177 @@
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 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,
querySerializer: customQuerySerializer,
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
})
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<Post | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!slug) return
const fetchPost = async () => {
try {
setLoading(true)
setError(null)
const response = (await Posts.listPosts({
client,
query: {
where: {
slug: {
equals: slug,
},
},
limit: 1,
},
})) as ListPostsResult
setPost(response.data?.docs?.[0] ?? null)
} catch (err) {
setError(err instanceof Error ? err.message : t('common.loadFailed'))
console.error('Fetch post failed:', err)
} finally {
setLoading(false)
}
}
fetchPost()
}, [slug, t])
const hero = useMemo(() => {
if (!post) return undefined
return isMedia(post.heroImage) ? post.heroImage : undefined
}, [post])
if (loading) {
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header />
<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>
<Footer />
</div>
)
}
if (error || !post) {
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">
<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 />
</div>
)
}
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<Header />
<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)}
</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>
{hero?.url ? (
<img
src={hero.url}
alt={hero.alt || post.title}
className="mt-6 h-auto w-full rounded-2xl"
loading="lazy"
/>
) : null}
<div
className="prose prose-zinc mt-8 max-w-none prose-headings:scroll-mt-24"
dangerouslySetInnerHTML={{ __html: post.content_html || '' }}
/>
<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"
>
{t('post.back')}
</Link>
</div>
</article>
</main>
<Footer />
</div>
)
}

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
}

13
tailwind.config.js Normal file
View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: '0.0.0.0',
port: 3000,
strictPort: true,
allowedHosts: true
},
});