design: remove client-profile step from agency flow, streamlining it to Brief, Scope, and Handoff
This commit is contained in:
@@ -1,64 +1,16 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
WizardTop,
|
||||
WizardBody,
|
||||
WizardQ,
|
||||
WizardFooter,
|
||||
Label,
|
||||
LANE_LABELS,
|
||||
PresetGroup,
|
||||
Field,
|
||||
} from "./onboarding-primitives";
|
||||
// Consultant path — 4 steps for freelancers building for a client.
|
||||
|
||||
const CONS_TOTAL = 4;
|
||||
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
|
||||
|
||||
export function ConsClient({ clientName, industry, contact, onChange }) {
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="Who are you building for?"
|
||||
sub="Used to brand the preview and the handoff doc."
|
||||
/>
|
||||
<Field label="Client / company">
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="Rivera & Co Roofing"
|
||||
value={clientName}
|
||||
onChange={(e) => onChange({ clientName: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="What they do">
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="Residential roofing, mostly insurance jobs"
|
||||
value={industry}
|
||||
onChange={(e) => onChange({ industry: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Your point of contact"
|
||||
optional
|
||||
hint="So Vibn can address them in the handoff."
|
||||
>
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="Marisol Rivera, Owner"
|
||||
value={contact}
|
||||
onChange={(e) => onChange({ contact: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const CONS_TOTAL = 3;
|
||||
const CONS_STEP_NAMES = ["Brief", "Scope", "Handoff"];
|
||||
|
||||
const BRIEF_TEMPLATES = [
|
||||
{
|
||||
@@ -91,7 +43,14 @@ function ConsBrief({ brief, onChange }) {
|
||||
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" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{BRIEF_TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -108,7 +67,7 @@ function ConsBrief({ brief, onChange }) {
|
||||
<Field label="Brief">
|
||||
<textarea
|
||||
className="wiz-input"
|
||||
style={{ minHeight: 160 }}
|
||||
style={{ minHeight: 180, fontSize: 15 }}
|
||||
placeholder="The client wants…"
|
||||
value={brief}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -148,96 +107,99 @@ const SCOPE_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Ops",
|
||||
label: "Ops & Data",
|
||||
items: [
|
||||
"Dashboards & reports",
|
||||
"CSV import / export",
|
||||
"Email + SMS notifs",
|
||||
"Audit log",
|
||||
"CSV Import/Export",
|
||||
"Email & SMS alerts",
|
||||
"Analytics & chart",
|
||||
"PDF reporting",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function ConsScope({ scope, onChange }) {
|
||||
const toggle = (item) => {
|
||||
if (scope.includes(item)) onChange(scope.filter((x) => x !== item));
|
||||
else onChange([...scope, item]);
|
||||
const toggle = (item: string) => {
|
||||
const next = scope.includes(item)
|
||||
? scope.filter((s) => s !== item)
|
||||
: [...scope, item];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WizardQ
|
||||
title="What's in scope?"
|
||||
sub="Tick what you've signed up to deliver. The rest you can add later — billable."
|
||||
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",
|
||||
gap: 12,
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{SCOPE_GROUPS.map((g) => (
|
||||
<div
|
||||
key={g.label}
|
||||
style={{
|
||||
padding: "14px 14px 10px",
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
background: "oklch(0.18 0.009 60 / 0.5)",
|
||||
border: "1px solid var(--hairline)",
|
||||
background: "oklch(0.18 0.009 60 / 0.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.12em",
|
||||
fontSize: 11,
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: "var(--accent)",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--fg-mute)",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{g.label}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{g.items.map((it) => {
|
||||
const active = scope.includes(it);
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{g.items.map((item) => {
|
||||
const active = scope.includes(item);
|
||||
return (
|
||||
<button
|
||||
key={it}
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggle(it)}
|
||||
onClick={() => toggle(item)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
gap: 8,
|
||||
textAlign: "left",
|
||||
padding: "6px 4px",
|
||||
color: active ? "var(--fg)" : "var(--fg-dim)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13.5,
|
||||
fontWeight: active ? 500 : 400,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
|
||||
background: active ? "var(--accent)" : "transparent",
|
||||
color: "var(--accent-fg)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "var(--accent-fg)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
@@ -246,7 +208,7 @@ function ConsScope({ scope, onChange }) {
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{it}
|
||||
<span>{item}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -258,95 +220,45 @@ function ConsScope({ scope, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
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="And finally — delivery."
|
||||
sub="Where it lives, how you bill. Change later from settings."
|
||||
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}
|
||||
/>
|
||||
<Field
|
||||
label="Brand colors"
|
||||
optional
|
||||
hint="Hex, or just describe it. Paste a link, drop a screenshot — Vibn will figure it out."
|
||||
>
|
||||
<input
|
||||
className="wiz-input"
|
||||
placeholder="#1B4D3E and #F2E2C4 — matches their truck wraps"
|
||||
value={data.brand || ""}
|
||||
onChange={(e) => onChange({ brand: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Where should it live?">
|
||||
<PresetGroup
|
||||
options={[
|
||||
{
|
||||
id: "subdomain",
|
||||
label: "Vibn subdomain",
|
||||
desc: "client-name.vibn.app — fastest.",
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
label: "Their custom domain",
|
||||
desc: "We'll walk you through DNS.",
|
||||
},
|
||||
{
|
||||
id: "transfer",
|
||||
label: "Transfer ownership",
|
||||
desc: "They own it. You stay billable as editor.",
|
||||
},
|
||||
]}
|
||||
value={data.handoff}
|
||||
onChange={(v) => onChange({ handoff: v })}
|
||||
columns={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Your hourly rate"
|
||||
optional
|
||||
hint="Used on the handoff doc & invoice template."
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--fg-faint)",
|
||||
fontSize: 14.5,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
className="wiz-input"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="120"
|
||||
value={data.rate || ""}
|
||||
onChange={(e) => onChange({ rate: e.target.value })}
|
||||
style={{ paddingLeft: 26, paddingRight: 58 }}
|
||||
/>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--fg-faint)",
|
||||
fontSize: 11.5,
|
||||
letterSpacing: "0.04em",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
/ hour
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -372,26 +284,14 @@ export function ConsultantPath({
|
||||
|
||||
let body, canNext;
|
||||
if (step === 0) {
|
||||
body = (
|
||||
<ConsClient
|
||||
clientName={data.clientName || ""}
|
||||
industry={data.industry || ""}
|
||||
contact={data.contact || ""}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
);
|
||||
canNext =
|
||||
(data.clientName || "").trim().length >= 2 &&
|
||||
(data.industry || "").trim().length >= 3;
|
||||
} else if (step === 1) {
|
||||
body = (
|
||||
<ConsBrief
|
||||
brief={data.brief || ""}
|
||||
onChange={(v) => onUpdate({ brief: v })}
|
||||
/>
|
||||
);
|
||||
canNext = (data.brief || "").trim().length >= 30;
|
||||
} else if (step === 2) {
|
||||
canNext = (data.brief || "").trim().length >= 8;
|
||||
} else if (step === 1) {
|
||||
body = (
|
||||
<ConsScope
|
||||
scope={data.scope || []}
|
||||
@@ -404,6 +304,7 @@ export function ConsultantPath({
|
||||
canNext = !!data.handoff;
|
||||
}
|
||||
|
||||
// 4 total: fork(1) + 3 path steps
|
||||
return (
|
||||
<>
|
||||
<WizardTop
|
||||
@@ -412,14 +313,14 @@ export function ConsultantPath({
|
||||
lane={LANE_LABELS.consultant}
|
||||
stepText={CONS_STEP_NAMES[step]}
|
||||
current={step + 2}
|
||||
total={5}
|
||||
total={4}
|
||||
/>
|
||||
<WizardBody width={step === 2 ? "wide" : null}>
|
||||
<WizardBody width={step === 1 || step === 2 ? "wide" : null}>
|
||||
{body}
|
||||
<WizardFooter
|
||||
onNext={next}
|
||||
canNext={canNext}
|
||||
nextLabel={step === CONS_TOTAL - 1 ? "Spin up project →" : "Continue"}
|
||||
nextLabel={step === CONS_TOTAL - 1 ? "Build →" : "Continue"}
|
||||
hint={canNext ? "⌘↵" : null}
|
||||
/>
|
||||
</WizardBody>
|
||||
|
||||
Reference in New Issue
Block a user