This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/app/(onboarding)/onboarding/onboarding-primitives.tsx

460 lines
13 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)
}) {
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 }) {
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 }) {
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",
}) {
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 }) {
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 }) {
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 }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 8,
width: "100%",
}}
>
{options.map((opt) => {
const active = value === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 10,
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",
alignItems: "flex-start",
gap: 12,
}}
>
{opt.icon && (
<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,
}}
>
<span
style={{
fontSize: 14,
fontWeight: 500,
letterSpacing: "-0.005em",
}}
>
{opt.label}
</span>
{opt.desc && (
<span
style={{
fontSize: 12.5,
color: "var(--fg-mute)",
lineHeight: 1.45,
}}
>
{opt.desc}
</span>
)}
</span>
{active && (
<span
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: "var(--accent)",
display: "grid",
placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
marginTop: 6,
}}
>
<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>
)}
</button>
);
})}
</div>
);
}
// ── Slider ─────────────────────────────────────────────────────────────────
export function Slider({ min, max, step = 1, value, onChange, format }) {
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",
};