Files
vibn-agent-runner/vibn-frontend/_onboarding/onboarding-consultant.tsx

330 lines
8.7 KiB
TypeScript

import React from "react";
import {
WizardTop,
WizardBody,
WizardQ,
WizardFooter,
LANE_LABELS,
PresetGroup,
Field,
} from "./onboarding-primitives";
const CONS_TOTAL = 3;
const CONS_STEP_NAMES = ["Brief", "Scope", "Handoff"];
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",
marginBottom: 12,
}}
>
{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: 180, fontSize: 15 }}
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 & Data",
items: [
"CSV Import/Export",
"Email & SMS alerts",
"Analytics & chart",
"PDF reporting",
],
},
];
function ConsScope({ scope, onChange }) {
const toggle = (item: string) => {
const next = scope.includes(item)
? scope.filter((s) => s !== item)
: [...scope, item];
onChange(next);
};
return (
<>
<WizardQ
title="What modules do they need?"
sub="Vibn scaffold-seeds only what is checked here. Keep it lean to launch fast."
/>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 20,
}}
>
{SCOPE_GROUPS.map((g) => (
<div
key={g.label}
style={{
padding: 14,
borderRadius: 12,
background: "oklch(0.18 0.009 60 / 0.5)",
border: "1px solid var(--hairline)",
}}
>
<div
style={{
fontSize: 11,
fontFamily: "var(--font-mono)",
color: "var(--accent)",
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: 10,
}}
>
{g.label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{g.items.map((item) => {
const active = scope.includes(item);
return (
<button
key={item}
type="button"
onClick={() => toggle(item)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
textAlign: "left",
color: active ? "var(--fg)" : "var(--fg-dim)",
fontSize: 13.5,
fontWeight: active ? 500 : 400,
cursor: "pointer",
}}
>
<span
style={{
width: 14,
height: 14,
borderRadius: 3,
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
display: "grid",
placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
}}
>
{active && (
<svg
width="8"
height="8"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m3 8.5 3.2 3.2L13 5" />
</svg>
)}
</span>
<span>{item}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</>
);
}
const DEPLOYS = [
{
id: "subdomain",
icon: "🌐",
label: "A free Vibn subdomain",
desc: "Deploy immediately to client-name.vibn.app. Fully secured with SSL.",
},
{
id: "custom",
icon: "🔌",
label: "Deploy to a custom domain",
desc: "CNAME to our proxy. SSL certificates provision automatically on DNS point.",
},
{
id: "handover",
icon: "📦",
label: "Handover Gitea repo",
desc: "We push to their Gitea/GitHub. They own their container fleet outright.",
},
];
function ConsHandoff({ data, onChange }) {
return (
<>
<WizardQ
title="How will you deliver it?"
sub="Your contract dictates the target. Vibn configures SSL and DNS routing automatically."
/>
<PresetGroup
options={DEPLOYS.map((d) => ({
id: d.id,
label: d.label,
desc: d.desc,
icon: <span style={{ fontSize: 14 }}>{d.icon}</span>,
}))}
value={data.handoff}
onChange={(v) => onChange({ handoff: v })}
columns={1}
/>
</>
);
}
// ── 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 = (
<ConsBrief
brief={data.brief || ""}
onChange={(v) => onUpdate({ brief: v })}
/>
);
canNext = (data.brief || "").trim().length >= 8;
} else if (step === 1) {
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;
}
// 4 total: fork(1) + 3 path steps
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.consultant}
stepText={CONS_STEP_NAMES[step]}
current={step + 2}
total={4}
/>
<WizardBody width={step === 1 || step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === CONS_TOTAL - 1 ? "Build →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}