feat: support root-level _marketing and _onboarding directories (T12)

This commit is contained in:
2026-06-06 18:32:57 -07:00
parent 1ed76c99b8
commit 47417d13a0
22 changed files with 695 additions and 688 deletions

View File

@@ -1,596 +0,0 @@
// Mock data for the contractor onboarding. The UI is fully interactive against
// these. The cheaper model replaces each with a real endpoint (see
// onboarding-agency-types.ts for the exact shapes) — DO NOT delete the types;
// only swap the data source.
//
// Vibn builds CUSTOM TOOLS for local businesses. The consultant picks the tool
// categories they want to build; we intersect those with each SMB type's
// `softwareNeeds` (market_data_assets/smb_to_software_mapping_final.json) to
// recommend local businesses they could target.
import type {
CityRef,
TerritoryOpportunity,
ToolCategory,
} from "./onboarding-agency-types";
// ── Cities (DEV FALLBACK ONLY) ───────────────────────────────────────────────
// Production city lookup hits Places API (New) Autocomplete (global, 200M+
// places) via GET /api/agency/cities — see CityLookup, which calls that endpoint
// and only falls back to this list when offline.
export const STARTER_CITIES: CityRef[] = [
{
id: "victoria-bc",
name: "Victoria",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "vancouver-bc",
name: "Vancouver",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "nanaimo-bc",
name: "Nanaimo",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "kelowna-bc",
name: "Kelowna",
region: "BC",
country: "Canada",
countryCode: "CA",
},
{
id: "calgary-ab",
name: "Calgary",
region: "AB",
country: "Canada",
countryCode: "CA",
},
{
id: "edmonton-ab",
name: "Edmonton",
region: "AB",
country: "Canada",
countryCode: "CA",
},
{
id: "toronto-on",
name: "Toronto",
region: "ON",
country: "Canada",
countryCode: "CA",
},
{
id: "ottawa-on",
name: "Ottawa",
region: "ON",
country: "Canada",
countryCode: "CA",
},
{
id: "montreal-qc",
name: "Montreal",
region: "QC",
country: "Canada",
countryCode: "CA",
},
{
id: "halifax-ns",
name: "Halifax",
region: "NS",
country: "Canada",
countryCode: "CA",
},
{
id: "seattle-wa",
name: "Seattle",
region: "WA",
country: "United States",
countryCode: "US",
},
{
id: "portland-or",
name: "Portland",
region: "OR",
country: "United States",
countryCode: "US",
},
{
id: "san-francisco-ca",
name: "San Francisco",
region: "CA",
country: "United States",
countryCode: "US",
},
{
id: "austin-tx",
name: "Austin",
region: "TX",
country: "United States",
countryCode: "US",
},
{
id: "denver-co",
name: "Denver",
region: "CO",
country: "United States",
countryCode: "US",
},
{
id: "chicago-il",
name: "Chicago",
region: "IL",
country: "United States",
countryCode: "US",
},
{
id: "new-york-ny",
name: "New York",
region: "NY",
country: "United States",
countryCode: "US",
},
{
id: "london-uk",
name: "London",
region: "England",
country: "United Kingdom",
countryCode: "GB",
},
{
id: "sydney-au",
name: "Sydney",
region: "NSW",
country: "Australia",
countryCode: "AU",
},
];
export const DEFAULT_CITY: CityRef = STARTER_CITIES[0];
export const cityLabel = (c: CityRef) => `${c.name}, ${c.region}`;
/** GET /api/agency/cities?q= — mock typeahead over the seed list. */
export function searchCities(query: string): CityRef[] {
const q = query.trim().toLowerCase();
if (!q) return STARTER_CITIES.slice(0, 8);
return STARTER_CITIES.filter(
(c) =>
cityLabel(c).toLowerCase().includes(q) ||
c.country.toLowerCase().includes(q),
).slice(0, 8);
}
// ── Tool categories the consultant can build ─────────────────────────────────
// GET /api/agency/tool-categories. `label` MUST match the strings in
// smb_to_software_mapping so the targeting intersection works. These are the
// common horizontal tools; the backend can expose the full ~524 from the mapping.
export const TOOL_CATEGORIES: ToolCategory[] = [
{
id: "reporting",
label: "Reporting / Dashboard Software",
short: "Reporting & dashboards",
icon: "chart",
},
{
id: "scheduling",
label: "Appointment Scheduling Software",
short: "Booking & scheduling",
icon: "calendar",
},
{ id: "crm", label: "CRM Software", short: "Customer CRM", icon: "users" },
{
id: "invoicing",
label: "Invoicing & Payments Software",
short: "Invoicing & payments",
icon: "receipt",
},
{
id: "billing",
label: "Billing Software",
short: "Billing",
icon: "receipt",
},
{
id: "inventory",
label: "Inventory Management Software",
short: "Inventory",
icon: "box",
},
{
id: "pos",
label: "Retail POS System",
short: "Point of sale",
icon: "card",
},
{
id: "ordering",
label: "Online Ordering Software",
short: "Online ordering",
icon: "cart",
},
{
id: "reservations",
label: "Reservations & Online Bookings",
short: "Reservations",
icon: "calendar",
},
{
id: "fsm",
label: "Field Service Management (FSM)",
short: "Field service / dispatch",
icon: "clipboard",
},
{
id: "staff",
label: "Employee Scheduling Software",
short: "Staff scheduling",
icon: "users",
},
{
id: "membership",
label: "Membership Management Software",
short: "Memberships",
icon: "badge",
},
{
id: "marketing",
label: "Marketing Automation Software",
short: "Marketing automation",
icon: "megaphone",
},
{
id: "accounting",
label: "Accounting Software",
short: "Accounting",
icon: "chart",
},
{
id: "orders",
label: "Order Management Software",
short: "Order management",
icon: "clipboard",
},
];
// ── SMB types + their software needs (sample from the mapping) ────────────────
// Real targeting reads the full smb_to_software_mapping; this is a grounded
// sample so the intersection responds to selections offline. Counts are mock.
type SmbTarget = {
gcid: string;
displayName: string;
softwareNeeds: string[];
businessCount: number;
weakPct: number;
claimed: number;
};
const SMB_TARGETS: SmbTarget[] = [
{
gcid: "gcid:dentist",
displayName: "Dentists",
businessCount: 312,
weakPct: 0.71,
claimed: 0,
softwareNeeds: [
"Dental Practice Management",
"EHR / EMR Software",
"Medical Billing Software",
"Patient Scheduling Software",
"Appointment Scheduling Software",
],
},
{
gcid: "gcid:plumber",
displayName: "Plumbers",
businessCount: 287,
weakPct: 0.86,
claimed: 0,
softwareNeeds: [
"Plumbing Software",
"Field Service Management (FSM)",
"Scheduling Software",
"Billing Software",
"Invoicing & Payments Software",
],
},
{
gcid: "gcid:hvac_contractor",
displayName: "HVAC contractors",
businessCount: 248,
weakPct: 0.83,
claimed: 1,
softwareNeeds: [
"HVAC Software",
"Field Service Management (FSM)",
"Accounting Software",
"Invoicing & Payments Software",
],
},
{
gcid: "gcid:hair_salon",
displayName: "Hair salons",
businessCount: 524,
weakPct: 0.74,
claimed: 6,
softwareNeeds: [
"Salon / Spa Management Software",
"Appointment Scheduling Software",
"Billing Software",
"CRM Software",
],
},
{
gcid: "gcid:beauty_salon",
displayName: "Beauty salons",
businessCount: 411,
weakPct: 0.76,
claimed: 3,
softwareNeeds: [
"Salon / Spa Management Software",
"Appointment Scheduling Software",
"CRM Software",
"Retail POS System",
"Accounting Software",
],
},
{
gcid: "gcid:auto_repair_shop",
displayName: "Auto repair shops",
businessCount: 198,
weakPct: 0.8,
claimed: 1,
softwareNeeds: [
"Auto Repair Shop Software",
"Invoicing & Payments Software",
"Appointment Scheduling Software",
"Inventory Management Software",
"CRM Software",
],
},
{
gcid: "gcid:gym",
displayName: "Gyms & fitness studios",
businessCount: 134,
weakPct: 0.58,
claimed: 2,
softwareNeeds: [
"Fitness & Gym Management Software",
"Membership Management Software",
"Appointment Scheduling Software",
"Billing Software",
"CRM Software",
],
},
{
gcid: "gcid:restaurant",
displayName: "Restaurants",
businessCount: 642,
weakPct: 0.69,
claimed: 5,
softwareNeeds: [
"Restaurant POS Software",
"Inventory Management Software",
"Employee Scheduling Software",
"Online Ordering Software",
],
},
{
gcid: "gcid:medical_spa",
displayName: "Med spas",
businessCount: 96,
weakPct: 0.62,
claimed: 0,
softwareNeeds: [
"Medical Spa Software",
"Appointment Scheduling Software",
"CRM Software",
"Retail POS System",
"Marketing Automation Software",
],
},
{
gcid: "gcid:electrician",
displayName: "Electricians",
businessCount: 233,
weakPct: 0.82,
claimed: 1,
softwareNeeds: [
"Electrical Contractor Software",
"Field Service Management (FSM)",
"Accounting Software",
"Invoicing & Payments Software",
],
},
{
gcid: "gcid:veterinarian",
displayName: "Veterinarians",
businessCount: 88,
weakPct: 0.64,
claimed: 0,
softwareNeeds: [
"Veterinary Management Software",
"Appointment Scheduling Software",
"Inventory Management Software",
"Medical Billing Software",
],
},
{
gcid: "gcid:coffee_shop",
displayName: "Coffee shops",
businessCount: 276,
weakPct: 0.67,
claimed: 4,
softwareNeeds: [
"Restaurant POS Software",
"Inventory Management Software",
"Employee Scheduling Software",
"Marketing Automation Software",
"Online Ordering Software",
],
},
{
gcid: "gcid:florist",
displayName: "Florists",
businessCount: 109,
weakPct: 0.78,
claimed: 0,
softwareNeeds: [
"Florist Software",
"Retail POS System",
"Inventory Management Software",
"Online Ordering Software",
"Order Management Software",
],
},
{
gcid: "gcid:landscaper",
displayName: "Landscapers",
businessCount: 264,
weakPct: 0.85,
claimed: 1,
softwareNeeds: [
"Landscape / Lawn Care Software",
"Field Service Management (FSM)",
"Appointment Scheduling Software",
"Billing Software",
"CRM Software",
],
},
{
gcid: "gcid:accounting_firm",
displayName: "Accounting firms",
businessCount: 187,
weakPct: 0.59,
claimed: 2,
softwareNeeds: [
"Accounting Practice Management",
"Accounting Software",
"CRM Software",
],
},
{
gcid: "gcid:bakery",
displayName: "Bakeries",
businessCount: 142,
weakPct: 0.72,
claimed: 0,
softwareNeeds: [
"Bakery Software",
"Inventory Management Software",
"Order Management Software",
"Employee Scheduling Software",
],
},
];
// ── AI expertise → tool categories ───────────────────────────────────────────
// POST /api/agency/analyze-expertise { text } -> { tools }. The real version is
// an LLM call; this is a keyword stub so the flow works offline. Maps free-text
// expertise to canonical tool-category labels that join to the SMB mapping.
const EXPERTISE_KEYWORDS: Array<{ re: RegExp; label: string }> = [
{
re: /report|dashboard|analytic|insight|\bbi\b/i,
label: "Reporting / Dashboard Software",
},
{
re: /crm|customer relationship|\bleads?\b|\bclients?\b/i,
label: "CRM Software",
},
{
re: /schedul|book|appointment|calendar/i,
label: "Appointment Scheduling Software",
},
{ re: /invoic|payment/i, label: "Invoicing & Payments Software" },
{ re: /billing/i, label: "Billing Software" },
{ re: /inventor|stock/i, label: "Inventory Management Software" },
{
re: /\bpos\b|point of sale|checkout|register/i,
label: "Retail POS System",
},
{ re: /online order|ordering|takeout/i, label: "Online Ordering Software" },
{ re: /reservation/i, label: "Reservations & Online Bookings" },
{
re: /field service|dispatch|\bfsm\b|work order/i,
label: "Field Service Management (FSM)",
},
{
re: /staff schedul|employee schedul|\brota\b|\bshifts?\b/i,
label: "Employee Scheduling Software",
},
{ re: /member/i, label: "Membership Management Software" },
{
re: /marketing|campaign|email automat|newsletter/i,
label: "Marketing Automation Software",
},
{ re: /account|bookkeep/i, label: "Accounting Software" },
{
re: /order management|fulfilment|fulfillment/i,
label: "Order Management Software",
},
];
export function extractTools(text: string): string[] {
const out: string[] = [];
for (const k of EXPERTISE_KEYWORDS) {
if (k.re.test(text) && !out.includes(k.label)) out.push(k.label);
}
return out;
}
/**
* POST /api/agency/targets { city, tools }
* Recommends SMB types whose softwareNeeds intersect the consultant's tools,
* scored by demand × gap × low Vibn saturation, biased toward how many of the
* consultant's tools each SMB needs.
*/
export function mockTargets(
city: CityRef,
tools: string[],
): TerritoryOpportunity[] {
const want = new Set(tools);
// Reporting / dashboards apply to virtually every business, so treat it as a
// universal need rather than listing it on every SMB type.
const UNIVERSAL = ["Reporting / Dashboard Software"];
return SMB_TARGETS.map((s, i) => {
const needs = [
...s.softwareNeeds,
...UNIVERSAL.filter((u) => !s.softwareNeeds.includes(u)),
];
const matchedTools = needs.filter((n) => want.has(n));
return { s, matchedTools, i };
})
.filter((x) => x.matchedTools.length > 0)
.map(({ s, matchedTools, i }) => {
const weak = Math.round(s.businessCount * s.weakPct);
const saturation = s.claimed / Math.max(1, s.businessCount / 20);
const demand = s.weakPct * (1 - Math.min(1, saturation));
const fit = Math.min(1, matchedTools.length / Math.max(1, tools.length));
const score = Math.max(
28,
Math.min(98, Math.round((demand * 0.7 + fit * 0.3) * 100)),
);
const status: TerritoryOpportunity["status"] =
s.claimed === 0 ? "open" : s.claimed <= 2 ? "contested" : "claimed";
return {
id: `${s.gcid}-${i}`,
gcid: s.gcid,
niche: s.displayName,
city: city.name,
businessCount: s.businessCount,
weakWebsiteCount: weak,
vibnClaimedCount: s.claimed,
opportunityScore: score,
status,
matchedTools,
};
})
.sort((a, b) => b.opportunityScore - a.opportunityScore);
}

