Files
vibn-frontend/_onboarding/page.tsx

693 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;
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 = (
<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}
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>
</>
);
}