Files
Red-Bull-Tracker/src/components/OnboardingScreen.tsx
T
Ned Halksworth 0db81bddd5 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.
2026-05-23 21:17:36 +01:00

495 lines
21 KiB
TypeScript

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>
);
}