Files
vibn-frontend/design-templates/VIBN (2)/onboarding-primitives.jsx

334 lines
11 KiB
JavaScript
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.
// 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.
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">
<LogoMark size={22} />
<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 ────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────
function WizardQ({ title, sub }) {
return (
<div className="wiz-q">
<h2>{title}</h2>
{sub && <p>{sub}</p>}
</div>
);
}
// ── Footer (back / hint / continue) ────────────────────────────────────────
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} <Arrow size={13} />
</button>
</div>
</div>
);
}
// ── Field wrappers (wizard variants) ───────────────────────────────────────
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) ──────────────────────────────────────────────
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) ─────────────────────────────────────
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 ─────────────────────────────────────────────────────────────────
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.
const LANE_LABELS = {
entrepreneur: "Solo entrepreneur",
owner: "Small business owner",
consultant: "Building for clients",
};
Object.assign(window, {
WizardTop, WizardBody, WizardQ, WizardFooter,
Field, ChipGroup, PresetGroup, Slider,
LANE_LABELS,
});