View File

@@ -1,123 +0,0 @@
// Data contracts for the contractor-first "Set up your agency" onboarding.
//
// THE SEAM: the frontend (onboarding-agency.tsx) is built fully against these
// types + mock data (onboarding-agency-mock.ts). The cheaper model implements
// the endpoints below to these exact shapes — no shared write scope.
//
// Endpoints to implement (all scoped to the signed-in consultant's workspace):
// GET /api/agency/cities?q=<query>
// -> CityRef[] (Places API (New) Autocomplete → Place Details for the
// locality's region/country/latlng — GLOBAL, 200M+ places)
// POST /api/agency/analyze-expertise { text: string }
// -> { tools: string[] } (LLM extracts canonical tool-category labels from
// the consultant's free-text expertise)
// POST /api/agency/targets { city: CityRef, tools: string[] }
// -> TerritoryOpportunity[] (SMB types whose softwareNeeds ∩ tools; per-city
// counts via Places Aggregate API × gap × saturation)
// POST /api/agency AgencyOnboardingResult
// -> { workspaceSlug: string; prospectId: string }
//
// DATA ALIGNMENT (see market_data_assets/):
// - Cities are GLOBAL via Places API (New) Autocomplete + Place Details — not a fixed list.
// - Niches are Google Business Profile categories keyed by `gcid` (e.g. "gcid:dentist");
// the gcid suffix maps to a Places `type` for the Aggregate query. Canonical list in
// gbp_categories.json / canada.md / us.md.
// - Per-city business counts come from the Places Aggregate API (computeInsights),
// filtered by place type within the city's area — works anywhere.
// dfs_categories_raw.json / camp_market_sizes.csv are country-level benchmarks for
// reference only; CityRef.countryCode joins to them where coverage exists (CA/US today).
// - TOOLS the consultant builds are software categories; the targeting step
// intersects them with each SMB type's `softwareNeeds`
// (smb_to_software_mapping_final.json) to recommend local businesses to target.
//
// "Illustrative" economics: until the metering ledger is live, cost/price numbers
// are platform estimates and MUST be labeled as such in the UI.
/**
* A geocoded place. Sourced from Places Autocomplete at runtime; `countryCode`
* is the join key into our country-split market data (camp_market_sizes.csv etc.).
*/
export interface CityRef {
/** Stable id — a Places id, or a `${name}-${region}` slug for seed data. */
id: string;
/** "Victoria" */
name: string;
/** Province / state code — "BC", "WA". */
region: string;
/** "Canada" / "United States". */
country: string;
/** ISO 3166-1 alpha-2 ("CA", "US", "GB"…). From Place Details; benchmark join key. */
countryCode: string;
lat?: number;
lng?: number;
}
export interface AgencyProfile {
/** Agency / studio name the consultant chooses. */
name: string;
/** Optional one-line positioning. */
tagline?: string;
/** Optional uploaded logo. */
logoUrl?: string;
/** Home city — seeds the targeting step. */
city?: CityRef;
/** Online presence the consultant already has (else Vibn can set them up). */
hasWebsite?: boolean;
websiteUrl?: string;
hasSocials?: boolean;
hasBlog?: boolean;
hasCustomDomain?: boolean;
hasExistingClients?: boolean;
}
/**
* A category of custom tool the consultant builds. `label` matches the
* softwareNeeds strings in the SMB mapping so targeting can intersect on it.
*/
export interface ToolCategory {
id: string;
/** Canonical software-category label (matches smb_to_software_mapping). */
label: string;
/** Short human label for the card. */
short: string;
/** Icon key resolved by the UI. */
icon: string;
}
export type TerritoryStatus = "open" | "contested" | "claimed";
export interface TerritoryOpportunity {
id: string;
/** Google Business Profile category key, e.g. "gcid:dentist". Joins to market data. */
gcid: string;
/** Display label, e.g. "Dentists" (GBP displayName). */
niche: string;
city: string;
/** Total local businesses in this niche (TAM). */
businessCount: number;
/** How many have a weak / missing website or Google profile (the gap). */
weakWebsiteCount: number;
/** How many are already served by a Vibn consultant (saturation). */
vibnClaimedCount: number;
/** 0..100 — demand × gap × low-saturation, biased by tool-fit. Higher = more open. */
opportunityScore: number;
status: TerritoryStatus;
/** Which of the consultant's tools this SMB needs (the intersection). */
matchedTools?: string[];
/** Secondary matching niches discovered for this business */
alternativeCategories?: Array<{
gcid: string;
typeLabel: string;
presetTools: string[];
description: string;
}>;
}
/** Final payload posted to create the agency workspace; then route to the dashboard. */
export interface AgencyOnboardingResult {
profile: AgencyProfile;
/** The consultant's free-text expertise ("what I love helping people build"). */
expertise: string;
/** Tool-category labels the AI extracted from `expertise` (match the mapping). */
tools: string[];
}

