Build AI servo sample sales prototype
40
.gitignore
vendored
Normal 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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"project_id": "appgprj_6a7949d3722c81918d95dfbf2887265c",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
100
README.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
<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.09–24</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>500–5000件</option>
|
||||
<option>5000–50000件</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>1–2只</option><option>3–5只</option><option>6–10只</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.7g–3.7g", note: "AI毛绒 · 微型机构", image: "/products/servo-2g.jpg" },
|
||||
{ name: "通用标准", range: "9g–17g", note: "玩具 · 教育 · 航模", image: "/products/servo-9g-hybrid.jpg" },
|
||||
{ name: "中等负载", range: "25g–37g", 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>
|
||||
);
|
||||
}
|
||||
45
build/sites-vite-plugin.ts
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
5
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": []
|
||||
}
|
||||
41
eslint.config.mjs
Normal 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;
|
||||
58
examples/d1/app/api/notes/route.ts
Normal 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
@@ -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
@@ -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
@@ -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
46
package.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
public/favicon.svg
Normal 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
@@ -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
@@ -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
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/products/servo-17g-metal.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
public/products/servo-17g-plastic.jpg
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
public/products/servo-17g.png
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
public/products/servo-25g-metal.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
public/products/servo-25g-plastic.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
public/products/servo-2g.jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
public/products/servo-37g-hybrid.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
public/products/servo-37g-metal.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
public/products/servo-37g-plastic.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
public/products/servo-37g.jpg
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/products/servo-9g-hybrid.jpg
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
public/products/servo-9g-metal.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/products/servo-9g-plastic.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
1
public/window.svg
Normal 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 |
91
tests/rendered-html.test.mjs
Normal 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 it’s 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
@@ -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
@@ -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
@@ -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;
|
||||