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

592 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from "react";
// Shared building blocks for the onboarding flow.
// All <style> belongs in onboarding.css; this file is JSX only.
// ── Wizard top bar ─────────────────────────────────────────────────────────
// Sticky, thin. Holds: back arrow · vibn mark · centered step label · close.
// A 2px progress bar runs along its bottom edge.
export function WizardTop({
onBack,
onClose,
lane, // "Solo / quiet entrepreneur" etc.
stepText, // "Idea" or "Pick your lane"
current,
total, // 1-indexed
progress, // 0..1 (optional override)
}: {
onBack?: (() => void) | null;
onClose?: () => void;
lane?: React.ReactNode;
stepText?: React.ReactNode;
current?: number;
total?: number;
progress?: number;
}) {
const pct =
typeof progress === "number"
? Math.max(0, Math.min(1, progress))
: typeof current === "number" && typeof total === "number"
? Math.max(0, Math.min(1, current / total))
: 0;
return (
<header className="wiz-top">
<div className="wiz-top-row">
<button
type="button"
className="wiz-iconbtn"
onClick={onBack}
disabled={!onBack}
aria-label="Back"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 8H3M7 4 3 8l4 4" />
</svg>
</button>
<a href="index.html" className="wiz-logo" aria-label="vibn — home">
<div
style={{
width: 22,
height: 22,
borderRadius: 6,
overflow: "hidden",
display: "inline-block",
}}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
<span>vibn</span>
</a>
<div className="wiz-step">
{lane && <span className="lane">{lane}</span>}
{lane && stepText && <span className="dot" />}
{stepText && (
<span>
{typeof current === "number" && typeof total === "number" && (
<>
<b>{current}</b>{" "}
<span style={{ opacity: 0.6 }}>/ {total}</span>
{" · "}
</>
)}
{stepText}
</span>
)}
</div>
<button
type="button"
className="wiz-iconbtn"
onClick={onClose}
aria-label="Save & exit"
title="Save & exit"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m4 4 8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="wiz-progress">
<div className="wiz-progress-fill" style={{ width: `${pct * 100}%` }} />
</div>
</header>
);
}
// ── Wizard body wrapper ────────────────────────────────────────────────────
export function WizardBody({
children,
width,
}: {
children?: React.ReactNode;
width?: "wide" | "xwide";
}) {
const cls =
"wiz-card" +
(width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
return (
<main className="wiz-body">
<div className={cls}>{children}</div>
</main>
);
}
// ── Question heading ───────────────────────────────────────────────────────
export function WizardQ({
title,
sub,
}: {
title?: React.ReactNode;
sub?: React.ReactNode;
}) {
return (
<div className="wiz-q">
<h2>{title}</h2>
{sub && <p>{sub}</p>}
</div>
);
}
// ── Footer (back / hint / continue) ────────────────────────────────────────
export function WizardFooter({
onBack,
onNext,
canNext = true,
nextLabel = "Continue",
hint,
onSkip,
skipLabel = "Skip",
}: {
onBack?: () => void;
onNext?: () => void;
canNext?: boolean;
nextLabel?: React.ReactNode;
hint?: React.ReactNode;
onSkip?: () => void;
skipLabel?: React.ReactNode;
}) {
return (
<div className="wiz-foot">
<div className="wiz-foot-left">
{onSkip && (
<button type="button" className="wiz-skip" onClick={onSkip}>
{skipLabel}
</button>
)}
</div>
<div className="wiz-foot-right">
{hint && <span className="wiz-hint">{hint}</span>}
<button
type="button"
className="btn btn-primary btn-wiz"
disabled={!canNext}
onClick={() => canNext && onNext && onNext()}
>
{nextLabel}{" "}
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
</button>
</div>
</div>
);
}
// ── Field wrappers (wizard variants) ───────────────────────────────────────
export function Field({
label,
hint,
children,
optional,
}: {
label?: React.ReactNode;
hint?: React.ReactNode;
children?: React.ReactNode;
optional?: boolean;
}) {
return (
<label className="wiz-field">
{label && (
<span className="wiz-field-label">
{label}
{optional && (
<span
style={{
color: "var(--fg-faint)",
fontWeight: 400,
marginLeft: 8,
fontSize: 12,
}}
>
optional
</span>
)}
</span>
)}
{children}
{hint && <span className="wiz-field-hint">{hint}</span>}
</label>
);
}
// ── Chip group (multi-select) ──────────────────────────────────────────────
export function ChipGroup({
options,
values,
onChange,
allowOther = false,
}: {
options: string[];
values: string[];
onChange?: (v: string[]) => void;
allowOther?: boolean;
}) {
const [other, setOther] = React.useState("");
const customs = (values || []).filter((v) => !options.includes(v));
const toggle = (v) => {
if (!onChange) return;
if (values.includes(v)) onChange(values.filter((x) => x !== v));
else onChange([...values, v]);
};
return (
<div>
<div className="chips">
{options.map((opt) => (
<button
type="button"
key={opt}
className={"chip" + (values.includes(opt) ? " active" : "")}
onClick={() => toggle(opt)}
>
{opt}
</button>
))}
{customs.map((c) => (
<button
type="button"
key={c}
className="chip active"
onClick={() => toggle(c)}
title="Click to remove"
>
{c} <span style={{ marginLeft: 4, opacity: 0.6 }}>×</span>
</button>
))}
</div>
{allowOther && (
<form
onSubmit={(e) => {
e.preventDefault();
const v = other.trim();
if (v && !values.includes(v)) onChange([...values, v]);
setOther("");
}}
style={{ marginTop: 10, display: "flex", gap: 8 }}
>
<input
type="text"
className="wiz-input"
placeholder="Add your own…"
value={other}
onChange={(e) => setOther(e.target.value)}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-ghost"
style={{
height: 42,
padding: "0 14px",
fontSize: 13,
borderRadius: 10,
}}
disabled={!other.trim()}
>
Add
</button>
</form>
)}
</div>
);
}
// ── Preset group (single-select cards) ─────────────────────────────────────
export function PresetGroup({
options,
value,
onChange,
columns = 1,
minimal = false,
}: {
options: Array<{
id: string;
label?: React.ReactNode;
desc?: React.ReactNode;
icon?: React.ReactNode;
fullWidth?: boolean;
illustration?: React.ReactNode;
}>;
value?: string;
onChange?: (id: string) => void;
columns?: number;
minimal?: boolean;
}) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 8,
width: "100%",
}}
>
{options.map((opt) => {
const active = value === opt.id;
const hasIllustration = !!opt.illustration;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
textAlign: "left",
padding: hasIllustration ? "0 0 14px" : "12px 14px",
borderRadius: 14,
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",
transition: "border-color .15s, background .15s",
color: "var(--fg)",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
gridColumn: opt.fullWidth ? "1 / -1" : "auto",
overflow: "hidden",
}}
>
{hasIllustration && (
<div
style={{
width: "100%",
height: 300,
background: "oklch(0.14 0.008 60 / 0.7)",
borderBottom: "1px solid var(--hairline)",
display: "grid",
placeItems: "center",
overflow: "hidden",
position: "relative",
}}
>
{opt.illustration}
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: minimal ? "center" : "flex-start",
gap: 12,
padding: hasIllustration ? "14px 14px 0" : "0",
width: "100%",
flex: 1,
}}
>
{opt.icon && !minimal && (
<span
style={{
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 8,
background: active
? "oklch(0.74 0.175 35 / 0.18)"
: "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid",
placeItems: "center",
fontSize: 14,
marginTop: 1,
}}
>
{opt.icon}
</span>
)}
<span
style={{
display: "flex",
flexDirection: "column",
gap: 2,
flex: 1,
minWidth: 0,
textAlign: minimal ? "center" : "left",
}}
>
<span
style={{
fontSize: 13.5,
fontWeight: 500,
letterSpacing: "-0.005em",
color: active ? "var(--fg)" : "var(--fg-dim)",
}}
>
{opt.label}
</span>
{opt.desc && !minimal && (
<span
style={{
fontSize: 12.5,
color: "var(--fg-mute)",
lineHeight: 1.45,
}}
>
{opt.desc}
</span>
)}
</span>
{!minimal && (
<span
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: active ? "var(--accent)" : "transparent",
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
display: "grid",
placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
marginTop: 2,
transition: "border-color .15s, background .15s",
}}
>
{active && (
<svg
width="9"
height="9"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m3 8.5 3.2 3.2L13 5" />
</svg>
)}
</span>
)}
</div>
</button>
);
})}
</div>
);
}
// ── Slider ─────────────────────────────────────────────────────────────────
export function Slider({
min,
max,
step = 1,
value,
onChange,
format,
}: {
min: number;
max: number;
step?: number;
value: number;
onChange?: (n: number) => void;
format?: (n: number) => React.ReactNode;
}) {
return (
<div style={{ width: "100%" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<span
className="mono"
style={{
fontSize: 11,
color: "var(--fg-faint)",
letterSpacing: "0.04em",
}}
>
{format ? format(min) : min}
</span>
<span
className="mono"
style={{
fontSize: 18,
color: "var(--fg)",
letterSpacing: "-0.01em",
fontWeight: 500,
}}
>
{format ? format(value) : value}
</span>
<span
className="mono"
style={{
fontSize: 11,
color: "var(--fg-faint)",
letterSpacing: "0.04em",
}}
>
{format ? format(max) : max}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ width: "100%", marginTop: 6, accentColor: "var(--accent)" }}
/>
</div>
);
}
// Lane labels — used by WizardTop and elsewhere.
export const LANE_LABELS = {
entrepreneur: "Solo entrepreneur",
owner: "Small business owner",
consultant: "Building for clients",
};