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

459 lines
12 KiB
TypeScript

import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from "react";
import {
WizardTop,
WizardBody,
WizardQ,
WizardFooter,
LANE_LABELS,
ChipGroup,
PresetGroup,
Field,
} from "./onboarding-primitives";
// Entrepreneur path — 4 steps. Each step is a focused question.
const ENTREP_TOTAL = 4;
const ENTREP_STEP_NAMES = ["Type", "Style", "Idea", "Look"];
const IDEA_PROMPTS = [
"A community for indie game devs to swap playtesters, with weekly demo nights",
"An AI tool that turns my handwritten recipe notes into a clean cookbook for my family",
"A waitlist + scheduler for my pottery studio — small classes, six people max",
"A subscription box service for cold-brew enthusiasts, with monthly tasting cards",
"A simple tool that turns my Strava data into framed art prints I can sell",
];
export function EntrepIdea({ value, onChange }) {
const [phIdx, setPhIdx] = React.useState(0);
const [phChars, setPhChars] = React.useState(0);
const [deleting, setDeleting] = React.useState(false);
React.useEffect(() => {
if (value.length > 0) return undefined;
const full = IDEA_PROMPTS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1500);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else {
setDeleting(false);
setPhIdx((phIdx + 1) % IDEA_PROMPTS.length);
}
}
}, speed);
return () => clearTimeout(t);
}, [value, phIdx, phChars, deleting]);
return (
<>
<WizardQ
title="What are you building?"
sub="Don't worry if it's not crisp yet — just dump your thoughts. Talk like you would to a friend."
/>
<div style={{ position: "relative" }}>
<textarea
className="wiz-input"
style={{ minHeight: 200, fontSize: 15 }}
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus
aria-label="Describe your idea"
/>
{value.length === 0 && (
<div
style={{
position: "absolute",
top: 12,
left: 14,
right: 14,
pointerEvents: "none",
color: "var(--fg-faint)",
font: "14.5px/1.5 var(--font-sans)",
}}
>
{IDEA_PROMPTS[phIdx].slice(0, phChars)}
<span
style={{
display: "inline-block",
width: 7,
height: 14,
verticalAlign: "-2px",
background: "var(--accent)",
marginLeft: 1,
animation: "blink 1s steps(2) infinite",
boxShadow: "0 0 10px var(--accent-glow)",
}}
/>
</div>
)}
</div>
<div
className="mono"
style={{
fontSize: 11,
color: "var(--fg-faint)",
letterSpacing: "0.06em",
marginTop: -16,
}}
>
{value.length} chars · be specific where it matters
</div>
</>
);
}
const ARCHETYPES = [
{
id: "saas",
label: "Web App / SaaS",
desc: "Dashboards, tools, interactive portals",
},
{
id: "marketplace",
label: "Marketplace",
desc: "Directories, bookings, listings",
},
{
id: "marketing",
label: "Marketing Site",
desc: "Portfolios, lead capture, landing pages",
},
{
id: "ecommerce",
label: "Online Store",
desc: "Carts, checkouts, selling physical/digital goods",
},
{
id: "mobile",
label: "Mobile App",
desc: "iOS and Android mobile applications",
},
{
id: "blog",
label: "Blog / Publication",
desc: "Newsletters, articles, content hubs",
},
{
id: "not_sure",
label: "I'm not sure",
desc: "Let Vibn help you decide based on your description",
fullWidth: true,
},
];
function EntrepType({ value, onChange }) {
return (
<>
<WizardQ
title="What kind of product is it?"
sub="Helps Vibn set up the right database, integrations, and starting code."
/>
<PresetGroup
options={ARCHETYPES.map((a) => ({
id: a.id,
label: a.label,
desc: a.desc,
icon: undefined,
fullWidth: a.fullWidth,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const SAAS_STYLES = [
{
id: "sidebar",
label: "Vertical Sidebar",
desc: "Left-side collapsible menu, data-dense. Ideal for CRM/dashboards.",
},
{
id: "topbar",
label: "Top Horizontal + ⌘K",
desc: "Spacious top navigation with global command search bar.",
},
{
id: "rail",
label: "Slim Icon Rail",
desc: "Minimalist vertical narrow icon bar, maximizes workspace area.",
},
];
const MARKETPLACE_STYLES = [
{
id: "flux",
label: "Dark Glass / Flux",
desc: "Modern dark-glass panels with glowing fuchsia aurora backdrops.",
},
{
id: "minimal",
label: "Classic Minimal",
desc: "Warm parchment neutrals, high-contrast typography and clean grids.",
},
];
const GENERAL_STYLES = [
{
id: "bento",
label: "Dark Bento",
desc: "Modern dark UI, bento-box card clusters.",
},
{
id: "swiss",
label: "Editorial Swiss",
desc: "Type-led, gridded, lots of white space — clean and academic.",
},
{
id: "brutalist",
label: "Neo-Brutalist",
desc: "Bold offsets, thick hand-drawn borders, highly tactile and organic.",
},
];
function EntrepStyle({ productType, value, onChange }) {
// Dynamically tailor the styles array based on what they picked on Page 2
const isSaas = productType === "saas";
const isMarketplace = productType === "marketplace";
const styles = isSaas
? SAAS_STYLES
: isMarketplace
? MARKETPLACE_STYLES
: GENERAL_STYLES;
return (
<>
<WizardQ
title="Choose a starting design style"
sub={
isSaas
? "Select the navigation layout that fits your app's density."
: isMarketplace
? "Select the design aesthetic for your marketplace templates."
: "Select the design layout for your custom pages."
}
/>
<PresetGroup
options={styles.map((s) => ({
id: s.id,
label: s.label,
desc: s.desc,
icon: undefined,
}))}
value={value}
onChange={onChange}
columns={styles.length === 3 ? 1 : 2}
/>
</>
);
}
const VIBES = [
{
id: "warm",
name: "Warm coral",
swatch: "linear-gradient(135deg, #E27855, #B33B2A)",
desc: "Confident, hand-built, warm.",
},
{
id: "ink",
name: "Ink & paper",
swatch: "linear-gradient(135deg, #1d1d1d, #4a4a4a)",
desc: "Editorial, serif, quiet.",
},
{
id: "sage",
name: "Sage matte",
swatch: "linear-gradient(135deg, #7BA890, #3F6B57)",
desc: "Calm, modern, slightly herbal.",
},
{
id: "neon",
name: "Neon arcade",
swatch: "linear-gradient(135deg, #5B6CFF, #FF3DDB)",
desc: "Loud, fun, late-night.",
},
{
id: "cream",
name: "Cream linen",
swatch: "linear-gradient(135deg, #F2E7D5, #C9A977)",
desc: "Cozy and beige.",
},
{
id: "later",
name: "Decide later",
swatch:
"repeating-linear-gradient(45deg, oklch(0.30 0.010 60), oklch(0.30 0.010 60) 6px, oklch(0.22 0.010 60) 6px, oklch(0.22 0.010 60) 12px)",
desc: "Vibn picks one that fits.",
},
];
function EntrepVibe({ value, onChange }) {
return (
<>
<WizardQ
title="Pick a starting vibe."
sub="Every color and font is a tweak away once the site is live."
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
}}
>
{VIBES.map((v) => {
const active = value === v.id;
return (
<button
key={v.id}
type="button"
onClick={() => onChange(v.id)}
style={{
padding: "10px 10px 10px",
borderRadius: 11,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active
? "oklch(0.20 0.04 35 / 0.4)"
: "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active
? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)"
: "none",
textAlign: "left",
color: "var(--fg)",
display: "flex",
flexDirection: "column",
gap: 8,
transition: "border-color .15s, background .15s",
}}
>
<span
style={{
height: 52,
borderRadius: 7,
background: v.swatch,
border: "1px solid oklch(1 0 0 / 0.08)",
boxShadow: "inset 0 1px 0 oklch(1 0 0 / 0.18)",
}}
/>
<span
style={{
fontSize: 13,
fontWeight: 500,
letterSpacing: "-0.005em",
}}
>
{v.name}
</span>
<span
style={{
fontSize: 11.5,
color: "var(--fg-mute)",
lineHeight: 1.4,
}}
>
{v.desc}
</span>
</button>
);
})}
</div>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
export function EntrepreneurPath({
data,
onUpdate,
onBack,
onClose,
onComplete,
onJumpToStep,
step,
}) {
const next = () => {
if (step < ENTREP_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body,
canNext,
onSkip = null;
if (step === 0) {
body = (
<EntrepType
value={data.productType || ""}
onChange={(v) => onUpdate({ productType: v })}
/>
);
canNext = !!data.productType;
} else if (step === 1) {
body = (
<EntrepStyle
productType={data.productType}
value={data.designStyle || ""}
onChange={(v) => onUpdate({ designStyle: v })}
/>
);
canNext = !!data.designStyle;
} else if (step === 2) {
body = (
<EntrepIdea
value={data.idea || ""}
onChange={(v) => onUpdate({ idea: v })}
/>
);
canNext = (data.idea || "").trim().length >= 8;
} else {
body = (
<EntrepVibe value={data.vibe} onChange={(v) => onUpdate({ vibe: v })} />
);
canNext = !!data.vibe;
onSkip = () => {
onUpdate({ vibe: "later" });
next();
};
}
// 5 total: fork(1) + 4 path steps
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.entrepreneur}
stepText={ENTREP_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 || step === 3 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === ENTREP_TOTAL - 1 ? "Build →" : "Continue"}
hint={canNext ? "⌘↵" : null}
onSkip={onSkip}
skipLabel={step === 0 ? "I'm not sure" : "Pick for me"}
/>
</WizardBody>
</>
);
}