592 lines
16 KiB
TypeScript
592 lines
16 KiB
TypeScript
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",
|
||
};
|