feat: support root-level _marketing and _onboarding directories (T12)
This commit is contained in:
493
vibn-frontend/_onboarding/onboarding-agency.tsx
Normal file
493
vibn-frontend/_onboarding/onboarding-agency.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user