330 lines
8.7 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|