feat: implement user limits and onboarding features

- Added user limits management with daily can and spend limits.
- Integrated onboarding flow to guide users through setting limits.
- Enhanced greeting messages to reflect user limits and violations.
- Updated CSS for new limit-related components and improved UI consistency.
- Refactored coach session to utilize user limits in interactions.
This commit is contained in:
Ned Halksworth
2026-05-23 21:17:36 +01:00
parent 2be7609172
commit 0db81bddd5
9 changed files with 1683 additions and 86 deletions
+104
View File
@@ -0,0 +1,104 @@
import { Settings2 } from "lucide-react";
import type { LimitCheckResult, UserLimits } from "../types";
import { currency } from "../lib/metrics";
import { formatStopTimeLabel, hasAnyLimit, limitProgress } from "../lib/userLimits";
type DailyLimitsCardProps = {
limits: UserLimits;
check: LimitCheckResult;
onOpenSettings: () => void;
};
export function DailyLimitsCard({ limits, check, onOpenSettings }: DailyLimitsCardProps) {
if (!hasAnyLimit(limits)) {
return (
<section className="limits-card glass-panel p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-400">
Set how many cans you want per day, when to stop, and a spend cap. Limits are optional and stored on your
account.
</p>
</div>
<button className="secondary-button shrink-0" type="button" onClick={onOpenSettings}>
<Settings2 size={17} aria-hidden="true" />
Set limits
</button>
</div>
</section>
);
}
const canOver = check.violations.includes("cans");
const spendOver = check.violations.includes("spend");
const stopActive = limits.stopTime && check.pastStopTime;
return (
<section className="limits-card glass-panel p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-cyan-100">Daily limits</p>
<button className="list-button !min-h-9 !px-3 !py-1.5 text-xs" type="button" onClick={onOpenSettings}>
<Settings2 size={14} aria-hidden="true" />
Edit
</button>
</div>
<div className="grid gap-4 md:grid-cols-3">
{limits.dailyCanLimit != null ? (
<LimitRow
label="Cans today"
value={`${check.todayCans.toFixed(1)} / ${limits.dailyCanLimit}`}
progress={limitProgress(check.todayCans, limits.dailyCanLimit)}
state={canOver ? "over" : check.todayCans >= limits.dailyCanLimit * 0.75 ? "warn" : "ok"}
/>
) : null}
{limits.dailySpendLimit != null ? (
<LimitRow
label="Spend today"
value={`${currency.format(check.todaySpend)} / ${currency.format(limits.dailySpendLimit)}`}
progress={limitProgress(check.todaySpend, limits.dailySpendLimit)}
state={spendOver ? "over" : check.todaySpend >= limits.dailySpendLimit * 0.75 ? "warn" : "ok"}
/>
) : null}
{limits.stopTime ? (
<div className={`limit-row limit-row--${stopActive ? "over" : "ok"}`}>
<div className="limit-row-head">
<span>Stop by</span>
<strong>{formatStopTimeLabel(limits.stopTime)}</strong>
</div>
<p className="limit-row-value">
{stopActive ? "Past your stop time" : "Still within your window"}
</p>
</div>
) : null}
</div>
</section>
);
}
function LimitRow({
label,
value,
progress,
state,
}: {
label: string;
value: string;
progress: number;
state: "ok" | "warn" | "over";
}) {
return (
<div className={`limit-row limit-row--${state}`}>
<div className="limit-row-head">
<span>{label}</span>
<strong>{value}</strong>
</div>
<div className="limit-progress" aria-hidden="true">
<div className="limit-progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { Loader2, Target } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import type { LimitCheckResult, UserLimits } from "../types";
import { currency } from "../lib/metrics";
type LimitsSettingsFormProps = {
limits: UserLimits;
check: LimitCheckResult;
saving: boolean;
onSave: (limits: UserLimits) => void;
};
export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSettingsFormProps) {
const [canInput, setCanInput] = useState(limits.dailyCanLimit?.toString() ?? "");
const [spendInput, setSpendInput] = useState(limits.dailySpendLimit?.toString() ?? "");
const [stopInput, setStopInput] = useState(limits.stopTime ?? "");
useEffect(() => {
setCanInput(limits.dailyCanLimit?.toString() ?? "");
setSpendInput(limits.dailySpendLimit?.toString() ?? "");
setStopInput(limits.stopTime ?? "");
}, [limits.dailyCanLimit, limits.dailySpendLimit, limits.stopTime]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const next: UserLimits = {};
const canTrim = canInput.trim();
if (canTrim) {
const parsed = Math.max(0.25, Number(canTrim) || 0);
if (parsed > 0) next.dailyCanLimit = parsed;
}
const spendTrim = spendInput.trim();
if (spendTrim) {
const parsed = Math.max(0, Number(spendTrim) || 0);
next.dailySpendLimit = parsed;
}
if (stopInput.trim()) {
next.stopTime = stopInput;
}
onSave(next);
}
const previewParts: string[] = [];
if (limits.dailyCanLimit != null) {
previewParts.push(`${check.todayCans.toFixed(1)}/${limits.dailyCanLimit} cans today`);
}
if (limits.dailySpendLimit != null) {
previewParts.push(`${currency.format(check.todaySpend)} of ${currency.format(limits.dailySpendLimit)} spent today`);
}
return (
<form className="grid gap-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium text-slate-300">Cans per day</span>
<input
className="field-input"
type="number"
min={0.25}
step={0.25}
placeholder="e.g. 3"
value={canInput}
onChange={(event) => setCanInput(event.target.value)}
/>
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium text-slate-300">Spend per day (£)</span>
<input
className="field-input"
type="number"
min={0}
step={0.01}
placeholder="e.g. 5.00"
value={spendInput}
onChange={(event) => setSpendInput(event.target.value)}
/>
<span className="text-xs text-slate-500">Based on price per can in your log.</span>
</label>
</div>
<label className="grid gap-2 text-sm sm:max-w-xs">
<span className="font-medium text-slate-300">Stop drinking by</span>
<input
className="field-input"
type="time"
value={stopInput}
onChange={(event) => setStopInput(event.target.value)}
/>
<span className="text-xs text-slate-500">Europe/London (BST/GMT). Leave empty to remove.</span>
</label>
{previewParts.length ? (
<p className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-slate-300">
Today so far: {previewParts.join(" · ")}
</p>
) : null}
<button className="primary-button w-fit" type="submit" disabled={saving}>
{saving ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Target size={17} aria-hidden="true" />}
Save limits
</button>
</form>
);
}
+494
View File
@@ -0,0 +1,494 @@
import { useMemo, useState } from "react";
import { ArrowRight, Check, ChevronLeft } from "lucide-react";
import { APP_THEMES, THEME_CATEGORIES, type ThemeCategory } from "../data/themes";
import { currency } from "../lib/metrics";
import type { UserLimits } from "../types";
type OnboardingScreenProps = {
onSave: (limits: UserLimits, themeId: string) => Promise<void>;
onClose: () => void;
activeThemeId: string;
onThemeChange: (themeId: string) => void;
userName?: string;
};
const STEP_COUNT = 6;
const curfewOptions: Array<{ id: string; label: string; hint: string }> = [
{ id: "16:00", label: "4:00 PM", hint: "Early cut-off" },
{ id: "18:00", label: "6:00 PM", hint: "Balanced default" },
{ id: "20:00", label: "8:00 PM", hint: "Late schedule" },
{ id: "none", label: "No curfew", hint: "Only track intake" },
];
export function OnboardingScreen({
onSave,
onClose,
activeThemeId,
onThemeChange,
userName,
}: OnboardingScreenProps) {
const [step, setStep] = useState(1);
const [dailyCanLimit, setDailyCanLimit] = useState<number | "none">(2);
const [dailySpendLimit, setDailySpendLimit] = useState<number | "none">(3.5);
const [stopTime, setStopTime] = useState<string | "none">("18:00");
const [saving, setSaving] = useState(false);
const [activeCategory, setActiveCategory] = useState<ThemeCategory>("flavour");
const visibleThemes = useMemo(() => {
return APP_THEMES.filter((theme) => theme.category === activeCategory);
}, [activeCategory]);
const activeTheme = useMemo(() => {
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
}, [activeThemeId]);
const progress = `${(step / STEP_COUNT) * 100}%`;
async function handleFinish() {
setSaving(true);
try {
const limits: UserLimits = {};
if (dailyCanLimit !== "none") limits.dailyCanLimit = dailyCanLimit;
if (dailySpendLimit !== "none") limits.dailySpendLimit = dailySpendLimit;
if (stopTime !== "none") limits.stopTime = stopTime;
await onSave(limits, activeThemeId);
onClose();
} catch (err) {
console.error("Failed to save onboarding preferences", err);
} finally {
setSaving(false);
}
}
function incrementCans() {
if (dailyCanLimit === "none") {
setDailyCanLimit(1);
return;
}
if (dailyCanLimit < 10) setDailyCanLimit(Number((dailyCanLimit + 0.5).toFixed(1)));
}
function decrementCans() {
if (dailyCanLimit === "none") return;
if (dailyCanLimit <= 0.5) {
setDailyCanLimit("none");
return;
}
setDailyCanLimit(Number((dailyCanLimit - 0.5).toFixed(1)));
}
function incrementSpend() {
if (dailySpendLimit === "none") {
setDailySpendLimit(1);
return;
}
if (dailySpendLimit < 30) setDailySpendLimit(Number((dailySpendLimit + 0.5).toFixed(2)));
}
function decrementSpend() {
if (dailySpendLimit === "none") return;
if (dailySpendLimit <= 0.5) {
setDailySpendLimit("none");
return;
}
setDailySpendLimit(Number((dailySpendLimit - 0.5).toFixed(2)));
}
function goNext() {
setStep((current) => Math.min(current + 1, STEP_COUNT));
}
function goBack() {
setStep((current) => Math.max(current - 1, 1));
}
return (
<div
className="fixed inset-0 z-[100] flex min-h-screen flex-col overflow-y-auto px-5 py-6 sm:px-8"
style={{
background: "var(--bg)",
color: "var(--text)",
fontFamily: "inherit",
}}
>
<div
className="pointer-events-none absolute inset-0 opacity-60"
style={{
background:
"radial-gradient(circle at 76% 20%, color-mix(in srgb, var(--primary-container) 62%, transparent) 0 22%, transparent 44%), radial-gradient(circle at 12% 84%, color-mix(in srgb, var(--tertiary-container) 48%, transparent) 0 18%, transparent 42%)",
}}
/>
<header className="relative z-10 mx-auto flex w-full max-w-3xl items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-3 h-1 overflow-hidden rounded-full bg-[var(--surface-container-high)]">
<div className="h-full rounded-full bg-[var(--primary)] transition-all duration-500" style={{ width: progress }} />
</div>
<p className="text-xs font-normal uppercase tracking-[0.18em] text-[var(--muted)]">
Question {step} of {STEP_COUNT}
</p>
</div>
<p className="hidden text-xs font-normal text-[var(--muted)] sm:block">Red Bull Intake Tracker</p>
</header>
<main className="relative z-10 mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center py-10 sm:py-16">
{step === 1 && (
<section className="grid gap-9">
<div className="grid gap-5">
<p className="text-sm font-normal text-[var(--primary)]">Energy setup</p>
<h1 className="max-w-2xl text-5xl font-normal leading-[0.95] tracking-[-0.055em] sm:text-7xl">
Hey {userName || "there"}. Set your baseline.
</h1>
<p className="max-w-xl text-lg font-normal leading-8 text-[var(--muted)]">
Six quick screens. Pick a theme, then set light guardrails for cans, spend, and late caffeine.
</p>
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Start
<ArrowRight size={16} />
</button>
</section>
)}
{step === 2 && (
<section className="grid gap-8">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">1. Visual style</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
Choose the mood you want to see every day.
</h2>
</div>
<div className="flex flex-wrap gap-2">
{THEME_CATEGORIES.map((cat) => {
const isActive = activeCategory === cat.id;
return (
<button
key={cat.id}
type="button"
onClick={() => setActiveCategory(cat.id)}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{
background: isActive ? "var(--primary-container)" : "var(--surface-container-lowest)",
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
color: isActive ? "var(--on-primary-container)" : "var(--muted)",
}}
>
{cat.label}
</button>
);
})}
</div>
<div className="grid max-h-[48vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{visibleThemes.map((theme) => {
const isActive = activeThemeId === theme.id;
return (
<button
key={theme.id}
type="button"
onClick={() => onThemeChange(theme.id)}
className="flex min-h-16 items-center justify-between rounded-2xl border px-4 text-left text-sm font-normal transition"
style={{
background: isActive ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
borderColor: isActive ? "var(--primary)" : "var(--outline-variant)",
color: "var(--text)",
}}
>
<span className="flex min-w-0 items-center gap-3">
<span className="h-6 w-6 shrink-0 rounded-full border border-white/40" style={{ background: theme.swatch }} />
<span className="truncate">{theme.label}</span>
</span>
{isActive && <Check size={16} style={{ color: "var(--primary)" }} />}
</button>
);
})}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Continue
<ArrowRight size={16} />
</button>
</section>
)}
{step === 3 && (
<section className="grid gap-9">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">2. Daily cans</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
What is your daily can ceiling?
</h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
App warns before logging past this number. You can change it later.
</p>
</div>
<div className="flex flex-wrap items-end gap-5">
<button
type="button"
onClick={decrementCans}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
-
</button>
<div className="min-w-44">
<p className="text-7xl font-normal leading-none tracking-[-0.06em] sm:text-8xl" style={{ color: "var(--primary)" }}>
{dailyCanLimit === "none" ? "No cap" : dailyCanLimit}
</p>
<p className="mt-3 text-sm font-normal text-[var(--muted)]">
{dailyCanLimit === "none" ? "Unlimited daily volume" : dailyCanLimit === 1 ? "can per day" : "cans per day"}
</p>
</div>
<button
type="button"
onClick={incrementCans}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDailyCanLimit("none")}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{
background: dailyCanLimit === "none" ? "var(--primary-container)" : "var(--surface-container-lowest)",
borderColor: dailyCanLimit === "none" ? "var(--primary)" : "var(--outline-variant)",
color: dailyCanLimit === "none" ? "var(--on-primary-container)" : "var(--muted)",
}}
>
No daily cap
</button>
{dailyCanLimit === "none" && (
<button
type="button"
onClick={() => setDailyCanLimit(2)}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
Use 2 cans
</button>
)}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Continue
<ArrowRight size={16} />
</button>
</section>
)}
{step === 4 && (
<section className="grid gap-9">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">3. Daily spend</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
Set a daily spend line.
</h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
Useful for catching small purchases before they stack up.
</p>
</div>
<div className="flex flex-wrap items-end gap-5">
<button
type="button"
onClick={decrementSpend}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
-
</button>
<div className="min-w-52">
<p className="text-7xl font-normal leading-none tracking-[-0.06em] sm:text-8xl" style={{ color: "var(--primary)" }}>
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
</p>
<p className="mt-3 text-sm font-normal text-[var(--muted)]">
{dailySpendLimit === "none" ? "No daily budget" : "maximum per day"}
</p>
</div>
<button
type="button"
onClick={incrementSpend}
className="grid h-12 w-12 place-items-center rounded-full border text-2xl font-normal transition active:scale-95"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
+
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDailySpendLimit("none")}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{
background: dailySpendLimit === "none" ? "var(--primary-container)" : "var(--surface-container-lowest)",
borderColor: dailySpendLimit === "none" ? "var(--primary)" : "var(--outline-variant)",
color: dailySpendLimit === "none" ? "var(--on-primary-container)" : "var(--muted)",
}}
>
No spend cap
</button>
{dailySpendLimit === "none" && (
<button
type="button"
onClick={() => setDailySpendLimit(3.5)}
className="rounded-full border px-4 py-2 text-sm font-normal transition"
style={{ borderColor: "var(--outline-variant)", color: "var(--text)" }}
>
Use £3.50
</button>
)}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Continue
<ArrowRight size={16} />
</button>
</section>
)}
{step === 5 && (
<section className="grid gap-8">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">4. Caffeine curfew</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
When should late caffeine stop?
</h2>
<p className="max-w-lg text-base leading-7 text-[var(--muted)]">
Choose when the app should warn you that sleep may take the hit.
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{curfewOptions.map((timeOption) => {
const isSelected = stopTime === timeOption.id;
return (
<button
key={timeOption.id}
type="button"
onClick={() => setStopTime(timeOption.id)}
className="flex min-h-20 items-center justify-between rounded-2xl border px-4 text-left transition"
style={{
background: isSelected ? "var(--surface-container-low)" : "var(--surface-container-lowest)",
borderColor: isSelected ? "var(--primary)" : "var(--outline-variant)",
}}
>
<span>
<span className="block text-lg font-normal text-[var(--text)]">{timeOption.label}</span>
<span className="mt-1 block text-sm font-normal text-[var(--muted)]">{timeOption.hint}</span>
</span>
{isSelected && <Check size={16} style={{ color: "var(--primary)" }} />}
</button>
);
})}
</div>
<button
type="button"
onClick={goNext}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98]"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
Review
<ArrowRight size={16} />
</button>
</section>
)}
{step === 6 && (
<section className="grid gap-8">
<div className="grid gap-4">
<p className="text-sm font-normal text-[var(--primary)]">Ready</p>
<h2 className="max-w-2xl text-4xl font-normal leading-tight tracking-[-0.04em] sm:text-6xl">
This is your tracking profile.
</h2>
</div>
<div className="grid max-w-xl gap-3 rounded-3xl border p-5" style={{ background: "var(--surface-container-lowest)", borderColor: "var(--outline-variant)" }}>
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
<span className="text-sm font-normal text-[var(--muted)]">Theme</span>
<span className="flex items-center gap-2 text-sm font-normal text-[var(--text)]">
<span className="h-3 w-3 rounded-full" style={{ background: activeTheme.swatch }} />
{activeTheme.label}
</span>
</div>
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
<span className="text-sm font-normal text-[var(--muted)]">Daily cans</span>
<span className="text-sm font-normal text-[var(--text)]">
{dailyCanLimit === "none" ? "No cap" : `${dailyCanLimit} ${dailyCanLimit === 1 ? "can" : "cans"}`}
</span>
</div>
<div className="flex items-center justify-between gap-4 border-b pb-3" style={{ borderColor: "var(--outline-variant)" }}>
<span className="text-sm font-normal text-[var(--muted)]">Daily spend</span>
<span className="text-sm font-normal text-[var(--text)]">
{dailySpendLimit === "none" ? "No cap" : currency.format(dailySpendLimit)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-normal text-[var(--muted)]">Caffeine curfew</span>
<span className="text-sm font-normal text-[var(--text)]">{stopTime === "none" ? "No curfew" : stopTime}</span>
</div>
</div>
<button
type="button"
onClick={() => void handleFinish()}
disabled={saving}
className="inline-flex min-h-12 w-fit items-center gap-3 rounded-full px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: "var(--primary)", color: "var(--on-primary)" }}
>
{saving ? "Saving..." : "Start tracking"}
{!saving && <ArrowRight size={16} />}
</button>
</section>
)}
</main>
<footer className="relative z-10 mx-auto flex w-full max-w-3xl items-center justify-between gap-4 pb-2">
{step > 1 ? (
<button
type="button"
onClick={goBack}
disabled={saving}
className="inline-flex min-h-10 items-center gap-2 text-sm font-normal text-[var(--muted)] transition hover:text-[var(--text)] disabled:opacity-50"
>
<ChevronLeft size={16} />
Back
</button>
) : (
<span />
)}
<p className="text-xs font-normal text-[var(--muted)]">Minimal setup. Editable later.</p>
</footer>
</div>
);
}