View File

@@ -1,493 +0,0 @@
"use client";
import React from "react";
import {
WizardTop,
WizardBody,
WizardQ,
WizardFooter,
Field,
} from "./onboarding-primitives";
import {
cityLabel,
extractTools,
searchCities,
} from "./onboarding-agency-mock";
import {
type AgencyOnboardingResult,
type AgencyProfile,
type CityRef,
} from "./onboarding-agency-types";
// Contractor-first onboarding — "Set up your AI agency".
// Vibn builds CUSTOM TOOLS for local businesses. We capture who the consultant
// is and what they love building, then drop them into their dashboard — where
// the local-business targeting recommendations live as an ongoing feature.
// Steps: identity → presence → expertise → (dashboard).
// Built to the Vibn design concept: dark wizard surface, coral accent, used sparingly.
// Fully interactive against mock data; swap the mock calls for the endpoints
// documented in onboarding-agency-types.ts.
const STEPS = ["identity", "presence", "expertise"] as const;
type Step = (typeof STEPS)[number];
export interface AgencyOnboardingProps {
/** Fired with the assembled result; wire to POST /api/agency then route to the dashboard. */
onComplete: (result: AgencyOnboardingResult) => void;
/** Save & exit. */
onExit: () => void;
/** Back to the front-door fork. */
onBack: () => void;
}
export function AgencyOnboarding({
onComplete,
onExit,
onBack,
}: AgencyOnboardingProps) {
const [stepIdx, setStepIdx] = React.useState(0);
const step: Step = STEPS[stepIdx];
const [profile, setProfile] = React.useState<AgencyProfile>({
name: "",
city: undefined,
});
const [expertise, setExpertise] = React.useState("");
const detectedTools = React.useMemo(
() => extractTools(expertise),
[expertise],
);
const goNext = () => setStepIdx((i) => Math.min(STEPS.length - 1, i + 1));
const goPrev = () => (stepIdx === 0 ? onBack() : setStepIdx((i) => i - 1));
const finish = () => {
onComplete({
profile,
expertise,
tools: detectedTools,
});
};
const stepLabel = (
{
identity: "Your agency",
presence: "Your presence",
expertise: "Ideal customer",
} as Record<Step, string>
)[step];
return (
<>
<WizardTop
onBack={goPrev}
onClose={onExit}
lane="Agency"
stepText={stepLabel}
current={stepIdx + 1}
total={STEPS.length}
/>
{step === "identity" && (
<IdentityStep profile={profile} onChange={setProfile} onNext={goNext} />
)}
{step === "presence" && (
<PresenceStep profile={profile} onChange={setProfile} onNext={goNext} />
)}
{step === "expertise" && (
<IdealCustomerStep
value={expertise}
onChange={setExpertise}
onNext={finish}
/>
)}
</>
);
}
// ── Step 2 · Identity ────────────────────────────────────────────────────────
function IdentityStep({
profile,
onChange,
onNext,
}: {
profile: AgencyProfile;
onChange: (p: AgencyProfile) => void;
onNext: () => void;
}) {
return (
<WizardBody>
<WizardQ
title="Name your agency."
sub="This is how you'll show up to clients. You can change it anytime."
/>
<Field label="Agency name">
<input
className="wiz-input"
placeholder="e.g. Riverside Studio"
value={profile.name}
autoFocus
onChange={(e) => onChange({ ...profile, name: e.target.value })}
/>
</Field>
<Field
label="Your city"
hint="Start typing — we'll find open territory near you."
>
<CityLookup
value={profile.city}
onChange={(c) => onChange({ ...profile, city: c })}
/>
</Field>
<WizardFooter
onNext={onNext}
canNext={profile.name.trim().length > 1 && !!profile.city}
nextLabel="Continue"
hint={
profile.name && profile.city ? "Press ⌘↵" : "Name + city to continue"
}
/>
</WizardBody>
);
}
// ── City lookup (typeahead) ──────────────────────────────────────────────────
// Hits Places API (New) Autocomplete via GET /api/agency/cities; falls back to
// the seed list only when that endpoint isn't reachable (offline dev).
export function CityLookup({
value,
onChange,
}: {
value?: CityRef;
onChange: (c: CityRef) => void;
}) {
const [query, setQuery] = React.useState(value ? cityLabel(value) : "");
const [open, setOpen] = React.useState(false);
const [results, setResults] = React.useState<CityRef[]>(() =>
searchCities(""),
);
const listboxId = React.useId();
React.useEffect(() => {
let cancelled = false;
const handle = setTimeout(async () => {
let next: CityRef[] | null = null;
try {
const res = await fetch(
`/api/agency/cities?q=${encodeURIComponent(query)}`,
);
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) next = data as CityRef[];
}
} catch {
/* offline / endpoint missing — use the seed fallback */
}
if (cancelled) return;
setResults(next && next.length ? next : searchCities(query));
}, 180);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [query]);
const select = (c: CityRef) => {
onChange(c);
setQuery(cityLabel(c));
setOpen(false);
};
return (
<div style={{ position: "relative" }}>
<input
className="wiz-input"
placeholder="Start typing a city…"
value={query}
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
autoComplete="off"
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
/>
{open && results.length > 0 && (
<div
id={listboxId}
role="listbox"
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 20,
background: "var(--bg-1)",
border: "1px solid var(--hairline-2)",
borderRadius: 10,
overflow: "hidden",
maxHeight: 260,
overflowY: "auto",
boxShadow: "0 12px 32px -12px rgba(0,0,0,0.55)",
}}
>
{results.map((c) => {
const active = value?.id === c.id;
return (
<button
key={c.id}
type="button"
role="option"
aria-selected={active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(c)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
textAlign: "left",
padding: "9px 12px",
border: "none",
borderBottom: "1px solid var(--hairline)",
background: active ? "var(--accent-soft)" : "transparent",
color: "var(--fg)",
cursor: "pointer",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: active ? "var(--accent)" : "var(--fg-mute)",
flexShrink: 0,
}}
aria-hidden="true"
>
<path d="M12 21s7-6.5 7-11a7 7 0 1 0-14 0c0 4.5 7 11 7 11Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
<span style={{ flex: 1, fontSize: 14 }}>
{c.name}, {c.region}
</span>
<span
className="mono"
style={{
fontSize: 10,
letterSpacing: "0.06em",
color: "var(--fg-faint)",
}}
>
{c.countryCode}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
// ── Step 3 · Online presence ─────────────────────────────────────────────────
function PresenceStep({
profile,
onChange,
onNext,
}: {
profile: AgencyProfile;
onChange: (p: AgencyProfile) => void;
onNext: () => void;
}) {
return (
<WizardBody>
<WizardQ
title="What does your agency have today?"
sub="This helps us understand your agency setup so we can tailor your starting dashboard."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<ToggleRow
label="I have a Website"
on={!!profile.hasWebsite}
onToggle={() =>
onChange({ ...profile, hasWebsite: !profile.hasWebsite })
}
/>
{profile.hasWebsite && (
<input
className="wiz-input"
placeholder="yoursite.com"
value={profile.websiteUrl ?? ""}
onChange={(e) =>
onChange({ ...profile, websiteUrl: e.target.value })
}
/>
)}
<ToggleRow
label="I have social media accounts"
on={!!profile.hasSocials}
onToggle={() =>
onChange({ ...profile, hasSocials: !profile.hasSocials })
}
/>
<ToggleRow
label="I have a blog"
on={!!profile.hasBlog}
onToggle={() => onChange({ ...profile, hasBlog: !profile.hasBlog })}
/>
<ToggleRow
label="I have a custom domain (mybusiness.com)"
on={!!profile.hasCustomDomain}
onToggle={() =>
onChange({ ...profile, hasCustomDomain: !profile.hasCustomDomain })
}
/>
<ToggleRow
label="I bill existing customers"
on={!!profile.hasExistingClients}
onToggle={() =>
onChange({
...profile,
hasExistingClients: !profile.hasExistingClients,
})
}
/>
</div>
<WizardFooter onNext={onNext} nextLabel="Continue" hint="Press ⌘↵" />
</WizardBody>
);
}
function ToggleRow({
label,
on,
onToggle,
}: {
label: string;
on: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={on}
onClick={onToggle}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "13px 15px",
borderRadius: 12,
textAlign: "left",
cursor: "pointer",
color: "var(--fg)",
border: `1px solid ${on ? "var(--accent)" : "var(--hairline)"}`,
background: on ? "var(--accent-soft)" : "var(--bg-1)",
transition: "border-color .15s, background .15s",
}}
>
<span style={{ flex: 1, fontSize: 14, fontWeight: 500 }}>{label}</span>
<span
aria-hidden="true"
style={{
width: 40,
height: 24,
flexShrink: 0,
borderRadius: 999,
background: on ? "var(--accent)" : "var(--bg-2)",
position: "relative",
transition: "background .15s",
}}
>
<span
style={{
position: "absolute",
top: 3,
left: on ? 19 : 3,
width: 18,
height: 18,
borderRadius: "50%",
background: on ? "var(--accent-fg)" : "var(--fg-mute)",
transition: "left .15s",
}}
/>
</span>
</button>
);
}
// ── Step 4 · Your ideal customer (final step → dashboard) ────────────────────
function IdealCustomerStep({
value,
onChange,
onNext,
}: {
value: string;
onChange: (s: string) => void;
onNext: () => void;
}) {
const ready = value.trim().length >= 8;
return (
<WizardBody>
<WizardQ
title="Is there a certain type of business or business problem you are passionate about?"
sub="Vibn will help you match with potential customers in your area, and drive awareness of your brand."
/>
<Field label="Your ideal customer">
<textarea
className="wiz-input"
rows={3}
autoFocus
placeholder="e.g. I want to help dentists automate patient booking, or help landscapers coordinate their scheduling and dispatch."
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ resize: "vertical", minHeight: 84 }}
/>
</Field>
<div
style={{ display: "flex", justifyContent: "flex-start", marginTop: 4 }}
>
<button
type="button"
onClick={() => {
onChange(
"I want to help any local business automate their workflows and reporting.",
);
setTimeout(onNext, 50); // slight pause so state propagates
}}
style={{
background: "none",
border: "none",
color: "var(--accent)",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
padding: "4px 0",
textDecoration: "underline",
opacity: 0.85,
transition: "opacity .15s",
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.85")}
>
{"I'm not sure right now"}
</button>
</div>
<WizardFooter
onNext={onNext}
canNext={ready}
nextLabel="Open my dashboard →"
hint={ready ? "Press ⌘↵" : "Tell us a little more"}
/>
</WizardBody>
);
}

View File

@@ -1,447 +0,0 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, LANE_LABELS } from "./onboarding-primitives";
// 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 ───────────────────────────────────────────────────────────
export 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 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</button>
)}
</div>
</div>
</main>
</>
);
}
// ── Ready screen ───────────────────────────────────────────────────────────
export 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 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</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" },
];
}

