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

494 lines
14 KiB
TypeScript

"use client";
import React from "react";
import {
WizardTop,
WizardBody,
WizardQ,
WizardFooter,
Field,
} from "./onboarding-primitives";
import {
cityLabel,
extractTools,
searchCities,
} from "./onboarding-agency-mock";
import {
type AgencyOnboardingResult,
type AgencyProfile,
type CityRef,
} from "./onboarding-agency-types";
// Contractor-first onboarding — "Set up your AI agency".
// Vibn builds CUSTOM TOOLS for local businesses. We capture who the consultant
// is and what they love building, then drop them into their dashboard — where
// the local-business targeting recommendations live as an ongoing feature.
// Steps: identity → presence → expertise → (dashboard).
// Built to the Vibn design concept: dark wizard surface, coral accent, used sparingly.
// Fully interactive against mock data; swap the mock calls for the endpoints
// documented in onboarding-agency-types.ts.
const STEPS = ["identity", "presence", "expertise"] as const;
type Step = (typeof STEPS)[number];
export interface AgencyOnboardingProps {
/** Fired with the assembled result; wire to POST /api/agency then route to the dashboard. */
onComplete: (result: AgencyOnboardingResult) => void;
/** Save & exit. */
onExit: () => void;
/** Back to the front-door fork. */
onBack: () => void;
}
export function AgencyOnboarding({
onComplete,
onExit,
onBack,
}: AgencyOnboardingProps) {
const [stepIdx, setStepIdx] = React.useState(0);
const step: Step = STEPS[stepIdx];
const [profile, setProfile] = React.useState<AgencyProfile>({
name: "",
city: undefined,
});
const [expertise, setExpertise] = React.useState("");
const detectedTools = React.useMemo(
() => extractTools(expertise),
[expertise],
);
const goNext = () => setStepIdx((i) => Math.min(STEPS.length - 1, i + 1));
const goPrev = () => (stepIdx === 0 ? onBack() : setStepIdx((i) => i - 1));
const finish = () => {
onComplete({
profile,
expertise,
tools: detectedTools,
});
};
const stepLabel = (
{
identity: "Your agency",
presence: "Your presence",
expertise: "Ideal customer",
} as Record<Step, string>
)[step];
return (
<>
<WizardTop
onBack={goPrev}
onClose={onExit}
lane="Agency"
stepText={stepLabel}
current={stepIdx + 1}
total={STEPS.length}
/>
{step === "identity" && (
<IdentityStep profile={profile} onChange={setProfile} onNext={goNext} />
)}
{step === "presence" && (
<PresenceStep profile={profile} onChange={setProfile} onNext={goNext} />
)}
{step === "expertise" && (
<IdealCustomerStep
value={expertise}
onChange={setExpertise}
onNext={finish}
/>
)}
</>
);
}
// ── Step 2 · Identity ────────────────────────────────────────────────────────
function IdentityStep({
profile,
onChange,
onNext,
}: {
profile: AgencyProfile;
onChange: (p: AgencyProfile) => void;
onNext: () => void;
}) {
return (
<WizardBody>
<WizardQ
title="Name your agency."
sub="This is how you'll show up to clients. You can change it anytime."
/>
<Field label="Agency name">
<input
className="wiz-input"
placeholder="e.g. Riverside Studio"
value={profile.name}
autoFocus
onChange={(e) => onChange({ ...profile, name: e.target.value })}
/>
</Field>
<Field
label="Your city"
hint="Start typing — we'll find open territory near you."
>
<CityLookup
value={profile.city}
onChange={(c) => onChange({ ...profile, city: c })}
/>
</Field>
<WizardFooter
onNext={onNext}
canNext={profile.name.trim().length > 1 && !!profile.city}
nextLabel="Continue"
hint={
profile.name && profile.city ? "Press ⌘↵" : "Name + city to continue"
}
/>
</WizardBody>
);
}
// ── City lookup (typeahead) ──────────────────────────────────────────────────
// Hits Places API (New) Autocomplete via GET /api/agency/cities; falls back to
// the seed list only when that endpoint isn't reachable (offline dev).
export function CityLookup({
value,
onChange,
}: {
value?: CityRef;
onChange: (c: CityRef) => void;
}) {
const [query, setQuery] = React.useState(value ? cityLabel(value) : "");
const [open, setOpen] = React.useState(false);
const [results, setResults] = React.useState<CityRef[]>(() =>
searchCities(""),
);
const listboxId = React.useId();
React.useEffect(() => {
let cancelled = false;
const handle = setTimeout(async () => {
let next: CityRef[] | null = null;
try {
const res = await fetch(
`/api/agency/cities?q=${encodeURIComponent(query)}`,
);
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) next = data as CityRef[];
}
} catch {
/* offline / endpoint missing — use the seed fallback */
}
if (cancelled) return;
setResults(next && next.length ? next : searchCities(query));
}, 180);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [query]);
const select = (c: CityRef) => {
onChange(c);
setQuery(cityLabel(c));
setOpen(false);
};
return (
<div style={{ position: "relative" }}>
<input
className="wiz-input"
placeholder="Start typing a city…"
value={query}
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
autoComplete="off"
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
/>
{open && results.length > 0 && (
<div
id={listboxId}
role="listbox"
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 20,
background: "var(--bg-1)",
border: "1px solid var(--hairline-2)",
borderRadius: 10,
overflow: "hidden",
maxHeight: 260,
overflowY: "auto",
boxShadow: "0 12px 32px -12px rgba(0,0,0,0.55)",
}}
>
{results.map((c) => {
const active = value?.id === c.id;
return (
<button
key={c.id}
type="button"
role="option"
aria-selected={active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => select(c)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
textAlign: "left",
padding: "9px 12px",
border: "none",
borderBottom: "1px solid var(--hairline)",
background: active ? "var(--accent-soft)" : "transparent",
color: "var(--fg)",
cursor: "pointer",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: active ? "var(--accent)" : "var(--fg-mute)",
flexShrink: 0,
}}
aria-hidden="true"
>
<path d="M12 21s7-6.5 7-11a7 7 0 1 0-14 0c0 4.5 7 11 7 11Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
<span style={{ flex: 1, fontSize: 14 }}>
{c.name}, {c.region}
</span>
<span
className="mono"
style={{
fontSize: 10,
letterSpacing: "0.06em",
color: "var(--fg-faint)",
}}
>
{c.countryCode}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
// ── Step 3 · Online presence ─────────────────────────────────────────────────
function PresenceStep({
profile,
onChange,
onNext,
}: {
profile: AgencyProfile;
onChange: (p: AgencyProfile) => void;
onNext: () => void;
}) {
return (
<WizardBody>
<WizardQ
title="What does your agency have today?"
sub="This helps us understand your agency setup so we can tailor your starting dashboard."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<ToggleRow
label="I have a Website"
on={!!profile.hasWebsite}
onToggle={() =>
onChange({ ...profile, hasWebsite: !profile.hasWebsite })
}
/>
{profile.hasWebsite && (
<input
className="wiz-input"
placeholder="yoursite.com"
value={profile.websiteUrl ?? ""}
onChange={(e) =>
onChange({ ...profile, websiteUrl: e.target.value })
}
/>
)}
<ToggleRow
label="I have social media accounts"
on={!!profile.hasSocials}
onToggle={() =>
onChange({ ...profile, hasSocials: !profile.hasSocials })
}
/>
<ToggleRow
label="I have a blog"
on={!!profile.hasBlog}
onToggle={() => onChange({ ...profile, hasBlog: !profile.hasBlog })}
/>
<ToggleRow
label="I have a custom domain (mybusiness.com)"
on={!!profile.hasCustomDomain}
onToggle={() =>
onChange({ ...profile, hasCustomDomain: !profile.hasCustomDomain })
}
/>
<ToggleRow
label="I bill existing customers"
on={!!profile.hasExistingClients}
onToggle={() =>
onChange({
...profile,
hasExistingClients: !profile.hasExistingClients,
})
}
/>
</div>
<WizardFooter onNext={onNext} nextLabel="Continue" hint="Press ⌘↵" />
</WizardBody>
);
}
function ToggleRow({
label,
on,
onToggle,
}: {
label: string;
on: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={on}
onClick={onToggle}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "13px 15px",
borderRadius: 12,
textAlign: "left",
cursor: "pointer",
color: "var(--fg)",
border: `1px solid ${on ? "var(--accent)" : "var(--hairline)"}`,
background: on ? "var(--accent-soft)" : "var(--bg-1)",
transition: "border-color .15s, background .15s",
}}
>
<span style={{ flex: 1, fontSize: 14, fontWeight: 500 }}>{label}</span>
<span
aria-hidden="true"
style={{
width: 40,
height: 24,
flexShrink: 0,
borderRadius: 999,
background: on ? "var(--accent)" : "var(--bg-2)",
position: "relative",
transition: "background .15s",
}}
>
<span
style={{
position: "absolute",
top: 3,
left: on ? 19 : 3,
width: 18,
height: 18,
borderRadius: "50%",
background: on ? "var(--accent-fg)" : "var(--fg-mute)",
transition: "left .15s",
}}
/>
</span>
</button>
);
}
// ── Step 4 · Your ideal customer (final step → dashboard) ────────────────────
function IdealCustomerStep({
value,
onChange,
onNext,
}: {
value: string;
onChange: (s: string) => void;
onNext: () => void;
}) {
const ready = value.trim().length >= 8;
return (
<WizardBody>
<WizardQ
title="Is there a certain type of business or business problem you are passionate about?"
sub="Vibn will help you match with potential customers in your area, and drive awareness of your brand."
/>
<Field label="Your ideal customer">
<textarea
className="wiz-input"
rows={3}
autoFocus
placeholder="e.g. I want to help dentists automate patient booking, or help landscapers coordinate their scheduling and dispatch."
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ resize: "vertical", minHeight: 84 }}
/>
</Field>
<div
style={{ display: "flex", justifyContent: "flex-start", marginTop: 4 }}
>
<button
type="button"
onClick={() => {
onChange(
"I want to help any local business automate their workflows and reporting.",
);
setTimeout(onNext, 50); // slight pause so state propagates
}}
style={{
background: "none",
border: "none",
color: "var(--accent)",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
padding: "4px 0",
textDecoration: "underline",
opacity: 0.85,
transition: "opacity .15s",
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = "1")}
onMouseLeave={(e) => (e.currentTarget.style.opacity = "0.85")}
>
{"I'm not sure right now"}
</button>
</div>
<WizardFooter
onNext={onNext}
canNext={ready}
nextLabel="Open my dashboard →"
hint={ready ? "Press ⌘↵" : "Tell us a little more"}
/>
</WizardBody>
);
}