feat: support root-level _marketing and _onboarding directories (T12)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user