From 96d49f0a0743b5dd6d369bd2309730fabeedd19d Mon Sep 17 00:00:00 2001 From: Jacky Ser Date: Mon, 10 Aug 2026 11:48:07 +0800 Subject: [PATCH] Build AI servo sample sales prototype --- .gitignore | 40 + .openai/hosting.json | 5 + README.md | 100 + app/chatgpt-auth.ts | 90 + app/globals.css | 279 + app/layout.tsx | 36 + app/page.tsx | 741 ++ build/sites-vite-plugin.ts | 45 + db/index.ts | 13 + db/schema.ts | 4 + drizzle.config.ts | 7 + drizzle/meta/_journal.json | 5 + eslint.config.mjs | 41 + examples/d1/app/api/notes/route.ts | 58 + examples/d1/db/schema.ts | 9 + next-env.d.ts | 5 + next.config.ts | 7 + package-lock.json | 10371 ++++++++++++++++++++++++ package.json | 46 + postcss.config.mjs | 7 + public/favicon.svg | 6 + public/file.svg | 1 + public/globe.svg | 1 + public/og.png | Bin 0 -> 1621979 bytes public/products/servo-17g-metal.jpg | Bin 0 -> 190411 bytes public/products/servo-17g-plastic.jpg | Bin 0 -> 172928 bytes public/products/servo-17g.png | Bin 0 -> 277153 bytes public/products/servo-25g-metal.jpg | Bin 0 -> 192619 bytes public/products/servo-25g-plastic.jpg | Bin 0 -> 183228 bytes public/products/servo-2g.jpg | Bin 0 -> 229494 bytes public/products/servo-37g-hybrid.jpg | Bin 0 -> 193026 bytes public/products/servo-37g-metal.jpg | Bin 0 -> 190810 bytes public/products/servo-37g-plastic.jpg | Bin 0 -> 197675 bytes public/products/servo-37g.jpg | Bin 0 -> 193977 bytes public/products/servo-9g-hybrid.jpg | Bin 0 -> 176878 bytes public/products/servo-9g-metal.jpg | Bin 0 -> 173897 bytes public/products/servo-9g-plastic.jpg | Bin 0 -> 190830 bytes public/window.svg | 1 + tests/rendered-html.test.mjs | 91 + tsconfig.json | 29 + vite.config.ts | 59 + worker/index.ts | 47 + 42 files changed, 12144 insertions(+) create mode 100644 .gitignore create mode 100644 .openai/hosting.json create mode 100644 README.md create mode 100644 app/chatgpt-auth.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 build/sites-vite-plugin.ts create mode 100644 db/index.ts create mode 100644 db/schema.ts create mode 100644 drizzle.config.ts create mode 100644 drizzle/meta/_journal.json create mode 100644 eslint.config.mjs create mode 100644 examples/d1/app/api/notes/route.ts create mode 100644 examples/d1/db/schema.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/favicon.svg create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/og.png create mode 100644 public/products/servo-17g-metal.jpg create mode 100644 public/products/servo-17g-plastic.jpg create mode 100644 public/products/servo-17g.png create mode 100644 public/products/servo-25g-metal.jpg create mode 100644 public/products/servo-25g-plastic.jpg create mode 100644 public/products/servo-2g.jpg create mode 100644 public/products/servo-37g-hybrid.jpg create mode 100644 public/products/servo-37g-metal.jpg create mode 100644 public/products/servo-37g-plastic.jpg create mode 100644 public/products/servo-37g.jpg create mode 100644 public/products/servo-9g-hybrid.jpg create mode 100644 public/products/servo-9g-metal.jpg create mode 100644 public/products/servo-9g-plastic.jpg create mode 100644 public/window.svg create mode 100644 tests/rendered-html.test.mjs create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 worker/index.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..967037f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.openai/hosting.json b/.openai/hosting.json new file mode 100644 index 0000000..b4e68fa --- /dev/null +++ b/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6a7949d3722c81918d95dfbf2887265c", + "d1": null, + "r2": null +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd9b9e0 --- /dev/null +++ b/README.md @@ -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) diff --git a/app/chatgpt-auth.ts b/app/chatgpt-auth.ts new file mode 100644 index 0000000..a0ae2ed --- /dev/null +++ b/app/chatgpt-auth.ts @@ -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 { + 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 { + 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; + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2abefcb --- /dev/null +++ b/app/globals.css @@ -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; } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..06fa8d7 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import "./globals.css"; + +export async function generateMetadata(): Promise { + 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 ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..ff78451 --- /dev/null +++ b/app/page.tsx @@ -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: "5000–50000件", + 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 ( +
+ {label} + {value} +
+ ); +} + +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(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) { + event.preventDefault(); + const id = `GDL-${new Date().toISOString().slice(5, 10).replace("-", "")}-${String( + Date.now(), + ).slice(-4)}`; + setLeadId(id); + setSubmitted(true); + } + + return ( +
+
+ + G + + GAUDELOT + 高徳乐 · 精密传动 + + + + +
+ +
+
+
AI 智能选型 · 工程师复核
+

+ 3分钟,找到
+ 适合你的舵机 +

+

+ 告诉我们应用、扭矩与结构限制。AI从高徳乐标准产品中生成候选方案, + 同步准备样品与替代选型摘要。 +

+
+ + 查看能力范围 +
+
+ 13 款标准样品 + 0.09–24 kgf·cm + 48h 方案响应 +
+
+ +
+
+
+ AI 选型助手 +
+ 仅作初选建议 +
+ + {stage === 0 && ( +
+
AI
+

高徳乐选型助手

+

你好,我来帮你缩小选型范围。

+

回答3组问题,即可获得推荐型号、匹配理由和样品申请入口。

+ +
✓ 无需注册即可查看选型结果
+
+ )} + + {stage > 0 && stage < 4 && ( +
+
+
+ 需求识别 + {stage} / 3 +
+
+
+ + {stage === 1 && ( +
+

第一步 · 使用场景

+

舵机将用在什么产品上?

+

选择最接近的一项,后续可以补充具体结构。

+
+ {APPLICATIONS.map((item) => ( + + ))} +
+
+ )} + + {stage === 2 && ( +
+

第二步 · 核心参数

+

需要多大的输出能力?

+

不确定时可填写估算值,工程师会在送样前复核。

+
+ + update("torque", Number(event.target.value))} + /> +
0.15101524
+
+
+ + +
+
+ )} + + {stage === 3 && ( +
+

第三步 · 项目信息

+

最后,告诉我采购侧的偏好。

+

这些信息用于计算推荐优先级,不会改变公开参考价。

+
+ 齿轮偏好 +
+ {(["不限", "塑料齿", "半金属齿", "金属齿"] as const).map((gear) => ( + + ))} +
+
+
+ + +
+ +
+ )} + +
+ + +
+
+ )} + + {stage === 4 && top && ( +
+
+
+

AI 初选结果

+

找到 {topMatches.length} 个候选方案

+
+ +
+ +
+
+ {top.product.name} + 首选推荐 +
+
+
匹配度{top.confidence}%
+

{top.product.code}

+

{top.product.name}

+

{top.product.subtitle}

+
+ + + + +
+
    + {top.reasons.map((reason) =>
  • ✓ {reason}
  • )} +
+
+ 商城参考价 + ¥{top.product.price.toFixed(1)} + / 只 · 批量价需确认 +
+
+
+ + {needsEngineer && ( +
+ 工程师复核 +

你的项目包含高用量、工业场景或边界参数,系统已标记为优先人工复核。

+
+ )} + +
+ {topMatches.slice(1).map((match) => ( +
+ {match.product.name} +
+ {match.confidence}% 匹配 +

{match.product.name}

+

{match.product.torque} kgf·cm · {match.product.gear} · ¥{match.product.price.toFixed(1)}

+
+
+ ))} +
+ + {!showSampleForm && ( +
+ + +
+ )} + + {showSampleForm && !submitted && ( +
+
+

样品申请

+

留下项目联系方式

+

演示版会生成一张线索卡;正式版可接入CRM、企微或商城订单。

+
+
+ + + + +
+