494 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|