View File

@@ -1,296 +0,0 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, WizardFooter, Label, LANE_LABELS, PresetGroup, Field } from "./onboarding-primitives";
// Consultant path — 4 steps for freelancers building for a client.
const CONS_TOTAL = 4;
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
export 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 ───────────────────────────────────────────────────────────
export 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>
</>
);
}

View File

@@ -1,276 +0,0 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, WizardFooter, Label, LANE_LABELS, ChipGroup, PresetGroup, Field } from "./onboarding-primitives";
// 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",
];
export 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 ───────────────────────────────────────────────────────────
export 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>
</>
);
}

View File

@@ -1,223 +0,0 @@
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from "react";
import {
WizardTop,
WizardBody,
WizardQ,
WizardFooter,
} from "./onboarding-primitives";
// 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 am building a tool to market to many users",
hint: "Launch a SaaS, platform, or app with a public audience.",
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'm building an internal tool for my work or business",
hint: "Automate your operations or replace the software you 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: "undecided",
label: "I'm not sure yet.",
hint: "Explore possibilities and see what AI can build.",
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="6" />
<path d="M9 11v1M9 6a2 2 0 0 1 2 2c0 2-2 2-2 2" />
</svg>
),
},
];
export function ForkScreen({ name, value, onChange, onClose, onNext }) {
return (
<>
<WizardTop
onBack={null}
onClose={onClose}
stepText="Pick your lane"
current={1}
total={4}
/>
<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>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,548 +0,0 @@
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from "react";
// 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.
export function WizardTop({
onBack,
onClose,
lane, // "Solo / quiet entrepreneur" etc.
stepText, // "Idea" or "Pick your lane"
current,
total, // 1-indexed
progress, // 0..1 (optional override)
}: {
onBack?: (() => void) | null;
onClose?: () => void;
lane?: React.ReactNode;
stepText?: React.ReactNode;
current?: number;
total?: number;
progress?: number;
}) {
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">
<div
style={{
width: 22,
height: 22,
borderRadius: 6,
overflow: "hidden",
display: "inline-block",
}}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
<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 ────────────────────────────────────────────────────
export function WizardBody({
children,
width,
}: {
children?: React.ReactNode;
width?: "wide" | "xwide";
}) {
const cls =
"wiz-card" +
(width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
return (
<main className="wiz-body">
<div className={cls}>{children}</div>
</main>
);
}
// ── Question heading ───────────────────────────────────────────────────────
export function WizardQ({
title,
sub,
}: {
title?: React.ReactNode;
sub?: React.ReactNode;
}) {
return (
<div className="wiz-q">
<h2>{title}</h2>
{sub && <p>{sub}</p>}
</div>
);
}
// ── Footer (back / hint / continue) ────────────────────────────────────────
export function WizardFooter({
onBack,
onNext,
canNext = true,
nextLabel = "Continue",
hint,
onSkip,
skipLabel = "Skip",
}: {
onBack?: () => void;
onNext?: () => void;
canNext?: boolean;
nextLabel?: React.ReactNode;
hint?: React.ReactNode;
onSkip?: () => void;
skipLabel?: React.ReactNode;
}) {
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}{" "}
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
</button>
</div>
</div>
);
}
// ── Field wrappers (wizard variants) ───────────────────────────────────────
export function Field({
label,
hint,
children,
optional,
}: {
label?: React.ReactNode;
hint?: React.ReactNode;
children?: React.ReactNode;
optional?: boolean;
}) {
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) ──────────────────────────────────────────────
export function ChipGroup({
options,
values,
onChange,
allowOther = false,
}: {
options: string[];
values: string[];
onChange?: (v: string[]) => void;
allowOther?: boolean;
}) {
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) ─────────────────────────────────────
export function PresetGroup({
options,
value,
onChange,
columns = 1,
}: {
options: Array<{
id: string;
label?: React.ReactNode;
desc?: React.ReactNode;
icon?: React.ReactNode;
}>;
value?: string;
onChange?: (id: string) => void;
columns?: number;
}) {
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 ─────────────────────────────────────────────────────────────────
export function Slider({
min,
max,
step = 1,
value,
onChange,
format,
}: {
min: number;
max: number;
step?: number;
value: number;
onChange?: (n: number) => void;
format?: (n: number) => React.ReactNode;
}) {
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.
export const LANE_LABELS = {
entrepreneur: "Solo entrepreneur",
owner: "Small business owner",
consultant: "Building for clients",
};

View File

@@ -1,677 +0,0 @@
/* 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; }

View File

@@ -1,685 +1,5 @@
"use client";
import React, { useState, useEffect, useMemo, Fragment } from "react";
import "./onboarding.css";
import { ForkScreen } from "./onboarding-fork";
import { EntrepreneurPath } from "./onboarding-entrepreneur";
import { OwnerPath } from "./onboarding-owner";
import { ConsultantPath } from "./onboarding-consultant";
import { BuildScreen } from "./onboarding-build";
import { ReadyScreen } from "./onboarding-build"; // Assuming ReadyScreen is exported from build
import { AgencyOnboarding } from "./onboarding-agency";
import { type AgencyOnboardingResult } from "./onboarding-agency-types";
import { WizardTop, WizardBody, WizardQ } from "./onboarding-primitives";
import OnboardingPage from "@/_onboarding/page";
// 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.
export default function OnboardingApp() {
const initialName = React.useMemo(() => {
try {
return typeof window !== "undefined"
? localStorage.getItem("vibn:firstName") || ""
: "";
} catch {
return "";
}
}, []);
const [stage, setStage] = React.useState("door"); // door | agency | fork | path | choice | 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<Record<string, unknown>>({});
const [createdSlug, setCreatedSlug] = React.useState<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [debugOpen, setDebugOpen] = React.useState(false);
const update = (patch: Record<string, unknown>) =>
setData((d) => ({ ...d, ...patch }));
// ── GTM Onboarding database saving endpoints ────────────────────────────────
const saveOnboarding = async (
payload: Record<string, unknown>,
): Promise<string | null> => {
setSaving(true);
try {
const res = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
const bodyData = await res.json();
setCreatedSlug(bodyData.slug);
setSaving(false);
return bodyData.slug;
}
} catch (err) {
console.error("Failed to save onboarding selections:", err);
}
setSaving(false);
return null;
};
const finishAgency = async (result: AgencyOnboardingResult) => {
const slug = await saveOnboarding({
isAgency: true,
profile: result.profile,
expertise: result.expertise,
tools: result.tools,
});
if (slug && typeof window !== "undefined") {
window.location.href = "/" + slug;
}
};
const finishPersonal = async (choice: "workspace" | "build") => {
const slug = await saveOnboarding({ isAgency: false, data });
if (slug && typeof window !== "undefined") {
if (choice === "workspace") {
window.location.href = "/" + slug;
} else {
setStage("build");
}
}
};
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
setPath(forkChoice);
setStep(0);
setStage("path");
};
const backToFork = () => {
setStage("fork");
setStep(0);
};
const completePath = () => setStage("choice");
const openWorkspace = () => {
if (createdSlug && typeof window !== "undefined") {
window.location.href = "/" + createdSlug; // Route directly to their live chat workspace!
} else {
setStage("ready");
}
};
const close = () => {
if (typeof window !== "undefined") window.location.href = "/";
};
const openChat = () => {
if (createdSlug && typeof window !== "undefined") {
window.location.href = "/" + createdSlug;
} else if (typeof window !== "undefined") {
window.location.href = "/";
}
};
const openAgency = () => setStage("agency");
const openSelf = () => {
setStage("fork");
setStep(0);
};
// ⌘↵ 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])",
) as HTMLElement;
if (btn) btn.click();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// ── render ───────────────────────────────────────────────────────────
let body;
if (stage === "door") {
body = (
<DoorScreen onAgency={openAgency} onSelf={openSelf} onClose={close} />
);
} else if (stage === "agency") {
body = (
<AgencyOnboarding
onComplete={finishAgency}
onExit={close}
onBack={() => setStage("door")}
/>
);
} else 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 === "choice") {
body = (
<ChoiceScreen
onWorkspace={() => finishPersonal("workspace")}
onBuild={() => finishPersonal("build")}
onClose={close}
resolving={saving}
/>
);
} 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>
);
}
// ── Transition Choice Screen ────────────────────────────────────────────────
// Displays after completing Step 3 (Design) on the Self-Builder / Personal path.
// Lets them choose whether they want to explore their workspace console first,
// or go straight into the co-founder build chat session.
function ChoiceScreen({ onWorkspace, onBuild, onClose, resolving }) {
if (resolving) {
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Saving" />
<WizardBody>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "44px 0",
}}
>
<span className="spinner" style={{ width: 30, height: 30 }}>
<span className="spinner-line" />
</span>
<div style={{ textAlign: "center" }}>
<div
style={{ fontSize: 15, color: "var(--fg)", fontWeight: 500 }}
>
Registering your workspace...
</div>
<div
className="mono"
style={{
marginTop: 6,
fontSize: 12.5,
color: "var(--accent)",
}}
>
Setting up Gitea & GMB pipelines on the server
</div>
</div>
</div>
</WizardBody>
</>
);
}
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Launch option" />
<WizardBody>
<WizardQ
title="Where would you like to start?"
sub="Your workspace is fully mapped out. Choose how you want to dive in."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<DoorCard
onClick={onWorkspace}
title="Go to my workspace"
sub="Explore your live dashboard, manage projects, and view your billing console."
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 3h12v12H3V3Zm6 0v12" />
</svg>
}
/>
<DoorCard
emphasized
onClick={onBuild}
title="Start working on my tool"
sub="Launch the AI co-founder build session and watch your custom tool scaffold in real-time."
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 12 9-9 9 9" />
<path d="M5 10v10h14V10" />
</svg>
}
/>
</div>
</WizardBody>
</>
);
}
// ── Front door ─────────────────────────────────────────────────────────────
// The very first choice. Motivations are opposite, so the openings diverge:
// consultants set up an agency; self-builders go straight to build.
function DoorCard({
emphasized,
icon,
title,
sub,
onClick,
}: {
emphasized?: boolean;
icon: React.ReactNode;
title: React.ReactNode;
sub: React.ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "16px 18px",
borderRadius: 12,
textAlign: "left",
cursor: "pointer",
color: "var(--fg)",
border: `1px solid ${emphasized ? "var(--accent)" : "var(--hairline)"}`,
background: emphasized ? "var(--accent-soft)" : "var(--bg-1)",
transition: "border-color .15s, background .15s, transform .1s",
}}
>
<span
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 10,
display: "grid",
placeItems: "center",
background: emphasized ? "var(--accent)" : "var(--bg-2)",
color: emphasized ? "var(--accent-fg)" : "var(--fg-mute)",
}}
>
{icon}
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 15,
fontWeight: 600,
letterSpacing: "-0.01em",
}}
>
{title}
</span>
<span
style={{
display: "block",
marginTop: 3,
fontSize: 13,
color: "var(--fg-mute)",
lineHeight: 1.45,
}}
>
{sub}
</span>
</span>
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--fg-faint)" }}
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
</button>
);
}
function DoorScreen({ onAgency, onSelf, onClose }) {
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Get started" />
<WizardBody>
<WizardQ
title="What brings you to Vibn?"
sub="This sets up the right workspace for you. You can do both later."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<DoorCard
onClick={onSelf}
title="Personal"
sub={
<>
<span>I want to build my own ideas</span>
<span
style={{
display: "block",
marginTop: 8,
fontSize: 12,
color: "var(--fg-faint)",
lineHeight: 1.4,
}}
>
Go from idea to market, and beyond.
</span>
</>
}
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>
}
/>
<DoorCard
onClick={onAgency}
title="Agency"
sub={
<>
<span>I want to do billable AI work for others</span>
<span
style={{
display: "block",
marginTop: 8,
fontSize: 12,
color: "var(--fg-faint)",
lineHeight: 1.4,
}}
>
VIBN will help you find local businesses that you can build
custom solutions for
</span>
</>
}
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>
}
/>
</div>
</WizardBody>
</>
);
}
export default OnboardingPage;