Build AI servo sample sales prototype

This commit is contained in:
Jacky Ser
2026-08-10 11:48:07 +08:00
commit 96d49f0a07
42 changed files with 12144 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
/dist/
/.wrangler/
/outputs/
/work/

5
.openai/hosting.json Normal file
View File

@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a7949d3722c81918d95dfbf2887265c",
"d1": null,
"r2": null
}

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# vinext-starter
A clean full-stack starter running on
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
Drizzle support.
## Prerequisites
- Node.js `>=22.13.0`
## Quick Start
```bash
npm install
npm run dev
npm run build
```
This starter does not use `wrangler.jsonc`.
## Included Shape
- edit site code under `app/`
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed
## Workspace Auth Headers
Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present.
The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes.
SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
Treat the full name as optional and fall back to email when it is absent:
```tsx
import { headers } from "next/headers";
export default async function Home() {
const requestHeaders = await headers();
const userId = requestHeaders.get("oai-authenticated-user-id");
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;
const displayName = fullName ?? email;
// ...
}
```
## Optional Dispatch-Owned ChatGPT Sign-In
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:
- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.
Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.
## Useful Commands
- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
## Learn More
- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)

90
app/chatgpt-auth.ts Normal file
View File

@@ -0,0 +1,90 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
userId: string;
displayName: string;
email: string;
fullName: string | null;
};
const USER_ID_HEADER = "oai-authenticated-user-id";
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const userId = requestHeaders.get(USER_ID_HEADER);
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!userId || !email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
userId,
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

279
app/globals.css Normal file
View File

