feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds
Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
This commit is contained in:
45
new-site/Onboarding.bundle.html
Normal file
45
new-site/Onboarding.bundle.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vibn — Onboarding</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="assets/logo-black.png" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="onboarding.css" />
|
||||
|
||||
<template id="__bundler_thumbnail" data-bg-color="#27201d">
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#27201d"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#e9835d"/>
|
||||
<stop offset="1" stop-color="#b53a25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="100" r="58" fill="url(#g)"/>
|
||||
<g fill="#1a0f0a" stroke="#1a0f0a" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M82 78 L94 78 L98 110 L102 78 L114 78 L102 130 Z"/>
|
||||
<rect x="118" y="120" width="14" height="5" rx="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="primitives.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-primitives.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-fork.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-owner.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-consultant.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-build.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
new-site/Onboarding.html
Normal file
28
new-site/Onboarding.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vibn — Onboarding</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="assets/logo-black.png" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="onboarding.css" />
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="primitives.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-primitives.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-fork.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-owner.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-consultant.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-build.jsx"></script>
|
||||
<script type="text/babel" src="onboarding-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
181
new-site/onboarding-app.jsx
Normal file
181
new-site/onboarding-app.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
// Root onboarding app — owns the route state and the answers dict.
|
||||
// Routes: fork → <path> → build → ready. A floating debug navigator (toggle
|
||||
// in the lower-right) lets reviewers jump between any screen without
|
||||
// filling out the form.
|
||||
|
||||
function OnboardingApp() {
|
||||
const initialName = React.useMemo(() => {
|
||||
try { return localStorage.getItem("vibn:firstName") || ""; } catch { return ""; }
|
||||
}, []);
|
||||
|
||||
const [stage, setStage] = React.useState("fork"); // fork | path | build | ready
|
||||
const [path, setPath] = React.useState(null); // entrepreneur | owner | consultant
|
||||
const [forkChoice, setForkChoice] = React.useState(null);
|
||||
const [step, setStep] = React.useState(0);
|
||||
const [data, setData] = React.useState({});
|
||||
|
||||
const [debugOpen, setDebugOpen] = React.useState(false);
|
||||
|
||||
const update = (patch) => setData((d) => ({ ...d, ...patch }));
|
||||
|
||||
// ── transitions ──────────────────────────────────────────────────────
|
||||
const confirmFork = () => {
|
||||
if (!forkChoice) return;
|
||||
setPath(forkChoice);
|
||||
setStep(0);
|
||||
setStage("path");
|
||||
};
|
||||
const backToFork = () => { setStage("fork"); setStep(0); };
|
||||
const completePath = () => setStage("build");
|
||||
const openWorkspace = () => setStage("ready");
|
||||
const close = () => { window.location.href = "index.html"; };
|
||||
const openChat = () => { window.location.href = "index.html"; };
|
||||
|
||||
// ⌘↵ advances on whatever the current primary action is
|
||||
React.useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
const btn = document.querySelector(".btn-primary:not([disabled])");
|
||||
if (btn) btn.click();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
// ── render ───────────────────────────────────────────────────────────
|
||||
let body;
|
||||
if (stage === "fork") {
|
||||
body = (
|
||||
<ForkScreen
|
||||
name={initialName}
|
||||
value={forkChoice}
|
||||
onChange={setForkChoice}
|
||||
onClose={close}
|
||||
onNext={confirmFork}
|
||||
/>
|
||||
);
|
||||
} else if (stage === "path") {
|
||||
const props = {
|
||||
data, onUpdate: update,
|
||||
onBack: backToFork,
|
||||
onClose: close,
|
||||
onComplete: completePath,
|
||||
onJumpToStep: setStep,
|
||||
step,
|
||||
};
|
||||
if (path === "entrepreneur") body = <EntrepreneurPath {...props} />;
|
||||
else if (path === "owner") body = <OwnerPath {...props} />;
|
||||
else body = <ConsultantPath {...props} />;
|
||||
} else if (stage === "build") {
|
||||
body = (
|
||||
<BuildScreen
|
||||
path={path} data={data}
|
||||
onBack={() => setStage("path")}
|
||||
onClose={close}
|
||||
onOpen={openWorkspace}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = <ReadyScreen path={path} data={data} onClose={close} onOpenChat={openChat} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{body}
|
||||
<DebugNav
|
||||
open={debugOpen} setOpen={setDebugOpen}
|
||||
stage={stage} path={path} step={step}
|
||||
onJump={(s, p, idx) => {
|
||||
if (s === "fork") setStage("fork");
|
||||
else if (s === "build") { setPath(p); setStage("build"); }
|
||||
else if (s === "ready") { setPath(p); setStage("ready"); }
|
||||
else { setPath(p); setStep(idx); setStage("path"); }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Debug navigator ──────────────────────────────────────────────────────
|
||||
function DebugNav({ open, setOpen, stage, path, step, onJump }) {
|
||||
const groups = [
|
||||
{ title: "Start", rows: [
|
||||
{ label: "01 · Fork", active: stage === "fork", go: () => onJump("fork") },
|
||||
]},
|
||||
{ title: "Entrepreneur", rows: [
|
||||
{ label: "02 · Idea", active: stage === "path" && path === "entrepreneur" && step === 0, go: () => onJump("path", "entrepreneur", 0) },
|
||||
{ label: "03 · Audience", active: stage === "path" && path === "entrepreneur" && step === 1, go: () => onJump("path", "entrepreneur", 1) },
|
||||
{ label: "04 · Goal", active: stage === "path" && path === "entrepreneur" && step === 2, go: () => onJump("path", "entrepreneur", 2) },
|
||||
{ label: "05 · Vibe", active: stage === "path" && path === "entrepreneur" && step === 3, go: () => onJump("path", "entrepreneur", 3) },
|
||||
]},
|
||||
{ title: "Owner", rows: [
|
||||
{ label: "02 · Business", active: stage === "path" && path === "owner" && step === 0, go: () => onJump("path", "owner", 0) },
|
||||
{ label: "03 · Stack", active: stage === "path" && path === "owner" && step === 1, go: () => onJump("path", "owner", 1) },
|
||||
{ label: "04 · First fix", active: stage === "path" && path === "owner" && step === 2, go: () => onJump("path", "owner", 2) },
|
||||
{ label: "05 · Scale", active: stage === "path" && path === "owner" && step === 3, go: () => onJump("path", "owner", 3) },
|
||||
]},
|
||||
{ title: "Consultant", rows: [
|
||||
{ label: "02 · Client", active: stage === "path" && path === "consultant" && step === 0, go: () => onJump("path", "consultant", 0) },
|
||||
{ label: "03 · Brief", active: stage === "path" && path === "consultant" && step === 1, go: () => onJump("path", "consultant", 1) },
|
||||
{ label: "04 · Scope", active: stage === "path" && path === "consultant" && step === 2, go: () => onJump("path", "consultant", 2) },
|
||||
{ label: "05 · Handoff", active: stage === "path" && path === "consultant" && step === 3, go: () => onJump("path", "consultant", 3) },
|
||||
]},
|
||||
{ title: "Finish", rows: [
|
||||
{ label: "Build · entrepreneur", active: stage === "build" && path === "entrepreneur", go: () => onJump("build", "entrepreneur") },
|
||||
{ label: "Build · owner", active: stage === "build" && path === "owner", go: () => onJump("build", "owner") },
|
||||
{ label: "Build · consultant", active: stage === "build" && path === "consultant", go: () => onJump("build", "consultant") },
|
||||
{ label: "Ready", active: stage === "ready", go: () => onJump("ready", path || "entrepreneur") },
|
||||
]},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="debug">
|
||||
{open && (
|
||||
<div className="debug-panel">
|
||||
{groups.map((g) => (
|
||||
<React.Fragment key={g.title}>
|
||||
<div style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 9.5,
|
||||
color: "var(--fg-faint)",
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
padding: "8px 8px 4px",
|
||||
}}>{g.title}</div>
|
||||
{g.rows.map((r) => (
|
||||
<button
|
||||
key={r.label}
|
||||
type="button"
|
||||
className={"debug-row" + (r.active ? " active" : "")}
|
||||
onClick={r.go}
|
||||
>
|
||||
{r.active && <b>▸ </b>}{r.label}
|
||||
</button>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="debug-row"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{ marginTop: 8, justifyContent: "center", color: "var(--fg-mute)" }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="debug-toggle"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Designer navigator"
|
||||
>
|
||||
<span style={{ color: "var(--accent)", marginRight: 6 }}>◉</span>
|
||||
{stage === "path" ? `${path} · step ${step + 1}` : stage}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<OnboardingApp />);
|
||||
445
new-site/onboarding-build.jsx
Normal file
445
new-site/onboarding-build.jsx
Normal file
@@ -0,0 +1,445 @@
|
||||
// Build + Ready screens. The build screen shows the terminal stream + a live
|
||||
// preview stencil; ready is a quiet confirmation page with the workspace URL.
|
||||
|
||||
// ── Per-path build plans ───────────────────────────────────────────────────
|
||||
function buildPlanFor(path, data) {
|
||||
const common = [
|
||||
{ line: "vibn init — reading brief", ms: 600 },
|
||||
{ line: "↳ provisioning workspace", ms: 700 },
|
||||
{ line: "↳ wiring auth (email + Google)", ms: 800 },
|
||||
{ line: "↳ minting database & seed schema", ms: 700 },
|
||||
];
|
||||
|
||||
if (path === "entrepreneur") {
|
||||
return [
|
||||
...common,
|
||||
{ line: `↳ generating landing page · vibe "${data.vibe || "warm"}"`, ms: 900 },
|
||||
{ line: `↳ writing copy aimed at "${(data.audience || "your audience").slice(0, 40)}"`, ms: 800 },
|
||||
{ line: "↳ wiring email capture + Stripe payment link", ms: 700 },
|
||||
{ line: "↳ scaffolding admin: subscribers, sales, comments", ms: 800 },
|
||||
{ line: `↳ tuning launch plan for goal: ${data.goal || "first_customer"}`, ms: 700 },
|
||||
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
|
||||
{ line: "ready.", ms: 400, ok: true },
|
||||
];
|
||||
}
|
||||
if (path === "owner") {
|
||||
return [
|
||||
...common,
|
||||
{ line: `↳ modelling ${data.biz || "small business"} for "${data.bizName || "your business"}"`, ms: 800 },
|
||||
{ line: `↳ importing your stack (${(data.tools || []).length} tools)`, ms: 800 },
|
||||
{ line: `↳ building module: ${labelFor(data.firstThing)}`, ms: 1000 },
|
||||
{ line: "↳ generating customer + job records (10 sample)", ms: 700 },
|
||||
{ line: "↳ scheduling daily ops view + weekly report", ms: 700 },
|
||||
{ line: `↳ wiring savings tracker · est. $${(data.spend || 0)}/mo replaced`, ms: 800 },
|
||||
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
|
||||
{ line: "ready.", ms: 400, ok: true },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...common,
|
||||
{ line: `↳ branding workspace for "${data.clientName || "client"}"`, ms: 800 },
|
||||
{ line: `↳ scaffolding scope (${(data.scope || []).length} modules)`, ms: 1000 },
|
||||
...(data.scope || []).slice(0, 4).map((s) => ({ line: ` • ${s}`, ms: 350 })),
|
||||
{ line: "↳ generating handoff document + invoice template", ms: 700 },
|
||||
{ line: `↳ setting deploy target: ${data.handoff || "subdomain"}`, ms: 700 },
|
||||
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
|
||||
{ line: "ready.", ms: 400, ok: true },
|
||||
];
|
||||
}
|
||||
|
||||
function labelFor(id) {
|
||||
const map = {
|
||||
booking: "Bookings & scheduling",
|
||||
invoicing: "Quotes & invoices",
|
||||
customers: "Customer portal",
|
||||
inventory: "Inventory & orders",
|
||||
team: "Team & dispatch",
|
||||
marketing: "Marketing site",
|
||||
};
|
||||
return map[id] || "your first workflow";
|
||||
}
|
||||
|
||||
function workspaceUrlFor(path, data) {
|
||||
const seed =
|
||||
(path === "owner" && data.bizName) ||
|
||||
(path === "consultant" && data.clientName) ||
|
||||
(path === "entrepreneur" && (data.audience || data.idea)) ||
|
||||
"your-workspace";
|
||||
const slug = String(seed).toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 3)
|
||||
.join("-")
|
||||
.slice(0, 28) || "your-workspace";
|
||||
return `${slug}.vibn.app`;
|
||||
}
|
||||
|
||||
const BUILD_BIZ_LABEL = {
|
||||
service: "Trades / services",
|
||||
retail: "Retail",
|
||||
food: "Food & drink",
|
||||
appointments: "Appointments",
|
||||
events: "Events / hospitality",
|
||||
other: "Small business",
|
||||
};
|
||||
|
||||
// ── Build screen ───────────────────────────────────────────────────────────
|
||||
function BuildScreen({ path, data, onBack, onClose, onOpen }) {
|
||||
const plan = React.useMemo(() => buildPlanFor(path, data), [path, data]);
|
||||
const [lineIdx, setLineIdx] = React.useState(0);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const logRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (lineIdx >= plan.length) { setDone(true); return undefined; }
|
||||
const t = setTimeout(() => setLineIdx(lineIdx + 1), plan[lineIdx].ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [lineIdx, plan]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}, [lineIdx]);
|
||||
|
||||
const url = workspaceUrlFor(path, data);
|
||||
const lane = LANE_LABELS[path];
|
||||
const previewTitle =
|
||||
path === "owner" ? (data.bizName || "Your business") :
|
||||
path === "consultant" ? (data.clientName || "Your client") :
|
||||
"Your launch page";
|
||||
const previewSub =
|
||||
path === "owner" ? `${BUILD_BIZ_LABEL[data.biz] || "Small business"} · ${labelFor(data.firstThing)}` :
|
||||
path === "consultant" ? (data.industry || "Project") :
|
||||
(data.audience || "An idea worth building").slice(0, 64);
|
||||
|
||||
const pct = done ? 1 : lineIdx / plan.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<WizardTop
|
||||
onBack={onBack}
|
||||
onClose={onClose}
|
||||
lane={lane}
|
||||
stepText={done ? "Done" : "Building"}
|
||||
progress={pct}
|
||||
/>
|
||||
<main className="wiz-body" style={{ paddingTop: "clamp(28px, 5vh, 56px)" }}>
|
||||
<div className="wiz-card xwide" style={{ gap: 18 }}>
|
||||
<style>{`
|
||||
.b-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
|
||||
gap: 18px;
|
||||
}
|
||||
@media (max-width: 920px) { .b-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.b-pane {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--hairline);
|
||||
background: linear-gradient(180deg, oklch(0.16 0.008 60 / 0.92), oklch(0.14 0.008 60 / 0.92));
|
||||
min-height: 420px;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.b-pane-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
background: oklch(0.16 0.008 60 / 0.6);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-mute);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.b-pane-bar .live {
|
||||
margin-left: auto;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
color: oklch(0.85 0.16 155);
|
||||
font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.b-pane-bar .live::before {
|
||||
content: ""; width: 5px; height: 5px; border-radius: 50%;
|
||||
background: oklch(0.78 0.16 155);
|
||||
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
|
||||
animation: pulse 2s ease-out infinite;
|
||||
}
|
||||
.b-log {
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--fg-dim);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.b-log .l-ok { color: oklch(0.85 0.16 155); font-weight: 500; }
|
||||
.b-log .l-current { color: var(--fg); }
|
||||
.b-log .l-done { color: var(--fg-mute); }
|
||||
.b-log .l-done::before { content: "✓ "; color: var(--ok); margin-right: 2px; }
|
||||
.b-log .l-current::before { content: "● "; color: var(--accent); margin-right: 2px;
|
||||
animation: blink 1s steps(2) infinite; }
|
||||
.b-cursor {
|
||||
display: inline-block;
|
||||
width: 7px; height: 13px; vertical-align: -2px;
|
||||
background: var(--accent);
|
||||
margin-left: 2px;
|
||||
animation: blink 1s steps(2) infinite;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.b-prev-bar {
|
||||
padding: 10px 14px;
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
background: oklch(0.16 0.008 60 / 0.5);
|
||||
}
|
||||
.b-prev-url {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-mute);
|
||||
padding: 4px 12px;
|
||||
background: oklch(0.13 0.008 60);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 999px;
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.b-prev-stage {
|
||||
flex: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.b-stencil {
|
||||
width: 100%;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
opacity: 0;
|
||||
animation: stencil-in 0.7s ease-out forwards;
|
||||
}
|
||||
@keyframes stencil-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.b-stencil-block {
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, oklch(0.22 0.011 60), oklch(0.18 0.009 60));
|
||||
border: 1px solid var(--hairline);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.b-stencil-block::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.06), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shimmer { to { transform: translateX(100%); } }
|
||||
|
||||
@keyframes blink { 50% { opacity: 0.25; } }
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
|
||||
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
|
||||
100% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0); }
|
||||
}
|
||||
|
||||
.b-foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.b-foot-status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; letter-spacing: 0.06em;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.b-foot-status b { color: var(--accent); font-weight: 500; }
|
||||
`}</style>
|
||||
|
||||
<WizardQ
|
||||
title={done ? "Your workspace is live." : "Building your workspace…"}
|
||||
sub={done
|
||||
? "Open it to see what Vibn made. Every change from here happens in the chat."
|
||||
: "Nothing for you to do. Vibn is scaffolding everything from your answers."}
|
||||
/>
|
||||
|
||||
<div className="b-grid">
|
||||
{/* terminal */}
|
||||
<div className="b-pane">
|
||||
<div className="b-pane-bar">
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.65 0.18 25 / 0.7)" }} />
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.78 0.13 80 / 0.7)" }} />
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.72 0.16 145 / 0.7)" }} />
|
||||
<span style={{ marginLeft: 6 }}>vibn build — {url}</span>
|
||||
{!done && <span className="live">live</span>}
|
||||
</div>
|
||||
<div className="b-log" ref={logRef}>
|
||||
{plan.slice(0, lineIdx + 1).map((p, i) => {
|
||||
const isCurrent = i === lineIdx && !done;
|
||||
const isOk = p.ok && i <= lineIdx;
|
||||
const cls = isOk ? "l-ok" : isCurrent ? "l-current" : "l-done";
|
||||
return <div key={i} className={cls}>{p.line}</div>;
|
||||
})}
|
||||
{!done && <span className="b-cursor" aria-hidden="true" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* preview */}
|
||||
<div className="b-pane">
|
||||
<div className="b-prev-bar">
|
||||
<span style={{ display: "flex", gap: 5 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
|
||||
</span>
|
||||
<span className="b-prev-url">https://{url}</span>
|
||||
</div>
|
||||
<div className="b-prev-stage">
|
||||
<div className="b-stencil">
|
||||
<div style={{ fontSize: 20, fontWeight: 500, letterSpacing: "-0.02em", color: "var(--fg)", lineHeight: 1.15 }}>
|
||||
{previewTitle}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.45 }}>
|
||||
{previewSub}
|
||||
</div>
|
||||
<div style={{ height: 4 }} />
|
||||
<div className="b-stencil-block" style={{ height: 80 }} />
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.4s" }} />
|
||||
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.8s" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="b-foot">
|
||||
<span className="b-foot-status">
|
||||
{done ? "build complete" : <>Building <b>{Math.min(lineIdx, plan.length)}/{plan.length}</b></>}
|
||||
</span>
|
||||
{done && (
|
||||
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpen}>
|
||||
Open my workspace <Arrow size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ready screen ───────────────────────────────────────────────────────────
|
||||
function ReadyScreen({ path, data, onClose, onOpenChat }) {
|
||||
const url = workspaceUrlFor(path, data);
|
||||
const summary = summaryFor(path, data);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WizardTop
|
||||
onClose={onClose}
|
||||
lane={LANE_LABELS[path]}
|
||||
stepText="Ready"
|
||||
progress={1}
|
||||
/>
|
||||
<WizardBody>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: "linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18))",
|
||||
boxShadow: "0 0 18px var(--accent-glow)",
|
||||
display: "grid", placeItems: "center",
|
||||
color: "var(--accent-fg)", flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m3 8.5 3.2 3.2L13 5"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<WizardQ
|
||||
title="You're in."
|
||||
sub="Workspace provisioned, first build is online. Every change from here happens in the chat."
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--hairline)",
|
||||
background: "oklch(0.18 0.009 60 / 0.6)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 16px",
|
||||
borderBottom: "1px solid var(--hairline)",
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ fontSize: 10.5, color: "var(--fg-faint)", letterSpacing: "0.12em", textTransform: "uppercase" }}>
|
||||
Workspace URL
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 14, color: "var(--fg)", marginLeft: "auto" }}>
|
||||
{url}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: "12px 16px 14px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{summary.map((row, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 14, alignItems: "baseline", fontSize: 13.5 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
width: 86, flexShrink: 0,
|
||||
color: "var(--fg-faint)",
|
||||
fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
<span style={{ color: "var(--fg-dim)" }}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wiz-foot">
|
||||
<a href="index.html" className="wiz-skip">Back to home</a>
|
||||
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpenChat}>
|
||||
Open the build chat <Arrow size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</WizardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function summaryFor(path, data) {
|
||||
if (path === "owner") {
|
||||
return [
|
||||
{ label: "Business", value: `${data.bizName || "Untitled"} · ${BUILD_BIZ_LABEL[data.biz] || "Small business"}` },
|
||||
{ label: "Replacing", value: `${(data.tools || []).length} tools · ~$${data.spend || 0}/mo` },
|
||||
{ label: "First fix", value: labelFor(data.firstThing) },
|
||||
{ label: "Team", value: `${data.team || 1} · ${(data.customers || 0).toLocaleString()} cust/mo` },
|
||||
];
|
||||
}
|
||||
if (path === "consultant") {
|
||||
return [
|
||||
{ label: "Client", value: `${data.clientName || "Untitled"} · ${data.industry || ""}` },
|
||||
{ label: "Scope", value: `${(data.scope || []).length} modules` },
|
||||
{ label: "Brief", value: (data.brief || "").slice(0, 60) + ((data.brief || "").length > 60 ? "…" : "") },
|
||||
{ label: "Handoff", value: data.handoff || "subdomain" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: "Building", value: (data.idea || "").slice(0, 64) + ((data.idea || "").length > 64 ? "…" : "") },
|
||||
{ label: "Audience", value: (data.audience || "").slice(0, 64) },
|
||||
{ label: "Goal", value: data.goal || "first_customer" },
|
||||
{ label: "Vibe", value: data.vibe || "warm" },
|
||||
];
|
||||
}
|
||||
|
||||
Object.assign(window, { BuildScreen, ReadyScreen });
|
||||
294
new-site/onboarding-consultant.jsx
Normal file
294
new-site/onboarding-consultant.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
// Consultant path — 4 steps for freelancers building for a client.
|
||||
|
||||
const CONS_TOTAL = 4;
|
||||
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
|
||||
|
||||
function ConsClient({ clientName, industry, contact, onChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="Who are you building for?"
|
||||
sub="Used to brand the preview and the handoff doc."
|
||||
/>
|
||||
<Field label="Client / company">
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="Rivera & Co Roofing"
|
||||
value={clientName}
|
||||
onChange={(e) => onChange({ clientName: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="What they do">
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="Residential roofing, mostly insurance jobs"
|
||||
value={industry}
|
||||
onChange={(e) => onChange({ industry: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Your point of contact" optional hint="So Vibn can address them in the handoff.">
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="Marisol Rivera, Owner"
|
||||
value={contact}
|
||||
onChange={(e) => onChange({ contact: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const BRIEF_TEMPLATES = [
|
||||
{ id: "quote_tool", label: "Quote tool",
|
||||
body: "Customers request a quote with a few photos and a project description. The team reviews, sends a polished PDF, customer signs and pays a deposit online." },
|
||||
{ id: "booking", label: "Booking system",
|
||||
body: "Customers see real availability, book a service window, and get reminders. The team has a daily view of jobs with addresses and contact info." },
|
||||
{ id: "portal", label: "Customer portal",
|
||||
body: "Logged-in customers see past jobs, invoices, documents, and can message the business. The business sees a single page per customer." },
|
||||
{ id: "internal", label: "Internal ops",
|
||||
body: "Replace the spreadsheets the team is currently using. CRUD on jobs/customers, simple reports, role-based access, export to accounting." },
|
||||
];
|
||||
|
||||
function ConsBrief({ brief, onChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What did they ask for?"
|
||||
sub="Paste the brief, or start from a template and edit."
|
||||
/>
|
||||
<Field label="Start from a template" optional>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{BRIEF_TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className="preset-chip"
|
||||
style={{ padding: "8px 14px", fontSize: 13 }}
|
||||
onClick={() => onChange(t.body)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Brief">
|
||||
<textarea
|
||||
className="wiz-input"
|
||||
style={{ minHeight: 160 }}
|
||||
placeholder="The client wants…"
|
||||
value={brief}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SCOPE_GROUPS = [
|
||||
{ label: "Foundations", items: ["Auth & accounts", "Roles & permissions", "Database & admin", "Hosting & domain"] },
|
||||
{ label: "Customer-facing", items: ["Marketing site", "Booking / scheduling", "Quote requests", "Customer portal", "Self-serve forms"] },
|
||||
{ label: "Money", items: ["Stripe / payments", "Invoices & receipts", "Subscriptions", "Refunds & disputes"] },
|
||||
{ label: "Ops", items: ["Dashboards & reports", "CSV import / export", "Email + SMS notifs", "Audit log"] },
|
||||
];
|
||||
|
||||
function ConsScope({ scope, onChange }) {
|
||||
const toggle = (item) => {
|
||||
if (scope.includes(item)) onChange(scope.filter((x) => x !== item));
|
||||
else onChange([...scope, item]);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What's in scope?"
|
||||
sub="Tick what you've signed up to deliver. The rest you can add later — billable."
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
}}
|
||||
>
|
||||
{SCOPE_GROUPS.map((g) => (
|
||||
<div
|
||||
key={g.label}
|
||||
style={{
|
||||
padding: "14px 14px 10px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--hairline)",
|
||||
background: "oklch(0.18 0.009 60 / 0.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase",
|
||||
color: "var(--fg-mute)", marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{g.label}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{g.items.map((it) => {
|
||||
const active = scope.includes(it);
|
||||
return (
|
||||
<button
|
||||
key={it}
|
||||
type="button"
|
||||
onClick={() => toggle(it)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
textAlign: "left",
|
||||
padding: "6px 4px",
|
||||
color: active ? "var(--fg)" : "var(--fg-dim)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13.5,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 16, height: 16,
|
||||
flexShrink: 0,
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
|
||||
background: active ? "var(--accent)" : "transparent",
|
||||
color: "var(--accent-fg)",
|
||||
display: "grid", placeItems: "center",
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m3 8.5 3.2 3.2L13 5"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{it}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsHandoff({ data, onChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="And finally — delivery."
|
||||
sub="Where it lives, how you bill. Change later from settings."
|
||||
/>
|
||||
<Field label="Brand colors" optional hint="Hex, or just describe it. Paste a link, drop a screenshot — Vibn will figure it out.">
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="#1B4D3E and #F2E2C4 — matches their truck wraps"
|
||||
value={data.brand || ""}
|
||||
onChange={(e) => onChange({ brand: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Where should it live?">
|
||||
<PresetGroup
|
||||
options={[
|
||||
{ id: "subdomain", label: "Vibn subdomain", desc: "client-name.vibn.app — fastest." },
|
||||
{ id: "custom", label: "Their custom domain", desc: "We'll walk you through DNS." },
|
||||
{ id: "transfer", label: "Transfer ownership", desc: "They own it. You stay billable as editor." },
|
||||
]}
|
||||
value={data.handoff}
|
||||
onChange={(v) => onChange({ handoff: v })}
|
||||
columns={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Your hourly rate" optional hint="Used on the handoff doc & invoice template.">
|
||||
<div style={{ position: "relative" }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
|
||||
color: "var(--fg-faint)", fontSize: 14.5, pointerEvents: "none",
|
||||
}}
|
||||
>$</span>
|
||||
<input
|
||||
className="wiz-input"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="120"
|
||||
value={data.rate || ""}
|
||||
onChange={(e) => onChange({ rate: e.target.value })}
|
||||
style={{ paddingLeft: 26, paddingRight: 58 }}
|
||||
/>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
|
||||
color: "var(--fg-faint)", fontSize: 11.5, letterSpacing: "0.04em",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>/ hour</span>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path wrapper ───────────────────────────────────────────────────────────
|
||||
function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
|
||||
const next = () => {
|
||||
if (step < CONS_TOTAL - 1) onJumpToStep(step + 1);
|
||||
else onComplete();
|
||||
};
|
||||
const back = () => {
|
||||
if (step === 0) onBack();
|
||||
else onJumpToStep(step - 1);
|
||||
};
|
||||
|
||||
let body, canNext;
|
||||
if (step === 0) {
|
||||
body = (
|
||||
<ConsClient
|
||||
clientName={data.clientName || ""}
|
||||
industry={data.industry || ""}
|
||||
contact={data.contact || ""}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
);
|
||||
canNext = (data.clientName || "").trim().length >= 2 && (data.industry || "").trim().length >= 3;
|
||||
} else if (step === 1) {
|
||||
body = <ConsBrief brief={data.brief || ""} onChange={(v) => onUpdate({ brief: v })} />;
|
||||
canNext = (data.brief || "").trim().length >= 30;
|
||||
} else if (step === 2) {
|
||||
body = <ConsScope scope={data.scope || []} onChange={(v) => onUpdate({ scope: v })} />;
|
||||
canNext = (data.scope || []).length >= 2;
|
||||
} else {
|
||||
body = <ConsHandoff data={data} onChange={onUpdate} />;
|
||||
canNext = !!data.handoff;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WizardTop
|
||||
onBack={back}
|
||||
onClose={onClose}
|
||||
lane={LANE_LABELS.consultant}
|
||||
stepText={CONS_STEP_NAMES[step]}
|
||||
current={step + 2}
|
||||
total={5}
|
||||
/>
|
||||
<WizardBody width={step === 2 ? "wide" : null}>
|
||||
{body}
|
||||
<WizardFooter
|
||||
onNext={next}
|
||||
canNext={canNext}
|
||||
nextLabel={step === CONS_TOTAL - 1 ? "Spin up project →" : "Continue"}
|
||||
hint={canNext ? "⌘↵" : null}
|
||||
/>
|
||||
</WizardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ConsultantPath, CONS_TOTAL });
|
||||
274
new-site/onboarding-entrepreneur.jsx
Normal file
274
new-site/onboarding-entrepreneur.jsx
Normal file
@@ -0,0 +1,274 @@
|
||||
// Entrepreneur path — 4 steps. Each step is a focused question.
|
||||
|
||||
const ENTREP_TOTAL = 4;
|
||||
const ENTREP_STEP_NAMES = ["Idea", "Audience", "Goal", "Look"];
|
||||
|
||||
const IDEA_PROMPTS = [
|
||||
"A community for indie game devs to swap playtesters, with weekly demo nights",
|
||||
"An AI tool that turns my handwritten recipe notes into a clean cookbook for my family",
|
||||
"A waitlist + scheduler for my pottery studio — small classes, six people max",
|
||||
"A subscription box service for cold-brew enthusiasts, with monthly tasting cards",
|
||||
"A simple tool that turns my Strava data into framed art prints I can sell",
|
||||
];
|
||||
|
||||
function EntrepIdea({ value, onChange }) {
|
||||
const [phIdx, setPhIdx] = React.useState(0);
|
||||
const [phChars, setPhChars] = React.useState(0);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value.length > 0) return undefined;
|
||||
const full = IDEA_PROMPTS[phIdx];
|
||||
const speed = deleting ? 18 : 38;
|
||||
const t = setTimeout(() => {
|
||||
if (!deleting) {
|
||||
if (phChars < full.length) setPhChars(phChars + 1);
|
||||
else setTimeout(() => setDeleting(true), 1500);
|
||||
} else {
|
||||
if (phChars > 0) setPhChars(phChars - 1);
|
||||
else { setDeleting(false); setPhIdx((phIdx + 1) % IDEA_PROMPTS.length); }
|
||||
}
|
||||
}, speed);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, phIdx, phChars, deleting]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What are you building?"
|
||||
sub="One paragraph is enough. Talk like you would to a friend."
|
||||
/>
|
||||
<div style={{ position: "relative" }}>
|
||||
<textarea
|
||||
className="wiz-input"
|
||||
style={{ minHeight: 140, fontSize: 15 }}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoFocus
|
||||
aria-label="Describe your idea"
|
||||
/>
|
||||
{value.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute", top: 12, left: 14, right: 14,
|
||||
pointerEvents: "none",
|
||||
color: "var(--fg-faint)",
|
||||
font: "14.5px/1.5 var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{IDEA_PROMPTS[phIdx].slice(0, phChars)}
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 7, height: 14, verticalAlign: "-2px",
|
||||
background: "var(--accent)", marginLeft: 1,
|
||||
animation: "blink 1s steps(2) infinite",
|
||||
boxShadow: "0 0 10px var(--accent-glow)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.06em", marginTop: -16 }}
|
||||
>
|
||||
{value.length} chars · be specific where it matters
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const AUDIENCE_PRESETS = [
|
||||
"Me and people like me",
|
||||
"A small community I'm part of",
|
||||
"Local people in my city",
|
||||
"Anyone searching for this",
|
||||
"Other small businesses",
|
||||
"Hobbyists in a niche I love",
|
||||
];
|
||||
|
||||
function EntrepAudience({ value, onChange }) {
|
||||
const isPreset = AUDIENCE_PRESETS.includes(value);
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="Who is it for?"
|
||||
sub="The clearer your audience, the better the copy Vibn writes for it."
|
||||
/>
|
||||
<ChipGroup
|
||||
options={AUDIENCE_PRESETS}
|
||||
values={value ? [value] : []}
|
||||
onChange={(arr) => onChange(arr[arr.length - 1] || "")}
|
||||
/>
|
||||
<Field label="Or describe them in your own words" optional>
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="e.g. dog owners in Brooklyn who walk before work"
|
||||
value={!isPreset ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const GOALS = [
|
||||
{ id: "first_customer", icon: "🎯", label: "First real customer",
|
||||
desc: "Someone I don't know pays me. Even once." },
|
||||
{ id: "ten_users", icon: "👥", label: "Ten weekly users",
|
||||
desc: "A signal the thing actually does something useful." },
|
||||
{ id: "mrr_1k", icon: "📈", label: "$1k MRR",
|
||||
desc: "Enough to take it seriously." },
|
||||
{ id: "side_quit", icon: "🚪", label: "Replace my day job",
|
||||
desc: "The long road. Make this the main thing." },
|
||||
{ id: "audience", icon: "📣", label: "Build a tiny audience",
|
||||
desc: "200 emails, a community, something I can talk to." },
|
||||
{ id: "ship_it", icon: "🚀", label: "Just ship it",
|
||||
desc: "I want the thing to exist." },
|
||||
];
|
||||
|
||||
function EntrepGoal({ value, onChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What does “working” look like?"
|
||||
sub="Helps Vibn decide what to build first — a landing page that converts, or a tool that retains."
|
||||
/>
|
||||
<PresetGroup
|
||||
options={GOALS.map((g) => ({
|
||||
id: g.id, label: g.label, desc: g.desc,
|
||||
icon: <span style={{ fontSize: 14 }}>{g.icon}</span>,
|
||||
}))}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
columns={2}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const VIBES = [
|
||||
{ id: "warm", name: "Warm coral", swatch: "linear-gradient(135deg, #E27855, #B33B2A)",
|
||||
desc: "Confident, hand-built, warm." },
|
||||
{ id: "ink", name: "Ink & paper", swatch: "linear-gradient(135deg, #1d1d1d, #4a4a4a)",
|
||||
desc: "Editorial, serif, quiet." },
|
||||
{ id: "sage", name: "Sage matte", swatch: "linear-gradient(135deg, #7BA890, #3F6B57)",
|
||||
desc: "Calm, modern, slightly herbal." },
|
||||
{ id: "neon", name: "Neon arcade", swatch: "linear-gradient(135deg, #5B6CFF, #FF3DDB)",
|
||||
desc: "Loud, fun, late-night." },
|
||||
{ id: "cream", name: "Cream linen", swatch: "linear-gradient(135deg, #F2E7D5, #C9A977)",
|
||||
desc: "Cozy and beige." },
|
||||
{ id: "later", name: "Decide later", swatch: "repeating-linear-gradient(45deg, oklch(0.30 0.010 60), oklch(0.30 0.010 60) 6px, oklch(0.22 0.010 60) 6px, oklch(0.22 0.010 60) 12px)",
|
||||
desc: "Vibn picks one that fits." },
|
||||
];
|
||||
|
||||
function EntrepVibe({ value, onChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="Pick a starting vibe."
|
||||
sub="Every color and font is a tweak away once the site is live."
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{VIBES.map((v) => {
|
||||
const active = value === v.id;
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => onChange(v.id)}
|
||||
style={{
|
||||
padding: "10px 10px 10px",
|
||||
borderRadius: 11,
|
||||
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
|
||||
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
|
||||
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
|
||||
textAlign: "left",
|
||||
color: "var(--fg)",
|
||||
display: "flex", flexDirection: "column", gap: 8,
|
||||
transition: "border-color .15s, background .15s",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
height: 52, borderRadius: 7,
|
||||
background: v.swatch,
|
||||
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||
boxShadow: "inset 0 1px 0 oklch(1 0 0 / 0.18)",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, letterSpacing: "-0.005em" }}>
|
||||
{v.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11.5, color: "var(--fg-mute)", lineHeight: 1.4 }}>
|
||||
{v.desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path wrapper ───────────────────────────────────────────────────────────
|
||||
function EntrepreneurPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
|
||||
const next = () => {
|
||||
if (step < ENTREP_TOTAL - 1) onJumpToStep(step + 1);
|
||||
else onComplete();
|
||||
};
|
||||
const back = () => {
|
||||
if (step === 0) onBack();
|
||||
else onJumpToStep(step - 1);
|
||||
};
|
||||
|
||||
let body, canNext, onSkip = null;
|
||||
if (step === 0) {
|
||||
body = <EntrepIdea value={data.idea || ""} onChange={(v) => onUpdate({ idea: v })} />;
|
||||
canNext = (data.idea || "").trim().length >= 8;
|
||||
} else if (step === 1) {
|
||||
body = <EntrepAudience value={data.audience || ""} onChange={(v) => onUpdate({ audience: v })} />;
|
||||
canNext = (data.audience || "").trim().length >= 3;
|
||||
} else if (step === 2) {
|
||||
body = <EntrepGoal value={data.goal} onChange={(v) => onUpdate({ goal: v })} />;
|
||||
canNext = !!data.goal;
|
||||
} else {
|
||||
body = <EntrepVibe value={data.vibe} onChange={(v) => onUpdate({ vibe: v })} />;
|
||||
canNext = !!data.vibe;
|
||||
onSkip = () => { onUpdate({ vibe: "later" }); next(); };
|
||||
}
|
||||
|
||||
// 5 total: fork(1) + 4 path steps
|
||||
return (
|
||||
<>
|
||||
<WizardTop
|
||||
onBack={back}
|
||||
onClose={onClose}
|
||||
lane={LANE_LABELS.entrepreneur}
|
||||
stepText={ENTREP_STEP_NAMES[step]}
|
||||
current={step + 2}
|
||||
total={5}
|
||||
/>
|
||||
<WizardBody width={step === 2 || step === 3 ? "wide" : null}>
|
||||
{body}
|
||||
<WizardFooter
|
||||
onNext={next}
|
||||
canNext={canNext}
|
||||
nextLabel={step === ENTREP_TOTAL - 1 ? "Build →" : "Continue"}
|
||||
hint={canNext ? "⌘↵" : null}
|
||||
onSkip={onSkip}
|
||||
skipLabel="Pick for me"
|
||||
/>
|
||||
</WizardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { EntrepreneurPath, ENTREP_TOTAL });
|
||||
134
new-site/onboarding-fork.jsx
Normal file
134
new-site/onboarding-fork.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// Step 1: the only branching question — "which describes you?"
|
||||
// Quiet radio-style cards. No quotes, no marketing, no glow theatrics.
|
||||
|
||||
const FORKS = [
|
||||
{
|
||||
id: "entrepreneur",
|
||||
label: "I'm building my own thing",
|
||||
hint: "Idea → live → first customer. You're the founder.",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="3"/>
|
||||
<path d="M9 2.5v2M9 13.5v2M2.5 9h2M13.5 9h2"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "owner",
|
||||
label: "I run a business",
|
||||
hint: "Replace the stack of tools you currently rent.",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h12l-1 9H4L3 6Z"/>
|
||||
<path d="M6 6V4.5a3 3 0 0 1 6 0V6"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "consultant",
|
||||
label: "I build for clients",
|
||||
hint: "A workspace per client. Bill for the system, not the hours.",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2.5 15 9 3l6.5 12"/>
|
||||
<path d="M5.5 12h7"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function ForkScreen({ name, value, onChange, onClose, onNext }) {
|
||||
return (
|
||||
<>
|
||||
<WizardTop
|
||||
onBack={null}
|
||||
onClose={onClose}
|
||||
stepText="Pick your lane"
|
||||
current={1}
|
||||
total={5}
|
||||
/>
|
||||
<WizardBody>
|
||||
<WizardQ
|
||||
title={name ? `Welcome, ${name}. Which sounds like you?` : "Which one sounds like you?"}
|
||||
sub="Vibn asks different questions on the next screens depending on the answer. You can change this later."
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{FORKS.map((f) => {
|
||||
const active = value === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => onChange(f.id)}
|
||||
onDoubleClick={() => { onChange(f.id); onNext(); }}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 14,
|
||||
padding: "14px 16px",
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
|
||||
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
|
||||
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
|
||||
textAlign: "left",
|
||||
color: "var(--fg)",
|
||||
transition: "border-color .15s, background .15s",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 36, height: 36, flexShrink: 0,
|
||||
borderRadius: 9,
|
||||
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
|
||||
border: "1px solid var(--hairline)",
|
||||
color: active ? "var(--accent)" : "var(--fg-mute)",
|
||||
display: "grid", placeItems: "center",
|
||||
}}>
|
||||
{f.icon}
|
||||
</span>
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 500, letterSpacing: "-0.008em" }}>
|
||||
{f.label}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.4 }}>
|
||||
{f.hint}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
style={{
|
||||
width: 18, height: 18, flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
|
||||
background: active ? "var(--accent)" : "transparent",
|
||||
display: "grid", placeItems: "center",
|
||||
color: "var(--accent-fg)",
|
||||
transition: "border-color .15s, background .15s",
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m3 8.5 3.2 3.2L13 5"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<WizardFooter
|
||||
canNext={!!value}
|
||||
onNext={onNext}
|
||||
nextLabel="Continue"
|
||||
hint={value ? "Press ⌘↵" : null}
|
||||
/>
|
||||
</WizardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ForkScreen });
|
||||
262
new-site/onboarding-owner.jsx
Normal file
262
new-site/onboarding-owner.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
// Owner path — 4 steps for small-business owners replacing their stack.
|
||||
|
||||
const OWNER_TOTAL = 4;
|
||||
const OWNER_STEP_NAMES = ["Business", "Stack", "First fix", "Scale"];
|
||||
|
||||
const BIZ_KINDS = [
|
||||
{ id: "service", icon: "🛠", label: "Trades / home services", desc: "Plumbing, HVAC, landscaping, cleaning" },
|
||||
{ id: "retail", icon: "🛍", label: "Retail / shop", desc: "Vintage, boutique, market, online" },
|
||||
{ id: "food", icon: "🥐", label: "Food & drink", desc: "Café, bakery, food truck, catering" },
|
||||
{ id: "appointments", icon: "💈", label: "Appointment-based", desc: "Salon, studio, clinic, tutoring" },
|
||||
{ id: "events", icon: "🎟", label: "Events / hospitality", desc: "Venue, rental, planning" },
|
||||
{ id: "other", icon: "✦", label: "Something else", desc: "We'll learn from your answers" },
|
||||
];
|
||||
|
||||
function OwnerBiz({ value, name, onChange, onNameChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What does your business do?"
|
||||
sub="Roughly. We tailor the next screens to match."
|
||||
/>
|
||||
<PresetGroup
|
||||
options={BIZ_KINDS.map((b) => ({
|
||||
id: b.id, label: b.label, desc: b.desc,
|
||||
icon: <span style={{ fontSize: 14 }}>{b.icon}</span>,
|
||||
}))}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
columns={2}
|
||||
/>
|
||||
<Field label="Business name">
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="Sunrise Plumbing, Pearl Lane Bakery…"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const STACK_TOOLS = [
|
||||
"Square / POS",
|
||||
"Stripe",
|
||||
"Calendly",
|
||||
"Acuity",
|
||||
"Shopify",
|
||||
"QuickBooks",
|
||||
"Mailchimp",
|
||||
"Instagram",
|
||||
"Google Sheets",
|
||||
"Notion / Airtable",
|
||||
"Wix / Squarespace",
|
||||
"WhatsApp / Slack",
|
||||
"A printed binder",
|
||||
"Head + notepad",
|
||||
];
|
||||
|
||||
function OwnerStack({ tools, spend, onToolsChange, onSpendChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What are you renting right now?"
|
||||
sub="Tap everything you pay for. Approximate is fine."
|
||||
/>
|
||||
<Field label="Tools & subscriptions">
|
||||
<ChipGroup
|
||||
options={STACK_TOOLS}
|
||||
values={tools || []}
|
||||
onChange={onToolsChange}
|
||||
allowOther
|
||||
/>
|
||||
</Field>
|
||||
<Field label="About how much per month?" hint="Across all your software, ballpark.">
|
||||
<Slider
|
||||
min={0} max={1500} step={25}
|
||||
value={spend ?? 250}
|
||||
onChange={onSpendChange}
|
||||
format={(v) => v === 0 ? "$0" : v === 1500 ? "$1.5k+" : `$${v}`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{(tools || []).length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--hairline)",
|
||||
background: "oklch(0.18 0.009 60 / 0.6)",
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-dim)",
|
||||
display: "flex", gap: 12, alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: "50%",
|
||||
background: "var(--accent)", boxShadow: "0 0 10px var(--accent-glow)",
|
||||
marginTop: 7, flexShrink: 0,
|
||||
}} />
|
||||
<span>
|
||||
<b style={{ color: "var(--fg)", fontWeight: 500 }}>{tools.length} tool{tools.length === 1 ? "" : "s"}</b>
|
||||
{spend ? <> · ~<b style={{ color: "var(--fg)", fontWeight: 500 }}>${spend}/mo</b></> : null}.
|
||||
Replaced by one workspace, owned by you.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const OWNER_FIRST_THINGS = [
|
||||
{ id: "booking", icon: "📅", label: "Bookings & scheduling", desc: "Customers book themselves." },
|
||||
{ id: "invoicing", icon: "🧾", label: "Quotes, invoices, payments", desc: "Send a quote, get paid, no chasing." },
|
||||
{ id: "customers", icon: "👥", label: "Customer history & portal", desc: "One place per customer." },
|
||||
{ id: "inventory", icon: "📦", label: "Inventory & orders", desc: "Track stock, sales, suppliers." },
|
||||
{ id: "team", icon: "🪪", label: "Team & job dispatch", desc: "Assign jobs, log hours." },
|
||||
{ id: "marketing", icon: "📣", label: "Website + email + reviews", desc: "A site that converts, list that follows up." },
|
||||
];
|
||||
|
||||
function OwnerFirstThing({ value, onChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What's burning first?"
|
||||
sub="The one workflow you wish was already replaced. Vibn builds it on day one."
|
||||
/>
|
||||
<PresetGroup
|
||||
options={OWNER_FIRST_THINGS.map((f) => ({
|
||||
id: f.id, label: f.label, desc: f.desc,
|
||||
icon: <span style={{ fontSize: 14 }}>{f.icon}</span>,
|
||||
}))}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
columns={2}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const OWNER_HOW_LONG = [
|
||||
{ id: "starting", label: "Just starting" },
|
||||
{ id: "1_3", label: "1–3 years" },
|
||||
{ id: "3_10", label: "3–10 years" },
|
||||
{ id: "10_plus", label: "10+ years" },
|
||||
];
|
||||
|
||||
function OwnerScale({ customers, team, howLong, onCustomers, onTeam, onHowLong }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="A little about scale."
|
||||
sub="Sensible defaults — table view vs. cards, daily vs. monthly reports."
|
||||
/>
|
||||
<Field label="Customers per month">
|
||||
<Slider
|
||||
min={0} max={2000} step={10}
|
||||
value={customers ?? 50}
|
||||
onChange={onCustomers}
|
||||
format={(v) => v === 0 ? "0" : v >= 2000 ? "2k+" : v.toLocaleString()}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Team size (incl. you)">
|
||||
<Slider
|
||||
min={1} max={50} step={1}
|
||||
value={team ?? 1}
|
||||
onChange={onTeam}
|
||||
format={(v) => v >= 50 ? "50+" : `${v}`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="How long have you been at this?">
|
||||
<div className="chips">
|
||||
{OWNER_HOW_LONG.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className={"chip" + (howLong === h.id ? " active" : "")}
|
||||
onClick={() => onHowLong(h.id)}
|
||||
>
|
||||
{h.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path wrapper ───────────────────────────────────────────────────────────
|
||||
function OwnerPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
|
||||
const next = () => {
|
||||
if (step < OWNER_TOTAL - 1) onJumpToStep(step + 1);
|
||||
else onComplete();
|
||||
};
|
||||
const back = () => {
|
||||
if (step === 0) onBack();
|
||||
else onJumpToStep(step - 1);
|
||||
};
|
||||
|
||||
let body, canNext;
|
||||
if (step === 0) {
|
||||
body = (
|
||||
<OwnerBiz
|
||||
value={data.biz}
|
||||
name={data.bizName || ""}
|
||||
onChange={(v) => onUpdate({ biz: v })}
|
||||
onNameChange={(v) => onUpdate({ bizName: v })}
|
||||
/>
|
||||
);
|
||||
canNext = !!data.biz && (data.bizName || "").trim().length >= 2;
|
||||
} else if (step === 1) {
|
||||
body = (
|
||||
<OwnerStack
|
||||
tools={data.tools || []}
|
||||
spend={data.spend}
|
||||
onToolsChange={(v) => onUpdate({ tools: v })}
|
||||
onSpendChange={(v) => onUpdate({ spend: v })}
|
||||
/>
|
||||
);
|
||||
canNext = (data.tools || []).length >= 1;
|
||||
} else if (step === 2) {
|
||||
body = <OwnerFirstThing value={data.firstThing} onChange={(v) => onUpdate({ firstThing: v })} />;
|
||||
canNext = !!data.firstThing;
|
||||
} else {
|
||||
body = (
|
||||
<OwnerScale
|
||||
customers={data.customers}
|
||||
team={data.team}
|
||||
howLong={data.howLong}
|
||||
onCustomers={(v) => onUpdate({ customers: v })}
|
||||
onTeam={(v) => onUpdate({ team: v })}
|
||||
onHowLong={(v) => onUpdate({ howLong: v })}
|
||||
/>
|
||||
);
|
||||
canNext = !!data.howLong;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WizardTop
|
||||
onBack={back}
|
||||
onClose={onClose}
|
||||
lane={LANE_LABELS.owner}
|
||||
stepText={OWNER_STEP_NAMES[step]}
|
||||
current={step + 2}
|
||||
total={5}
|
||||
/>
|
||||
<WizardBody width={step === 0 || step === 2 ? "wide" : null}>
|
||||
{body}
|
||||
<WizardFooter
|
||||
onNext={next}
|
||||
canNext={canNext}
|
||||
nextLabel={step === OWNER_TOTAL - 1 ? "Build my workspace →" : "Continue"}
|
||||
hint={canNext ? "⌘↵" : null}
|
||||
/>
|
||||
</WizardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { OwnerPath, OWNER_TOTAL });
|
||||
333
new-site/onboarding-primitives.jsx
Normal file
333
new-site/onboarding-primitives.jsx
Normal file
@@ -0,0 +1,333 @@
|
||||
// Shared building blocks for the onboarding flow.
|
||||
// All <style> belongs in onboarding.css; this file is JSX only.
|
||||
|
||||
// ── Wizard top bar ─────────────────────────────────────────────────────────
|
||||
// Sticky, thin. Holds: back arrow · vibn mark · centered step label · close.
|
||||
// A 2px progress bar runs along its bottom edge.
|
||||
function WizardTop({
|
||||
onBack, onClose,
|
||||
lane, // "Solo / quiet entrepreneur" etc.
|
||||
stepText, // "Idea" or "Pick your lane"
|
||||
current, total, // 1-indexed
|
||||
progress, // 0..1 (optional override)
|
||||
}) {
|
||||
const pct = typeof progress === "number"
|
||||
? Math.max(0, Math.min(1, progress))
|
||||
: (typeof current === "number" && typeof total === "number"
|
||||
? Math.max(0, Math.min(1, current / total))
|
||||
: 0);
|
||||
|
||||
return (
|
||||
<header className="wiz-top">
|
||||
<div className="wiz-top-row">
|
||||
<button
|
||||
type="button"
|
||||
className="wiz-iconbtn"
|
||||
onClick={onBack}
|
||||
disabled={!onBack}
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M13 8H3M7 4 3 8l4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a href="index.html" className="wiz-logo" aria-label="vibn — home">
|
||||
<LogoMark size={22} />
|
||||
<span>vibn</span>
|
||||
</a>
|
||||
|
||||
<div className="wiz-step">
|
||||
{lane && <span className="lane">{lane}</span>}
|
||||
{lane && stepText && <span className="dot" />}
|
||||
{stepText && (
|
||||
<span>
|
||||
{typeof current === "number" && typeof total === "number" && (
|
||||
<>
|
||||
<b>{current}</b> <span style={{ opacity: 0.6 }}>/ {total}</span>{" · "}
|
||||
</>
|
||||
)}
|
||||
{stepText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="wiz-iconbtn"
|
||||
onClick={onClose}
|
||||
aria-label="Save & exit"
|
||||
title="Save & exit"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m4 4 8 8M12 4l-8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="wiz-progress">
|
||||
<div className="wiz-progress-fill" style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wizard body wrapper ────────────────────────────────────────────────────
|
||||
function WizardBody({ children, width }) {
|
||||
const cls = "wiz-card" + (width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
|
||||
return (
|
||||
<main className="wiz-body">
|
||||
<div className={cls}>{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question heading ───────────────────────────────────────────────────────
|
||||
function WizardQ({ title, sub }) {
|
||||
return (
|
||||
<div className="wiz-q">
|
||||
<h2>{title}</h2>
|
||||
{sub && <p>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer (back / hint / continue) ────────────────────────────────────────
|
||||
function WizardFooter({
|
||||
onBack, onNext, canNext = true,
|
||||
nextLabel = "Continue",
|
||||
hint,
|
||||
onSkip, skipLabel = "Skip",
|
||||
}) {
|
||||
return (
|
||||
<div className="wiz-foot">
|
||||
<div className="wiz-foot-left">
|
||||
{onSkip && (
|
||||
<button type="button" className="wiz-skip" onClick={onSkip}>
|
||||
{skipLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="wiz-foot-right">
|
||||
{hint && <span className="wiz-hint">{hint}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-wiz"
|
||||
disabled={!canNext}
|
||||
onClick={() => canNext && onNext && onNext()}
|
||||
>
|
||||
{nextLabel} <Arrow size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Field wrappers (wizard variants) ───────────────────────────────────────
|
||||
function Field({ label, hint, children, optional }) {
|
||||
return (
|
||||
<label className="wiz-field">
|
||||
{label && (
|
||||
<span className="wiz-field-label">
|
||||
{label}
|
||||
{optional && (
|
||||
<span style={{ color: "var(--fg-faint)", fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
|
||||
optional
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
{hint && <span className="wiz-field-hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Chip group (multi-select) ──────────────────────────────────────────────
|
||||
function ChipGroup({ options, values, onChange, allowOther = false }) {
|
||||
const [other, setOther] = React.useState("");
|
||||
const customs = (values || []).filter((v) => !options.includes(v));
|
||||
const toggle = (v) => {
|
||||
if (!onChange) return;
|
||||
if (values.includes(v)) onChange(values.filter((x) => x !== v));
|
||||
else onChange([...values, v]);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="chips">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
type="button" key={opt}
|
||||
className={"chip" + (values.includes(opt) ? " active" : "")}
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
{customs.map((c) => (
|
||||
<button
|
||||
type="button" key={c}
|
||||
className="chip active"
|
||||
onClick={() => toggle(c)}
|
||||
title="Click to remove"
|
||||
>
|
||||
{c} <span style={{ marginLeft: 4, opacity: 0.6 }}>×</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{allowOther && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const v = other.trim();
|
||||
if (v && !values.includes(v)) onChange([...values, v]);
|
||||
setOther("");
|
||||
}}
|
||||
style={{ marginTop: 10, display: "flex", gap: 8 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="wiz-input"
|
||||
placeholder="Add your own…"
|
||||
value={other}
|
||||
onChange={(e) => setOther(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-ghost"
|
||||
style={{ height: 42, padding: "0 14px", fontSize: 13, borderRadius: 10 }}
|
||||
disabled={!other.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preset group (single-select cards) ─────────────────────────────────────
|
||||
function PresetGroup({ options, value, onChange, columns = 1 }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const active = value === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.id)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
|
||||
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
|
||||
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
|
||||
transition: "border-color .15s, background .15s",
|
||||
color: "var(--fg)",
|
||||
display: "flex", alignItems: "flex-start", gap: 12,
|
||||
}}
|
||||
>
|
||||
{opt.icon && (
|
||||
<span style={{
|
||||
width: 28, height: 28, flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
|
||||
border: "1px solid var(--hairline)",
|
||||
color: active ? "var(--accent)" : "var(--fg-mute)",
|
||||
display: "grid", placeItems: "center",
|
||||
fontSize: 14,
|
||||
marginTop: 1,
|
||||
}}>
|
||||
{opt.icon}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, letterSpacing: "-0.005em" }}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.desc && (
|
||||
<span style={{ fontSize: 12.5, color: "var(--fg-mute)", lineHeight: 1.45 }}>
|
||||
{opt.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{active && (
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: "50%",
|
||||
background: "var(--accent)",
|
||||
display: "grid", placeItems: "center",
|
||||
color: "var(--accent-fg)",
|
||||
flexShrink: 0,
|
||||
marginTop: 6,
|
||||
}}>
|
||||
<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m3 8.5 3.2 3.2L13 5"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slider ─────────────────────────────────────────────────────────────────
|
||||
function Slider({ min, max, step = 1, value, onChange, format }) {
|
||||
return (
|
||||
<div style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
|
||||
{format ? format(min) : min}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "var(--fg)",
|
||||
letterSpacing: "-0.01em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{format ? format(value) : value}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
|
||||
{format ? format(max) : max}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min} max={max} step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{ width: "100%", marginTop: 6, accentColor: "var(--accent)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Lane labels — used by WizardTop and elsewhere.
|
||||
const LANE_LABELS = {
|
||||
entrepreneur: "Solo entrepreneur",
|
||||
owner: "Small business owner",
|
||||
consultant: "Building for clients",
|
||||
};
|
||||
|
||||
Object.assign(window, {
|
||||
WizardTop, WizardBody, WizardQ, WizardFooter,
|
||||
Field, ChipGroup, PresetGroup, Slider,
|
||||
LANE_LABELS,
|
||||
});
|
||||
677
new-site/onboarding.css
Normal file
677
new-site/onboarding.css
Normal file
@@ -0,0 +1,677 @@
|
||||
/* Onboarding shared styles — same tokens as the rest of the site. */
|
||||
|
||||
:root {
|
||||
--bg: oklch(0.155 0.008 60);
|
||||
--bg-1: oklch(0.185 0.009 60);
|
||||
--bg-2: oklch(0.225 0.010 60);
|
||||
--hairline: oklch(0.32 0.010 60 / 0.55);
|
||||
--hairline-2: oklch(0.40 0.012 60 / 0.35);
|
||||
--fg: oklch(0.97 0.005 80);
|
||||
--fg-dim: oklch(0.78 0.006 80);
|
||||
--fg-mute: oklch(0.58 0.006 80);
|
||||
--fg-faint: oklch(0.42 0.006 80);
|
||||
|
||||
--accent: oklch(0.74 0.175 35);
|
||||
--accent-soft: oklch(0.74 0.175 35 / 0.18);
|
||||
--accent-glow: oklch(0.74 0.175 35 / 0.35);
|
||||
--accent-fg: #1a0f0a;
|
||||
|
||||
--ok: oklch(0.78 0.16 155);
|
||||
|
||||
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; min-height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.035;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
||||
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
|
||||
p { margin: 0; }
|
||||
::selection { background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
/* App shell */
|
||||
.app {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 100dvh;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.app-bar {
|
||||
position: relative; z-index: 5;
|
||||
padding: 20px clamp(20px, 4vw, 48px);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.app-bar-left { display: flex; align-items: center; gap: 24px; }
|
||||
.app-step {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-faint);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.app-step::before {
|
||||
content: "";
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.app-bar-right {
|
||||
display: flex; gap: 18px; align-items: center;
|
||||
}
|
||||
.app-bar-right a, .app-bar-right button {
|
||||
font-size: 13px; color: var(--fg-mute);
|
||||
}
|
||||
.app-bar-right a:hover, .app-bar-right button:hover { color: var(--fg); }
|
||||
|
||||
/* Logo */
|
||||
.logo {
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.logo-mark {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
|
||||
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
|
||||
display: grid; place-items: center;
|
||||
color: var(--accent-fg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-mark svg { display: block; }
|
||||
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
|
||||
@keyframes caret-blink { 50% { opacity: 0.25; } }
|
||||
|
||||
/* Main */
|
||||
.screen {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
padding: clamp(40px, 7vh, 80px) clamp(20px, 4vw, 48px) clamp(40px, 6vh, 60px);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.screen-wide {
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
}
|
||||
.screen-content {
|
||||
position: relative; z-index: 2;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; text-align: center;
|
||||
}
|
||||
.screen-content-wide {
|
||||
max-width: 1100px;
|
||||
align-items: stretch; text-align: left;
|
||||
}
|
||||
|
||||
/* Ambient glows */
|
||||
.glow {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
filter: blur(20px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
.eyebrow::before {
|
||||
content: ""; width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.eyebrow-accent { color: var(--accent); }
|
||||
|
||||
.h1 {
|
||||
margin-top: 20px;
|
||||
font-size: clamp(36px, 5.4vw, 64px);
|
||||
font-weight: 500; letter-spacing: -0.03em; line-height: 1.04;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.h1 em {
|
||||
font-style: normal;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 30px var(--accent-glow);
|
||||
}
|
||||
.sub {
|
||||
margin-top: 18px;
|
||||
font-size: clamp(15px, 1.55vw, 18px);
|
||||
color: var(--fg-mute);
|
||||
line-height: 1.55;
|
||||
text-wrap: balance;
|
||||
max-width: 540px;
|
||||
}
|
||||
.sub b { color: var(--fg); font-weight: 500; }
|
||||
|
||||
.tagline {
|
||||
display: inline-flex; align-items: center; gap: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fg-faint);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tagline::before, .tagline::after {
|
||||
content: ""; width: 28px; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--hairline), transparent);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||
height: 50px; padding: 0 22px;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
transition: transform .12s, box-shadow .2s, background .2s, border-color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
|
||||
0 10px 40px -10px var(--accent-glow),
|
||||
0 0 40px -8px var(--accent-glow);
|
||||
}
|
||||
.btn-primary:hover { transform: translateY(-1px); }
|
||||
.btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
.btn-primary .arrow { transition: transform .15s; }
|
||||
.btn-primary:hover .arrow { transform: translateX(3px); }
|
||||
.btn-ghost {
|
||||
background: oklch(0.20 0.009 60 / 0.6);
|
||||
border: 1px solid var(--hairline);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
|
||||
|
||||
.link-quiet {
|
||||
font-size: 13px;
|
||||
color: var(--fg-mute);
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border-bottom: 1px dashed var(--hairline);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.link-quiet:hover { color: var(--fg); border-color: var(--accent); }
|
||||
|
||||
/* Or divider */
|
||||
.or-divider {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin: 28px 0 18px;
|
||||
width: 100%; max-width: 360px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.or-divider::before, .or-divider::after {
|
||||
content: ""; flex: 1; height: 1px; background: var(--hairline);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.field {
|
||||
width: 100%;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
margin-top: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.field-hint {
|
||||
font-size: 13px;
|
||||
color: var(--fg-mute);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: oklch(0.16 0.008 60 / 0.8);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 12px;
|
||||
color: var(--fg);
|
||||
font: 15px/1.5 var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color .15s, background .15s, box-shadow .15s;
|
||||
resize: vertical;
|
||||
}
|
||||
.input::placeholder { color: var(--fg-faint); }
|
||||
.input:focus {
|
||||
border-color: oklch(0.74 0.175 35 / 0.65);
|
||||
background: oklch(0.18 0.009 60 / 0.95);
|
||||
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
|
||||
}
|
||||
.input-textarea { min-height: 110px; resize: vertical; }
|
||||
.input-large { padding: 20px 22px; font-size: 17px; border-radius: 16px; }
|
||||
|
||||
/* Hero prompt input */
|
||||
.prompt {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.prompt-frame {
|
||||
position: relative;
|
||||
border-radius: 22px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(180deg,
|
||||
oklch(0.50 0.06 35 / 0.6),
|
||||
oklch(0.30 0.012 60 / 0.4) 40%,
|
||||
oklch(0.25 0.012 60 / 0.4));
|
||||
box-shadow:
|
||||
0 30px 80px -20px oklch(0 0 0 / 0.6),
|
||||
0 0 80px -20px var(--accent-glow);
|
||||
}
|
||||
.prompt-inner {
|
||||
background: linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92));
|
||||
border-radius: 21px;
|
||||
padding: 18px 20px 14px;
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex; flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.prompt-inner textarea {
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--fg);
|
||||
font: 17px/1.5 var(--font-sans);
|
||||
resize: none;
|
||||
outline: none;
|
||||
padding: 4px;
|
||||
}
|
||||
.prompt-typed {
|
||||
position: absolute;
|
||||
top: 22px; left: 24px; right: 24px;
|
||||
pointer-events: none;
|
||||
color: var(--fg-faint);
|
||||
font: 17px/1.5 var(--font-sans);
|
||||
text-align: left;
|
||||
}
|
||||
.prompt-typed::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px; height: 18px;
|
||||
background: var(--accent);
|
||||
vertical-align: -3px;
|
||||
margin-left: 2px;
|
||||
animation: blink 1s steps(2) infinite;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.prompt-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
.prompt-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-faint);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Chip / option grid */
|
||||
.chips {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.chip {
|
||||
padding: 9px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--hairline);
|
||||
background: oklch(0.20 0.009 60 / 0.5);
|
||||
color: var(--fg-dim);
|
||||
font-size: 13.5px;
|
||||
transition: border-color .15s, color .15s, background .15s, transform .12s;
|
||||
}
|
||||
.chip:hover { border-color: var(--hairline-2); color: var(--fg); transform: translateY(-1px); }
|
||||
.chip.active {
|
||||
border-color: var(--accent);
|
||||
background: oklch(0.20 0.04 35 / 0.4);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Preset chips */
|
||||
.preset-row {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.preset-chip {
|
||||
padding: 11px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--hairline);
|
||||
background: oklch(0.18 0.009 60 / 0.6);
|
||||
color: var(--fg-dim);
|
||||
font: 500 14.5px var(--font-mono);
|
||||
letter-spacing: -0.005em;
|
||||
transition: all .15s;
|
||||
}
|
||||
.preset-chip:hover { border-color: var(--hairline-2); color: var(--fg); }
|
||||
.preset-chip.active {
|
||||
border-color: var(--accent);
|
||||
background: oklch(0.20 0.04 35 / 0.4);
|
||||
color: var(--fg);
|
||||
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.1);
|
||||
}
|
||||
|
||||
/* Trust strip */
|
||||
.trust {
|
||||
margin-top: 36px;
|
||||
display: flex; gap: 14px; justify-content: center; align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.trust .sep { opacity: 0.5; }
|
||||
|
||||
/* CTA row */
|
||||
.cta-row {
|
||||
margin-top: 36px;
|
||||
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
border: 2px solid oklch(0 0 0 / 0.2);
|
||||
border-top-color: var(--accent-fg);
|
||||
animation: spin .9s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
.spinner-line {
|
||||
width: 12px; height: 12px;
|
||||
border-color: var(--hairline);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Surface card */
|
||||
.surface {
|
||||
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55));
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* ── Wizard chrome ───────────────────────────────────────────────────── */
|
||||
/* The persistent top strip with progress bar + back + step text + close. */
|
||||
.wiz-top {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
background: oklch(0.155 0.008 60 / 0.85);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.wiz-top-row {
|
||||
height: 54px;
|
||||
padding: 0 clamp(16px, 3vw, 28px);
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
}
|
||||
.wiz-iconbtn {
|
||||
width: 32px; height: 32px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--fg-mute);
|
||||
border: 1px solid transparent;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wiz-iconbtn:hover {
|
||||
color: var(--fg);
|
||||
background: oklch(0.20 0.009 60 / 0.6);
|
||||
border-color: var(--hairline);
|
||||
}
|
||||
.wiz-iconbtn[disabled] { opacity: 0; pointer-events: none; }
|
||||
|
||||
.wiz-logo {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-weight: 500; font-size: 14px; letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wiz-logo .logo-mark { width: 22px; height: 22px; }
|
||||
|
||||
.wiz-step {
|
||||
flex: 1;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-mute);
|
||||
letter-spacing: 0.04em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wiz-step b { color: var(--fg); font-weight: 500; }
|
||||
.wiz-step .dot {
|
||||
width: 4px; height: 4px; border-radius: 50%;
|
||||
background: var(--fg-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wiz-step .lane {
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 10.5px;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.wiz-step .lane::before {
|
||||
content: ""; width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--accent); box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.wiz-progress {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: oklch(0.30 0.010 60 / 0.35);
|
||||
}
|
||||
.wiz-progress-fill {
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent-glow);
|
||||
transition: width .35s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wiz-step .lane { display: none; }
|
||||
.wiz-step .dot:first-of-type { display: none; }
|
||||
}
|
||||
|
||||
/* ── Wizard body ─────────────────────────────────────────────────────── */
|
||||
.wiz-body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
padding: clamp(40px, 7vh, 88px) clamp(20px, 4vw, 32px) clamp(40px, 6vh, 64px);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.wiz-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
.wiz-card.wide { max-width: 760px; }
|
||||
.wiz-card.xwide { max-width: 1040px; }
|
||||
|
||||
/* Question heading — quiet, one line, no em accents */
|
||||
.wiz-q { display: flex; flex-direction: column; gap: 10px; }
|
||||
.wiz-q h2 {
|
||||
font-size: clamp(22px, 2.4vw, 28px);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.018em;
|
||||
line-height: 1.22;
|
||||
color: var(--fg);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.wiz-q p {
|
||||
font-size: 14.5px;
|
||||
color: var(--fg-mute);
|
||||
line-height: 1.55;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
/* Footer with back/continue */
|
||||
.wiz-foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.wiz-foot-left {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
.wiz-foot-right {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.wiz-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-faint);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.wiz-skip {
|
||||
font-size: 13.5px;
|
||||
color: var(--fg-mute);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wiz-skip:hover { color: var(--fg); background: oklch(0.20 0.009 60 / 0.5); }
|
||||
|
||||
.btn-wiz {
|
||||
height: 42px;
|
||||
padding: 0 18px;
|
||||
font-size: 14px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Fields tightened up for wizard context */
|
||||
.wiz-field {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.wiz-field-label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-dim);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.wiz-field-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--fg-mute);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wiz-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: oklch(0.16 0.008 60 / 0.8);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 10px;
|
||||
color: var(--fg);
|
||||
font: 14.5px/1.5 var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color .15s, background .15s, box-shadow .15s;
|
||||
}
|
||||
.wiz-input::placeholder { color: var(--fg-faint); }
|
||||
.wiz-input:focus {
|
||||
border-color: oklch(0.74 0.175 35 / 0.6);
|
||||
background: oklch(0.18 0.009 60 / 0.95);
|
||||
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12);
|
||||
}
|
||||
textarea.wiz-input { min-height: 96px; resize: vertical; }
|
||||
|
||||
/* Debug navigator panel */
|
||||
.debug {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
z-index: 1000;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.debug-toggle {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: oklch(0.18 0.009 60 / 0.85);
|
||||
border: 1px solid var(--hairline);
|
||||
color: var(--fg-mute);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.debug-toggle:hover { color: var(--fg); border-color: var(--hairline-2); }
|
||||
.debug-panel {
|
||||
width: 240px;
|
||||
padding: 12px;
|
||||
background: oklch(0.16 0.008 60 / 0.95);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
}
|
||||
.debug-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--fg-mute);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
}
|
||||
.debug-row:hover { background: oklch(0.20 0.009 60); color: var(--fg-dim); }
|
||||
.debug-row.active {
|
||||
background: oklch(0.74 0.175 35 / 0.18);
|
||||
color: var(--accent);
|
||||
}
|
||||
.debug-row b { color: inherit; font-weight: 600; }
|
||||
189
new-site/vibn-signin.html
Normal file
189
new-site/vibn-signin.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user