690 lines
20 KiB
TypeScript
690 lines
20 KiB
TypeScript
"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, Field } from "./onboarding-primitives";
|
|
|
|
// 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 finishNaming = async (workspaceName: string) => {
|
|
const slug = await saveOnboarding({ isAgency: false, data, workspaceName });
|
|
if (slug && typeof window !== "undefined") {
|
|
window.location.href = "/" + slug;
|
|
}
|
|
};
|
|
|
|
// ── transitions ──────────────────────────────────────────────────────
|
|
const confirmFork = () => {
|
|
if (!forkChoice) return;
|
|
if (forkChoice === "undecided") {
|
|
// Bypasses the questionnaire and goes straight to the Name Workspace step!
|
|
setPath("entrepreneur");
|
|
setStep(3); // step === 3 is Page 5 (Name your workspace)
|
|
setStage("path");
|
|
return;
|
|
}
|
|
setPath(forkChoice);
|
|
setStep(0);
|
|
setStage("path");
|
|
};
|
|
const backToFork = () => {
|
|
setStage("fork");
|
|
setStep(0);
|
|
};
|
|
const completePath = () => {
|
|
finishNaming(String(data.bizName || ""));
|
|
};
|
|
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 = (
|
|
<NameWorkspaceScreen
|
|
defaultName={String(data.bizName ?? data.idea ?? "")}
|
|
onSubmit={finishNaming}
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── Name Your Workspace ─────────────────────────────────────────────────────
|
|
// Final step on the Self-Builder / Personal path. Confirm (or edit) the
|
|
// workspace name — pre-filled from what they already told us — then create the
|
|
// workspace and drop them into their dashboard.
|
|
function NameWorkspaceScreen({ defaultName, onSubmit, onClose, resolving }) {
|
|
const [name, setName] = React.useState(defaultName || "");
|
|
const trimmed = name.trim();
|
|
const slug = trimmed
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 32);
|
|
|
|
if (resolving) {
|
|
return (
|
|
<>
|
|
<WizardTop onBack={null} onClose={onClose} stepText="Creating" />
|
|
<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 }}
|
|
>
|
|
Creating your workspace…
|
|
</div>
|
|
<div
|
|
className="mono"
|
|
style={{
|
|
marginTop: 6,
|
|
fontSize: 12.5,
|
|
color: "var(--accent)",
|
|
}}
|
|
>
|
|
Provisioning your server, repos & dashboard…
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</WizardBody>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<WizardTop
|
|
onBack={null}
|
|
onClose={onClose}
|
|
stepText="Name your workspace"
|
|
/>
|
|
<WizardBody>
|
|
<WizardQ
|
|
title="Name your workspace"
|
|
sub="This is your home base. You can rename it later."
|
|
/>
|
|
<Field label="Workspace name">
|
|
<input
|
|
type="text"
|
|
className="wiz-input"
|
|
placeholder="My Workspace"
|
|
value={name}
|
|
autoFocus
|
|
maxLength={48}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && trimmed) onSubmit(trimmed);
|
|
}}
|
|
/>
|
|
</Field>
|
|
{slug && (
|
|
<div
|
|
className="mono"
|
|
style={{ marginTop: 4, fontSize: 12.5, color: "var(--fg-mute)" }}
|
|
>
|
|
vibnai.com/<span style={{ color: "var(--accent)" }}>{slug}</span>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-wiz"
|
|
style={{ marginTop: 22, width: "100%" }}
|
|
disabled={!trimmed}
|
|
onClick={() => onSubmit(trimmed)}
|
|
>
|
|
Create 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>
|
|
</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}
|
|
className={emphasized ? "door-card active" : "door-card"}
|
|
>
|
|
<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="Solo or Team"
|
|
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>
|
|
</>
|
|
);
|
|
}
|