@@ -0,0 +1,279 @@
@import "tailwindcss";
:root {
--ink: #10182b;
--muted: #647089;
--line: #dce3ef;
--blue: #185ee8;
--blue-dark: #1048b7;
--blue-soft: #edf4ff;
--orange: #ff7a2f;
--surface: #ffffff;
--canvas: #f4f7fb;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
background: var(--canvas);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
.site-header {
position: sticky;
top: 0;
z-index: 30;
height: 74px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 max(32px, calc((100vw - 1240px) / 2));
background: rgba(255,255,255,.92);
border-bottom: 1px solid rgba(16,24,43,.08);
backdrop-filter: blur(14px);
}
.brand { display: inline-flex; align-items: center; gap: 11px; }
.brand-mark {
width: 38px; height: 38px; border-radius: 11px;
display: grid; place-items: center;
color: white; font-size: 21px; font-weight: 900; font-style: italic;
background: linear-gradient(145deg, #2d75ff, #0e48b9);
box-shadow: 0 7px 18px rgba(24,94,232,.25);
}
.brand > span:last-child { display: grid; gap: 1px; }
.brand strong { font-size: 14px; letter-spacing: .08em; line-height: 1; }
.brand small { color: var(--muted); font-size: 10px; letter-spacing: .04em; }
.site-header nav { display: flex; gap: 34px; font-size: 14px; color: #536078; }
.site-header nav a:hover { color: var(--blue); }
.header-cta {
border: 0; border-radius: 10px; padding: 11px 17px;
background: var(--ink); color: white; cursor: pointer; font-weight: 650;
}
.header-cta span { margin-left: 8px; color: #82aaff; }
.hero {
min-height: 720px;
padding: 74px max(32px, calc((100vw - 1240px) / 2)) 92px;
display: grid;
grid-template-columns: minmax(420px, .88fr) minmax(560px, 1.12fr);
gap: 70px;
align-items: center;
overflow: hidden;
background:
radial-gradient(circle at 4% 7%, rgba(62,129,255,.12), transparent 24%),
linear-gradient(135deg, #f8fbff 0%, #edf3fc 100%);
}
.hero-copy { padding: 14px 0 36px; }
.eyebrow { display: flex; align-items: center; gap: 9px; color: var(--blue); font-size: 13px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.eyebrow span { width: 22px; height: 2px; border-radius: 2px; background: var(--orange); }
.hero h1 { margin: 20px 0 24px; font-size: clamp(48px, 5.2vw, 76px); line-height: 1.05; letter-spacing: -.055em; font-weight: 780; }
.hero h1 em { color: var(--blue); font-style: normal; }
.hero-lead { max-width: 570px; margin: 0; color: #5e6b80; font-size: 17px; line-height: 1.85; }
.hero-actions { display: flex; align-items: center; gap: 25px; margin-top: 34px; }
.primary-action, .panel-primary, .next-button {
border: 0; border-radius: 12px; color: white; background: var(--blue);
cursor: pointer; font-weight: 720; box-shadow: 0 12px 28px rgba(24,94,232,.23);
transition: transform .2s, background .2s, box-shadow .2s;
}
.primary-action { padding: 16px 23px; }
.primary-action:hover, .panel-primary:hover, .next-button:hover { transform: translateY(-1px); background: var(--blue-dark); box-shadow: 0 15px 34px rgba(24,94,232,.3); }
.primary-action span, .next-button span { margin-left: 12px; }
.text-action { color: #41506a; font-size: 14px; padding-bottom: 3px; border-bottom: 1px solid #8f9aae; }
.trust-row { display: flex; gap: 25px; margin-top: 46px; padding-top: 24px; border-top: 1px solid #d7dfeb; }
.trust-row span { display: grid; gap: 5px; color: #718097; font-size: 11px; }
.trust-row b { color: var(--ink); font-size: 17px; }
.selector-shell {
min-height: 585px;
background: white;
border: 1px solid rgba(73,98,142,.15);
border-radius: 22px;
box-shadow: 0 28px 70px rgba(27,54,99,.16), 0 3px 8px rgba(27,54,99,.06);
overflow: hidden;
}
.selector-topbar {
height: 58px; display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; background: #f9fbfe; border-bottom: 1px solid #e4e9f1;
font-size: 13px; font-weight: 700;
}
.live-dot { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: #1fce7a; box-shadow: 0 0 0 4px rgba(31,206,122,.12); }
.secure-note { color: #8994a7; font-size: 11px; font-weight: 500; }
.welcome-panel { min-height: 527px; padding: 70px 54px 42px; display: flex; flex-direction: column; align-items: center; text-align: center; }
.assistant-avatar { width: 64px; height: 64px; border-radius: 19px; display: grid; place-items: center; color: white; background: linear-gradient(145deg, #397fff, #1454d0); font-size: 20px; font-weight: 850; box-shadow: 0 12px 28px rgba(24,94,232,.25); }
.assistant-label { margin: 16px 0 10px; color: var(--blue); font-size: 12px; font-weight: 720; }
.welcome-panel h2 { max-width: 380px; margin: 0; font-size: 26px; letter-spacing: -.03em; }
.welcome-panel > p:not(.assistant-label) { max-width: 390px; color: var(--muted); font-size: 14px; line-height: 1.75; }
.panel-primary { min-height: 49px; padding: 0 24px; }
.welcome-panel .panel-primary { width: min(100%, 330px); margin-top: 19px; display: flex; align-items: center; justify-content: space-between; }
.panel-primary span { margin-left: 18px; font-size: 11px; color: #dce8ff; font-weight: 500; }
.privacy-note { margin-top: 17px; color: #8a95a8; font-size: 11px; }
.question-panel { min-height: 527px; padding: 24px 30px 26px; display: flex; flex-direction: column; }
.progress-wrap { margin-bottom: 27px; }
.progress-labels { display: flex; justify-content: space-between; margin-bottom: 9px; color: #7e899b; font-size: 11px; }
.progress-labels strong { color: var(--blue); }
.progress-track { height: 4px; border-radius: 4px; overflow: hidden; background: #edf0f5; }
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--blue), #50a3ff); transition: width .3s; }
.question-step { flex: 1; }
.step-kicker { margin: 0 0 7px; color: var(--blue); font-size: 11px; letter-spacing: .08em; font-weight: 800; text-transform: uppercase; }
.question-step h2, .result-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
.step-help { margin: 7px 0 19px; color: #7c8799; font-size: 12px; }
.choice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.choice-card { position: relative; min-height: 76px; padding: 14px 35px 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #dfe5ee; border-radius: 11px; background: white; cursor: pointer; }
.choice-card:hover { border-color: #a8c3f4; background: #fbfdff; }
.choice-card.selected { border-color: var(--blue); background: var(--blue-soft); box-shadow: inset 0 0 0 1px var(--blue); }
.choice-card b { font-size: 13px; }
.choice-card small { color: #8590a2; font-size: 10px; line-height: 1.45; }
.choice-check { display: none; position: absolute; right: 12px; top: 12px; width: 19px; height: 19px; border-radius: 50%; place-items: center; color: white; background: var(--blue); font-size: 11px; }
.choice-card.selected .choice-check { display: grid; }
.panel-nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 22px; }
.back-button, .restart-button { padding: 11px 2px; border: 0; color: #798599; background: transparent; cursor: pointer; font-size: 12px; }
.next-button { min-width: 150px; padding: 13px 20px; }
.next-button:disabled { cursor: not-allowed; opacity: .35; transform: none; box-shadow: none; }
.field-stack { padding: 18px 18px 15px; border-radius: 12px; background: #f6f8fc; }
.field-label { display: flex; justify-content: space-between; color: #657187; font-size: 12px; }
.field-label strong { color: var(--blue); font-size: 15px; }
.range-input { width: 100%; margin: 24px 0 6px; accent-color: var(--blue); }
.range-scale { display: flex; justify-content: space-between; color: #9aa3b1; font-size: 9px; }
.two-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 15px; }
.select-field, .text-field { display: grid; gap: 7px; }
.select-field span, .text-field span, .field-title, .sample-form label span { color: #68758a; font-size: 11px; font-weight: 650; }
select, input, textarea { outline: none; border: 1px solid #dbe2ec; border-radius: 9px; color: var(--ink); background: white; }
select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(24,94,232,.1); }
.select-field select { height: 46px; padding: 0 12px; }
.text-field { margin-top: 13px; }
.text-field input { height: 44px; padding: 0 13px; }
.field-group { margin: 17px 0 13px; }
.pill-row { display: flex; gap: 7px; margin-top: 8px; }
.pill-row button { flex: 1; padding: 10px 7px; border: 1px solid #dce3ed; border-radius: 9px; background: white; cursor: pointer; font-size: 11px; }
.pill-row button.active { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); font-weight: 700; }
.result-panel { padding: 24px 28px 28px; }
.result-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; }
.best-match { display: grid; grid-template-columns: 180px 1fr; border: 1px solid #cfdaf0; border-radius: 15px; overflow: hidden; box-shadow: 0 8px 24px rgba(38,70,125,.08); }
.match-image-wrap { position: relative; min-height: 270px; background: #eff4fb; }
.match-image-wrap img { width: 100%; height: 100%; object-fit: cover; }
.match-badge { position: absolute; left: 12px; top: 12px; padding: 6px 9px; border-radius: 7px; color: white; background: var(--orange); font-size: 9px; font-weight: 800; }
.match-content { position: relative; padding: 19px 19px 16px; }
.match-score { position: absolute; right: 16px; top: 15px; display: grid; text-align: right; color: #7c889a; font-size: 9px; }
.match-score strong { color: var(--blue); font-size: 18px; }
.product-code { margin: 0 0 4px; color: #8b96a8; font-size: 9px; }
.match-content h3 { max-width: 250px; margin: 0; font-size: 19px; }
.product-subtitle { margin: 5px 0 13px; color: #7b8799; font-size: 10px; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.metric { padding: 8px 9px; border-radius: 8px; background: #f4f7fb; display: grid; gap: 3px; }
.metric span { color: #8994a6; font-size: 8px; }
.metric strong { font-size: 10px; }
.reason-list { display: flex; gap: 10px; flex-wrap: wrap; margin: 12px 0 10px; padding: 0; list-style: none; color: #2d6e55; font-size: 9px; }
.price-line { display: flex; align-items: baseline; gap: 6px; padding-top: 9px; border-top: 1px solid #edf0f4; }
.price-line span { color: #7c8798; font-size: 9px; }
.price-line strong { color: #e55726; font-size: 20px; }
.price-line small { color: #9aa4b3; font-size: 8px; }
.engineer-alert { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 12px; border: 1px solid #f0d29e; border-radius: 9px; background: #fff9ef; }
.engineer-alert span { flex: 0 0 auto; padding: 4px 7px; border-radius: 5px; color: #9b5a0a; background: #ffe7bc; font-size: 8px; font-weight: 800; }
.engineer-alert p { margin: 0; color: #815e2f; font-size: 9px; }
.alternative-list { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 10px; }
.alternative-card { min-width: 0; display: grid; grid-template-columns: 58px 1fr; align-items: center; border: 1px solid #e1e6ee; border-radius: 10px; overflow: hidden; }
.alternative-card img { width: 58px; height: 58px; object-fit: cover; background: #f0f3f7; }
.alternative-card div { min-width: 0; padding: 7px 9px; }
.alternative-card span { color: var(--blue); font-size: 8px; font-weight: 700; }
.alternative-card h4 { margin: 2px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
.alternative-card p { margin: 0; color: #8a95a6; font-size: 8px; }
.result-actions { display: flex; gap: 9px; margin-top: 14px; }
.result-actions .panel-primary { flex: 1; }
.outline-button { border: 1px solid #cfd7e4; border-radius: 10px; padding: 0 17px; background: white; cursor: pointer; font-size: 11px; font-weight: 650; }
.outline-button:hover { border-color: var(--blue); color: var(--blue); }
.sample-form { margin-top: 15px; padding: 18px; border: 1px solid #cbd7eb; border-radius: 12px; background: #f8faff; }
.sample-form h3 { margin: 0; font-size: 17px; }
.sample-form > div:first-child > p:last-child { margin: 5px 0 14px; color: #7e8999; font-size: 9px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.sample-form label { display: grid; gap: 5px; }
.sample-form input, .sample-form select { height: 37px; padding: 0 10px; font-size: 10px; }
.full-field { margin-top: 9px; }
.sample-form textarea { min-height: 58px; padding: 9px 10px; resize: vertical; font-size: 10px; }
.sample-form .panel-primary { width: 100%; margin-top: 10px; }
.demo-disclaimer { display: block; margin-top: 8px; color: #9aa3b1; text-align: center; font-size: 8px; }
.success-card { margin-top: 14px; padding: 24px; text-align: center; border-radius: 12px; background: #effaf5; border: 1px solid #bce8d2; }
.success-icon { width: 38px; height: 38px; margin: 0 auto 10px; display: grid; place-items: center; color: white; background: #20b973; border-radius: 50%; font-weight: 800; }
.success-card h3 { margin: 0; }
.success-card > p:not(.step-kicker) { color: #66776f; font-size: 10px; }
.success-card .outline-button { min-height: 38px; }
.capability-strip { padding: 28px max(32px, calc((100vw - 1120px) / 2)); display: grid; grid-template-columns: repeat(4, 1fr); background: var(--ink); color: white; }
.capability-strip div { display: grid; gap: 4px; text-align: center; border-right: 1px solid rgba(255,255,255,.13); }
.capability-strip div:last-child { border-right: 0; }
.capability-strip strong { font-size: 20px; }
.capability-strip span { color: #9fadca; font-size: 10px; }
.products-section { max-width: 1240px; margin: 0 auto; padding: 105px 32px 120px; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 60px; margin-bottom: 36px; }
.section-heading h2 { margin: 10px 0 0; font-size: 39px; letter-spacing: -.04em; }
.section-heading > p { max-width: 480px; margin: 0; color: #6e7a8e; line-height: 1.8; font-size: 13px; }
.product-band { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.product-band article { min-height: 380px; border: 1px solid #dbe2ec; border-radius: 16px; overflow: hidden; background: white; transition: transform .25s, box-shadow .25s; }
.product-band article:hover { transform: translateY(-4px); box-shadow: 0 18px 42px rgba(30,53,91,.12); }
.product-band img { width: 100%; height: 245px; object-fit: cover; background: #eef2f8; }
.product-band article div { padding: 21px; }
.product-band span { color: var(--blue); font-size: 11px; font-weight: 750; }
.product-band h3 { margin: 4px 0; font-size: 25px; }
.product-band p { margin: 0; color: #8490a3; font-size: 11px; }
.how-section { padding: 100px max(32px, calc((100vw - 1180px) / 2)); display: grid; grid-template-columns: .9fr 1.1fr; gap: 100px; color: white; background: linear-gradient(135deg, #0c1528, #14284e); }
.eyebrow.light { color: #77a5ff; }
.how-copy h2 { margin: 18px 0; font-size: 42px; line-height: 1.15; letter-spacing: -.04em; }
.how-copy > p:not(.eyebrow) { max-width: 500px; color: #a9b6cc; line-height: 1.8; font-size: 13px; }
.light-button { margin-top: 24px; color: var(--ink); background: white; box-shadow: none; }
.light-button:hover { color: white; }
.flow-list { display: grid; }
.flow-list > div { display: grid; grid-template-columns: 52px 1fr; align-items: center; padding: 22px 0; border-bottom: 1px solid rgba(255,255,255,.12); }
.flow-list b { color: #5e8ce2; font-size: 13px; }
.flow-list span { display: grid; gap: 6px; }
.flow-list strong { font-size: 17px; }
.flow-list small { color: #93a2bc; font-size: 11px; }
footer { padding: 34px max(32px, calc((100vw - 1240px) / 2)); display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 40px; background: white; border-top: 1px solid #e1e6ee; }
footer p { margin: 0; color: #8792a3; text-align: center; font-size: 10px; }
footer > span { color: #9aa4b2; font-size: 10px; }
@media (max-width: 1040px) {
.hero { grid-template-columns: 1fr; padding-top: 54px; gap: 35px; }
.hero-copy { max-width: 760px; }
.selector-shell { width: min(100%, 720px); }
.how-section { gap: 45px; }
}
@media (max-width: 720px) {
.site-header { height: 64px; padding: 0 18px; }
.site-header nav { display: none; }
.header-cta { padding: 9px 12px; font-size: 12px; }
.hero { min-height: 0; padding: 45px 16px 56px; }
.hero h1 { font-size: 48px; }
.hero-lead { font-size: 15px; }
.hero-actions { align-items: flex-start; flex-direction: column; gap: 16px; }
.trust-row { gap: 16px; }
.selector-shell { border-radius: 16px; }
.question-panel, .result-panel { padding: 20px 16px; }
.welcome-panel { padding: 55px 20px 35px; }
.choice-grid, .two-fields, .form-grid, .alternative-list { grid-template-columns: 1fr; }
.best-match { grid-template-columns: 1fr; }
.match-image-wrap { height: 240px; }
.capability-strip { grid-template-columns: 1fr 1fr; gap: 24px 0; padding: 28px 16px; }
.capability-strip div:nth-child(2) { border-right: 0; }
.section-heading { align-items: flex-start; flex-direction: column; gap: 18px; }
.product-band { grid-template-columns: 1fr; }
.products-section { padding: 75px 16px; }
.how-section { grid-template-columns: 1fr; padding: 75px 20px; }
footer { grid-template-columns: 1fr; justify-items: center; gap: 15px; }
.footer-brand { justify-self: center; }
}
@media print {
.site-header, .hero-copy, .capability-strip, .products-section, .how-section, footer, .selector-topbar, .result-actions, .restart-button { display: none !important; }
.hero { display: block; min-height: auto; padding: 0; background: white; }
.selector-shell { box-shadow: none; border: 0; }
.result-panel { padding: 20px; }
}

36
app/layout.tsx Normal file
View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost:3000";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const metadataBase = new URL(`${protocol}://${host}`);
return {
metadataBase,
title: "高徳乐 AI 舵机选型助手",
description: "3分钟获得高徳乐舵机候选型号、匹配理由与样品申请方案。",
openGraph: {
title: "高徳乐 AI 舵机选型助手",
description: "3分钟找到适合你的舵机。AI初选工程师复核。",
images: [{ url: new URL("/og.png", metadataBase).toString(), width: 1200, height: 630 }],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "高徳乐 AI 舵机选型助手",
description: "3分钟找到适合你的舵机。",
images: [new URL("/og.png", metadataBase).toString()],
},
};
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}

741
app/page.tsx Normal file
View File

@@ -0,0 +1,741 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
type Gear = "塑料齿" | "半金属齿" | "金属齿";
type Product = {
id: string;
name: string;
subtitle: string;
weight: number;
torque: number;
speed: number;
voltageMin: number;
voltageMax: number;
gear: Gear;
price: number;
size: string;
image: string;
applications: string[];
code: string;
};
type Requirements = {
application: string;
torque: number;
voltage: number;
weightLimit: number;
gear: "不限" | Gear;
annualVolume: string;
currentModel: string;
painPoint: string;
};
const PRODUCTS: Product[] = [
{
id: "17g",
name: "1.7克超微型舵机",
subtitle: "极致轻量,适合微型结构",
weight: 1.7,
torque: 0.09,
speed: 0.12,
voltageMin: 2.7,
voltageMax: 5,
gear: "塑料齿",
price: 30,
size: "13.40 × 6.15 × 16.00 mm",
image: "/products/servo-17g.png",
applications: ["AI毛绒", "智能玩具", "微型机器人", "云台/航模"],
code: "4.A.01.A0060",
},
{
id: "2g",
name: "2克塑料齿舵机",
subtitle: "快速响应的微型执行方案",
weight: 2,
torque: 0.4,
speed: 0.08,
voltageMin: 3.5,
voltageMax: 6,
gear: "塑料齿",
price: 14.4,
size: "16.25 × 8.30 × 16.90 mm",
image: "/products/servo-2g.jpg",
applications: ["AI毛绒", "智能玩具", "微型机器人", "云台/航模"],
code: "4.A.01.A0004",
},
{
id: "37g",
name: "3.7克塑料齿舵机",
subtitle: "轻量玩具与科创教育通用",
weight: 3.7,
torque: 0.8,
speed: 0.09,
voltageMin: 3.5,
voltageMax: 6,
gear: "塑料齿",
price: 14.7,
size: "20.40 × 8.70 × 20.32 mm",
image: "/products/servo-37g.jpg",
applications: ["AI毛绒", "智能玩具", "教育/积木", "云台/航模"],
code: "4.A.01.A0003",
},
{
id: "9p",
name: "9克塑料齿舵机",
subtitle: "成本优先的标准轻载方案",
weight: 9,
torque: 2.1,
speed: 0.11,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 13.1,
size: "23.00 × 12.36 × 28.50 mm",
image: "/products/servo-9g-plastic.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人", "云台/航模"],
code: "4.A.01.A0009",
},
{
id: "9h",
name: "9克半金属齿舵机",
subtitle: "耐久与成本兼顾",
weight: 9,
torque: 3.2,
speed: 0.13,
voltageMin: 4.8,
voltageMax: 6,
gear: "半金属齿",
price: 18,
size: "23.00 × 12.36 × 28.50 mm",
image: "/products/servo-9g-hybrid.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人", "云台/航模"],
code: "4.A.01.B0001",
},
{
id: "9m",
name: "9克全金属齿舵机",
subtitle: "高频使用与冲击负载优选",
weight: 9,
torque: 3.2,
speed: 0.13,
voltageMin: 4.8,
voltageMax: 6,
gear: "金属齿",
price: 28.7,
size: "23.00 × 12.36 × 28.50 mm",
image: "/products/servo-9g-metal.jpg",
applications: ["教育/积木", "微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0026",
},
{
id: "17p",
name: "17克塑料齿舵机",
subtitle: "玩具机构的高性价比选择",
weight: 17,
torque: 5,
speed: 0.1,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 14.9,
size: "28.60 × 13.00 × 31.00 mm",
image: "/products/servo-17g-plastic.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人"],
code: "4.A.01.A0002",
},
{
id: "17m",
name: "17克金属齿舵机",
subtitle: "宽电压、高扭矩、耐冲击",
weight: 17,
torque: 8.8,
speed: 0.18,
voltageMin: 4.8,
voltageMax: 8.4,
gear: "金属齿",
price: 27.8,
size: "28.60 × 13.00 × 31.00 mm",
image: "/products/servo-17g-metal.jpg",
applications: ["智能玩具", "微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0007",
},
{
id: "25p",
name: "25克塑料齿舵机",
subtitle: "中等负载的经济型方案",
weight: 25,
torque: 7.5,
speed: 0.15,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 16.4,
size: "36.00 × 15.20 × 29.50 mm",
image: "/products/servo-25g-plastic.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人"],
code: "4.A.01.A0001",
},
{
id: "25m",
name: "25克金属齿舵机",
subtitle: "中型机器人和高频机构",
weight: 25,
torque: 8.2,
speed: 0.15,
voltageMin: 4.8,
voltageMax: 8.4,
gear: "金属齿",
price: 44.7,
size: "36.00 × 15.20 × 29.50 mm",
image: "/products/servo-25g-metal.jpg",
applications: ["微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0014",
},
{
id: "37p",
name: "37克塑料齿舵机",
subtitle: "标准尺寸、大负载经济型",
weight: 37,
torque: 6.8,
speed: 0.22,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 18.4,
size: "40.85 × 20.15 × 39.15 mm",
image: "/products/servo-37g-plastic.jpg",
applications: ["智能玩具", "微型机器人", "工业设备"],
code: "4.A.01.B0015",
},
{
id: "37h",
name: "37克半金属齿舵机",
subtitle: "大扭矩与成本的平衡点",
weight: 37,
torque: 11.2,
speed: 0.26,
voltageMin: 4.8,
voltageMax: 6,
gear: "半金属齿",
price: 32.9,
size: "40.85 × 20.15 × 39.15 mm",
image: "/products/servo-37g-hybrid.jpg",
applications: ["微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0005",
},
{
id: "37m",
name: "37克金属齿舵机",
subtitle: "24kgf·cm级强劲输出",
weight: 37,
torque: 24,
speed: 0.22,
voltageMin: 4.8,
voltageMax: 8.4,
gear: "金属齿",
price: 37.6,
size: "40.85 × 20.15 × 39.15 mm",
image: "/products/servo-37g-metal.jpg",
applications: ["机器人/机械臂", "云台/航模", "工业设备"],
code: "4.A.01.B0017",
},
];
const APPLICATIONS = [
{ name: "AI毛绒", note: "头部、耳朵、手臂等轻量动作" },
{ name: "智能玩具", note: "互动机构、表情和行走结构" },
{ name: "教育/积木", note: "STEAM套件与积木动力模块" },
{ name: "微型机器人", note: "关节、夹爪和小型机械臂" },
{ name: "云台/航模", note: "飞行控制、云台与模型机构" },
{ name: "工业设备", note: "控制机构与定制传动组件" },
];
const DEFAULT_REQUIREMENTS: Requirements = {
application: "",
torque: 2,
voltage: 5,
weightLimit: 40,
gear: "不限",
annualVolume: "500050000件",
currentModel: "",
painPoint: "交期",
};
function getMatches(requirements: Requirements) {
return PRODUCTS.map((product) => {
let score = 34;
const reasons: string[] = [];
if (product.applications.includes(requirements.application)) {
score += 18;
reasons.push(`适合${requirements.application}场景`);
}
if (product.torque >= requirements.torque) {
score += 25;
const margin = Math.round((product.torque / requirements.torque - 1) * 100);
reasons.push(`扭矩满足,约有${Math.max(0, margin)}%余量`);
} else {
score -= 70;
}
if (
requirements.voltage >= product.voltageMin &&
requirements.voltage <= product.voltageMax
) {
score += 14;
reasons.push(`${requirements.voltage}V供电兼容`);
} else {
score -= 35;
}
if (product.weight <= requirements.weightLimit) {
score += 10;
reasons.push(`重量不超过${requirements.weightLimit}g`);
} else {
score -= 45;
}
if (requirements.gear === "不限" || requirements.gear === product.gear) {
score += requirements.gear === "不限" ? 4 : 12;
if (requirements.gear !== "不限") reasons.push(`${product.gear}符合偏好`);
} else if (requirements.gear === "金属齿" && product.gear !== "金属齿") {
score -= 22;
}
if (requirements.painPoint === "成本" && product.gear === "塑料齿") score += 8;
if (requirements.painPoint === "耐久" && product.gear === "金属齿") score += 8;
if (requirements.painPoint === "体积" && product.weight <= 9) score += 8;
return {
product,
score,
confidence: Math.max(52, Math.min(96, score)),
reasons: reasons.slice(0, 3),
};
}).sort((a, b) => b.score - a.score);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export default function Home() {
const [stage, setStage] = useState(0);
const [requirements, setRequirements] = useState(DEFAULT_REQUIREMENTS);
const [showSampleForm, setShowSampleForm] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [leadId, setLeadId] = useState("");
const matches = useMemo(() => getMatches(requirements), [requirements]);
const topMatches = matches.slice(0, 3);
const top = topMatches[0];
const needsEngineer =
top.score < 60 ||
requirements.annualVolume === "50000件以上" ||
requirements.application === "工业设备";
function update<K extends keyof Requirements>(key: K, value: Requirements[K]) {
setRequirements((current) => ({ ...current, [key]: value }));
}
function reset() {
setStage(1);
setRequirements(DEFAULT_REQUIREMENTS);
setShowSampleForm(false);
setSubmitted(false);
}
function submitLead(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const id = `GDL-${new Date().toISOString().slice(5, 10).replace("-", "")}-${String(
Date.now(),
).slice(-4)}`;
setLeadId(id);
setSubmitted(true);
}
return (
<main>
<header className="site-header">
<a className="brand" href="#top" aria-label="高徳乐 AI选型首页">
<span className="brand-mark">G</span>
<span>
<strong>GAUDELOT</strong>
<small> · </small>
</span>
</a>
<nav aria-label="主导航">
<a href="#selector">AI选型</a>
<a href="#products"></a>
<a href="#how"></a>
</nav>
<button className="header-cta" type="button" onClick={() => setStage(1)}>
<span></span>
</button>
</header>
<section className="hero" id="top">
<div className="hero-copy">
<div className="eyebrow"><span /> AI · </div>
<h1>
3<br />
<em></em>
</h1>
<p className="hero-lead">
AI从高徳乐标准产品中生成候选方案
</p>
<div className="hero-actions">
<button className="primary-action" type="button" onClick={() => setStage(1)}>
<span></span>
</button>
<a href="#products" className="text-action"></a>
</div>
<div className="trust-row">
<span><b>13</b> </span>
<span><b>0.0924</b> kgf·cm</span>
<span><b>48h</b> </span>
</div>
</div>
<div className="selector-shell" id="selector">
<div className="selector-topbar">
<div>
<span className="live-dot" /> AI
</div>
<span className="secure-note"></span>
</div>
{stage === 0 && (
<div className="welcome-panel">
<div className="assistant-avatar">AI</div>
<p className="assistant-label"></p>
<h2></h2>
<p>3</p>
<button className="panel-primary" type="button" onClick={() => setStage(1)}>
<span>3</span>
</button>
<div className="privacy-note"> </div>
</div>
)}
{stage > 0 && stage < 4 && (
<div className="question-panel">
<div className="progress-wrap" aria-label={`选型进度 ${stage}/3`}>
<div className="progress-labels">
<span></span>
<strong>{stage} / 3</strong>
</div>
<div className="progress-track"><i style={{ width: `${stage * 33.33}%` }} /></div>
</div>
{stage === 1 && (
<div className="question-step">
<p className="step-kicker"> · 使</p>
<h2></h2>
<p className="step-help"></p>
<div className="choice-grid">
{APPLICATIONS.map((item) => (
<button
type="button"
key={item.name}
className={`choice-card ${requirements.application === item.name ? "selected" : ""}`}
onClick={() => update("application", item.name)}
>
<b>{item.name}</b>
<small>{item.note}</small>
<span className="choice-check"></span>
</button>
))}
</div>
</div>
)}
{stage === 2 && (
<div className="question-step">
<p className="step-kicker"> · </p>
<h2></h2>
<p className="step-help"></p>
<div className="field-stack">
<label className="field-label" htmlFor="torque">
<strong>{requirements.torque} kgf·cm</strong>
</label>
<input
id="torque"
className="range-input"
type="range"
min="0.1"
max="24"
step="0.1"
value={requirements.torque}
onChange={(event) => update("torque", Number(event.target.value))}
/>
<div className="range-scale"><span>0.1</span><span>5</span><span>10</span><span>15</span><span>24</span></div>
</div>
<div className="two-fields">
<label className="select-field">
<span></span>
<select
value={requirements.voltage}
onChange={(event) => update("voltage", Number(event.target.value))}
>
<option value={3.7}>3.7V</option>
<option value={4.2}>4.2V</option>
<option value={5}>5.0V</option>
<option value={6}>6.0V</option>
<option value={7.4}>7.4V</option>
<option value={8.4}>8.4V</option>
</select>
</label>
<label className="select-field">
<span></span>
<select
value={requirements.weightLimit}
onChange={(event) => update("weightLimit", Number(event.target.value))}
>
<option value={2}>2g以内</option>
<option value={4}>4g以内</option>
<option value={10}>10g以内</option>
<option value={20}>20g以内</option>
<option value={40}>40g以内</option>
</select>
</label>
</div>
</div>
)}
{stage === 3 && (
<div className="question-step">
<p className="step-kicker"> · </p>
<h2></h2>
<p className="step-help"></p>
<div className="field-group">
<span className="field-title">齿</span>
<div className="pill-row">
{(["不限", "塑料齿", "半金属齿", "金属齿"] as const).map((gear) => (
<button
type="button"
key={gear}
className={requirements.gear === gear ? "active" : ""}
onClick={() => update("gear", gear)}
>{gear}</button>
))}
</div>
</div>
<div className="two-fields">
<label className="select-field">
<span></span>
<select
value={requirements.annualVolume}
onChange={(event) => update("annualVolume", event.target.value)}
>
<option>500</option>
<option>5005000</option>
<option>500050000</option>
<option>50000</option>
</select>
</label>
<label className="select-field">
<span></span>
<select
value={requirements.painPoint}
onChange={(event) => update("painPoint", event.target.value)}
>
<option></option>
<option></option>
<option></option>
<option></option>
<option></option>
<option></option>
</select>
</label>
</div>
<label className="text-field">
<span>使</span>
<input
value={requirements.currentModel}
onChange={(event) => update("currentModel", event.target.value)}
placeholder="例如SG90 / DS-E001 / 自定义型号"
/>
</label>
</div>
)}
<div className="panel-nav">
<button className="back-button" type="button" onClick={() => setStage(stage - 1)}> </button>
<button
className="next-button"
type="button"
disabled={stage === 1 && !requirements.application}
onClick={() => setStage(stage + 1)}
>
{stage === 3 ? "生成选型方案" : "继续"} <span></span>
</button>
</div>
</div>
)}
{stage === 4 && top && (
<div className="result-panel">
<div className="result-heading">
<div>
<p className="step-kicker">AI </p>
<h2> {topMatches.length} </h2>
</div>
<button type="button" className="restart-button" onClick={reset}></button>
</div>
<div className="best-match">
<div className="match-image-wrap">
<img src={top.product.image} alt={top.product.name} />
<span className="match-badge"></span>
</div>
<div className="match-content">
<div className="match-score"><span></span><strong>{top.confidence}%</strong></div>
<p className="product-code">{top.product.code}</p>
<h3>{top.product.name}</h3>
<p className="product-subtitle">{top.product.subtitle}</p>
<div className="metric-grid">
<Metric label="堵转扭矩" value={`${top.product.torque} kgf·cm`} />
<Metric label="空载速度" value={`${top.product.speed}s/60°`} />
<Metric label="工作电压" value={`${top.product.voltageMin}${top.product.voltageMax}V`} />
<Metric label="齿轮材质" value={top.product.gear} />
</div>
<ul className="reason-list">
{top.reasons.map((reason) => <li key={reason}> {reason}</li>)}
</ul>
<div className="price-line">
<span></span>
<strong>¥{top.product.price.toFixed(1)}</strong>
<small>/ · </small>
</div>
</div>
</div>
{needsEngineer && (
<div className="engineer-alert">
<span></span>
<p></p>
</div>
)}
<div className="alternative-list">
{topMatches.slice(1).map((match) => (
<div className="alternative-card" key={match.product.id}>
<img src={match.product.image} alt={match.product.name} />
<div>
<span>{match.confidence}% </span>
<h4>{match.product.name}</h4>
<p>{match.product.torque} kgf·cm · {match.product.gear} · ¥{match.product.price.toFixed(1)}</p>
</div>
</div>
))}
</div>
{!showSampleForm && (
<div className="result-actions">
<button className="panel-primary" type="button" onClick={() => setShowSampleForm(true)}>
<span></span>
</button>
<button className="outline-button" type="button" onClick={() => window.print()}> / </button>
</div>
)}
{showSampleForm && !submitted && (
<form className="sample-form" onSubmit={submitLead}>
<div>
<p className="step-kicker"></p>
<h3></h3>
<p>线CRM</p>
</div>
<div className="form-grid">
<label><span></span><input required name="company" placeholder="请输入公司名称" /></label>
<label><span></span><input required name="name" placeholder="姓名" /></label>
<label><span></span><input required name="phone" inputMode="tel" placeholder="用于样品确认" /></label>
<label><span></span><select name="quantity"><option>12</option><option>35</option><option>610</option></select></label>
</div>
<label className="full-field"><span></span><textarea name="notes" placeholder="安装尺寸、线长、角度、控制协议或测试时间要求" /></label>
<button className="panel-primary" type="submit"> <span></span></button>
<small className="demo-disclaimer"></small>
</form>
)}
{submitted && (
<div className="success-card">
<div className="success-icon"></div>
<p className="step-kicker">线</p>
<h3> {leadId}</h3>
<p>{top.product.name}线CRM并触发技术复核</p>
<button type="button" className="outline-button" onClick={reset}></button>
</div>
)}
</div>
)}
</div>
</section>
<section className="capability-strip" aria-label="高徳乐能力">
<div><strong>2</strong><span></span></div>
<div><strong>137</strong><span></span></div>
<div><strong>1/</strong><span>线</span></div>
<div><strong>IATF 16949</strong><span></span></div>
</section>
<section className="products-section" id="products">
<div className="section-heading">
<div>
<p className="eyebrow"><span /> </p>
<h2>24kgf·cm</h2>
</div>
<p>13</p>
</div>
<div className="product-band">
{[
{ name: "微型轻量", range: "1.7g3.7g", note: "AI毛绒 · 微型机构", image: "/products/servo-2g.jpg" },
{ name: "通用标准", range: "9g17g", note: "玩具 · 教育 · 航模", image: "/products/servo-9g-hybrid.jpg" },
{ name: "中等负载", range: "25g37g", note: "机器人 · 工业机构", image: "/products/servo-37g-metal.jpg" },
].map((item) => (
<article key={item.name}>
<img src={item.image} alt={item.name} />
<div><span>{item.name}</span><h3>{item.range}</h3><p>{item.note}</p></div>
</article>
))}
</div>
</section>
<section className="how-section" id="how">
<div className="how-copy">
<p className="eyebrow light"><span /> </p>
<h2><br /></h2>
<p>AI负责需求采集线</p>
<button className="primary-action light-button" type="button" onClick={() => { setStage(1); window.scrollTo({ top: 0, behavior: "smooth" }); }}>
<span></span>
</button>
</div>
<div className="flow-list">
<div><b>01</b><span><strong></strong><small></small></span></div>
<div><b>02</b><span><strong>AI生成候选方案</strong><small></small></span></div>
<div><b>03</b><span><strong></strong><small>线</small></span></div>
<div><b>04</b><span><strong></strong><small></small></span></div>
</div>
</section>
<footer>
<div className="brand footer-brand">
<span className="brand-mark">G</span>
<span><strong>GAUDELOT</strong><small> · </small></span>
</div>
<p>AI选型结果仅供初步筛选</p>
<span> · V1</span>
</footer>
</main>
);
}

View File

@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}

13
db/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}

4
db/schema.ts Normal file
View File

@@ -0,0 +1,4 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};

7
drizzle.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});

View File

@@ -0,0 +1,5 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}

41
eslint.config.mjs Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig, globalIgnores } from "eslint/config";
import eslint from "@eslint/js";
import next from "@next/eslint-plugin-next";
import jsxA11y from "eslint-plugin-jsx-a11y";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
const eslintConfig = defineConfig([
globalIgnores([
".next/**",
"dist/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
eslint.configs.recommended,
...tseslint.configs.recommended,
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
reactHooks.configs.flat["recommended-latest"],
jsxA11y.flatConfigs.recommended,
next.configs["core-web-vitals"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.serviceworker,
},
},
settings: {
react: {
version: "detect",
},
},
},
]);
export default eslintConfig;

View File

@@ -0,0 +1,58 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}

9
examples/d1/db/schema.ts Normal file
View File

@@ -0,0 +1,9 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import "vinext/types";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

10371
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "gaudelot-ai-servo-selector",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@eslint/js": "9.39.4",
"@next/eslint-plugin-next": "16.2.6",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "16.4.0",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"typescript-eslint": "8.59.3",
"vinext": "1.0.0-beta.2",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

6
public/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 392 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { access, readFile, readdir } from "node:fs/promises";
import test from "node:test";
const developmentPreviewMeta =
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
const templateRoot = new URL("../", import.meta.url);
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(
new Request("http://localhost/", {
headers: { accept: "text/html" },
}),
{
ASSETS: {
fetch: async () => new Response("Not found", { status: 404 }),
},
},
{
waitUntil() {},
passThroughOnException() {},
},
);
}
test("server-renders the starter loading skeleton", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, developmentPreviewMeta);
assert.match(html, /<title>Your site is taking shape<\/title>/i);
assert.match(html, /Building your site/);
assert.match(html, /Your site is taking shape/);
assert.match(
html,
/Your first version will appear here automatically when its ready\./,
);
assert.doesNotMatch(html, /Codex/);
assert.match(html, /react-loading-skeleton/);
assert.match(html, /role="status"/);
});
test("keeps the loading skeleton scoped and disposable", async () => {
const [preview, css, page, layout, packageJson, files] = await Promise.all([
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
readFile(new URL("preview.css", previewRoot), "utf8"),
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readdir(previewRoot),
]);
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
assert.match(preview, /from "react-loading-skeleton"/);
assert.match(preview, /baseColor="#eceae7"/);
assert.match(preview, /highlightColor="#f9f8f6"/);
assert.match(preview, /duration=\{2\.8\}/);
assert.match(preview, /sites-skeleton-search-placeholder/);
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
assert.match(css, /position:\s*fixed/);
assert.match(css, /inset:\s*0/);
assert.match(css, /opacity:\s*0\.52/);
assert.match(css, /prefers-reduced-motion:\s*reduce/);
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
assert.doesNotMatch(
preview,
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
);
assert.match(page, /export const metadata:\s*Metadata/);
assert.match(page, /"codex-preview": "development"/);
assert.match(page, /<SkeletonPreview \/>/);
assert.match(layout, /title:\s*"Starter Project"/);
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
await assert.rejects(
access(new URL("public/_sites-preview", templateRoot)),
);
});

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

59
vite.config.ts Normal file
View File

@@ -0,0 +1,59 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});

47
worker/index.ts Normal file
View File

@@ -0,0 +1,47 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;