feat: support root-level _marketing and _onboarding directories (T12)
This commit is contained in:
685
vibn-frontend/_onboarding/page.tsx
Normal file
685
vibn-frontend/_onboarding/page.tsx
Normal file
@@ -0,0 +1,685 @@
|
||||
"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";
|
||||
|
||||
// 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
"noImplicitAny": false,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
"name": "next",
|
||||
},
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"@/marketing/*": ["./_marketing/*"],
|
||||
"@/onboarding/*": ["./_onboarding/*"],
|
||||
"@/*": ["./*"],
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
@@ -29,7 +31,7 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
"**/*.mts",
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user