Merge main into owen

Updates the owen branch with the latest changes from main to keep it in sync and resolve any differences before continuing development.
This commit is contained in:
pricewowen
2026-05-23 22:04:04 -06:00
committed by GitHub
56 changed files with 7454 additions and 125 deletions

View File

@@ -1,28 +1,29 @@
import Link from "next/link";
import { Button } from "./ui/button";
import { createClient } from "@/lib/supabase/server";
import { LogoutButton } from "./logout-button";
import { routes } from "@/lib/routes";
export async function AuthButton() {
const supabase = await createClient();
// You can also use getUser() which will be slower.
const { data } = await supabase.auth.getClaims();
const user = data?.claims;
return user ? (
<div className="flex items-center gap-4">
Hey, {user.email}!
<div className="flex items-center gap-3">
<Button asChild size="sm" variant="outline">
<Link href={routes.avatar}>Open app</Link>
</Button>
<LogoutButton />
</div>
) : (
<div className="flex gap-2">
<Button asChild size="sm" variant={"outline"}>
<Link href="/auth/login">Sign in</Link>
<Link href={routes.login}>Sign in</Link>
</Button>
<Button asChild size="sm" variant={"default"}>
<Link href="/auth/sign-up">Sign up</Link>
<Link href={routes.signUp}>Sign up</Link>
</Button>
</div>
);

View File

@@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
} from "@/components/ui/card";
import {
defaultAvatarCondition,
WELLNESS_SCALE_MAX,
type AvatarCondition,
} from "@/lib/avatar-state";
import {
getAvatarConditionForToday,
hasCompletedDailyQuizToday,
} from "@/lib/daily-quiz-storage";
import { routes } from "@/lib/routes";
export function AvatarStatusCard() {
const [condition, setCondition] = useState<AvatarCondition>(
defaultAvatarCondition,
);
const [hasCheckedInToday, setHasCheckedInToday] = useState(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function loadStatus() {
const checkedIn = await hasCompletedDailyQuizToday();
setHasCheckedInToday(checkedIn);
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
setIsReady(true);
}
void loadStatus();
}, []);
if (!isReady) {
return (
<Card>
<CardContent className="pt-6">
<p className="text-center text-sm text-muted-foreground">
Loading your pet...
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
<PixelAvatar mood={condition.mood} />
<div className="flex flex-wrap justify-center gap-2">
<Badge variant="secondary">
Energy {condition.energy}/{WELLNESS_SCALE_MAX}
</Badge>
<Badge variant="secondary">
Health {condition.health}/{WELLNESS_SCALE_MAX}
</Badge>
<Badge variant={hasCheckedInToday ? "default" : "outline"}>
{hasCheckedInToday ? "Checked in today" : "Quiz pending"}
</Badge>
</div>
<p className="text-center text-sm text-muted-foreground">
{hasCheckedInToday
? "Your pet is reacting to today's wellness check-in and journal."
: "Complete today's quiz to update your pet's mood, energy, and health."}
</p>
{!hasCheckedInToday ? (
<Button asChild className="w-full">
<Link href={routes.dailyQuiz}>Take today&apos;s quiz</Link>
</Button>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import { cn } from "@/lib/utils";
import type { AvatarMood } from "@/lib/avatar-state";
const moodStyles: Record<AvatarMood, string> = {
happy: "bg-emerald-400 shadow-[0_0_0_4px_rgba(16,185,129,0.25)]",
neutral: "bg-amber-300 shadow-[0_0_0_4px_rgba(251,191,36,0.25)]",
tired: "bg-sky-400 shadow-[0_0_0_4px_rgba(56,189,248,0.25)]",
};
type PixelAvatarProps = {
mood?: AvatarMood;
size?: "sm" | "md" | "lg";
className?: string;
};
const sizeStyles = {
sm: "h-16 w-16",
md: "h-24 w-24",
lg: "h-32 w-32",
};
export function PixelAvatar({
mood = "neutral",
size = "lg",
className,
}: PixelAvatarProps) {
return (
<div
className={cn("flex flex-col items-center gap-3", className)}
aria-label={`Pixel pet avatar, mood ${mood}`}
>
<div
className={cn(
"relative rounded-md border-2 border-foreground/80",
sizeStyles[size],
moodStyles[mood],
)}
style={{ imageRendering: "pixelated" }}
>
<div className="absolute left-1/2 top-[28%] flex -translate-x-1/2 gap-2">
<span className="h-2 w-2 rounded-[1px] bg-foreground/90" />
<span className="h-2 w-2 rounded-[1px] bg-foreground/90" />
</div>
<div className="absolute bottom-[24%] left-1/2 h-1 w-4 -translate-x-1/2 rounded-[1px] bg-foreground/80" />
</div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{mood}
</p>
</div>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import { useEffect, useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
defaultDailyQuizAnswers,
formatSleepHours,
SLEEP_HOURS_MAX,
SLEEP_HOURS_MIN,
SLEEP_HOURS_STEP,
WELLNESS_SCALE_MAX,
type DailyQuizAnswers,
type DailyQuizSubmission,
} from "@/lib/avatar-state";
import {
getDailyEntryForToday,
saveDailyEntry,
} from "@/lib/daily-quiz-storage";
export function DailyQuizForm() {
const [answers, setAnswers] = useState<DailyQuizAnswers>(
defaultDailyQuizAnswers,
);
const [journal, setJournal] = useState("");
const [submission, setSubmission] = useState<DailyQuizSubmission | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function loadDailyCheckIn() {
try {
const existingEntry = await getDailyEntryForToday();
if (existingEntry) {
setSubmission(existingEntry);
setAnswers(existingEntry.answers);
setJournal(existingEntry.journal);
}
} catch (loadError) {
setError(
loadError instanceof Error
? loadError.message
: "Could not load today's check-in.",
);
} finally {
setIsReady(true);
}
}
void loadDailyCheckIn();
}, []);
const isCompleted = submission !== null;
const canSubmit = !isCompleted && !isSubmitting;
const updateAnswer = <K extends keyof DailyQuizAnswers>(
key: K,
value: DailyQuizAnswers[K],
) => {
setAnswers((current) => ({
...current,
[key]: value,
}));
};
const handleSubmit = async () => {
if (!canSubmit) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const savedEntry = await saveDailyEntry(answers, journal);
setSubmission(savedEntry);
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: "Could not save your daily check-in.",
);
} finally {
setIsSubmitting(false);
}
};
if (!isReady) {
return (
<p className="text-sm text-muted-foreground">Loading today&apos;s quiz...</p>
);
}
return (
<div className="flex flex-col gap-6">
{isCompleted && submission ? (
<>
<Card className="border-primary/40 bg-primary/10">
<CardContent className="flex items-start gap-4 pt-6">
<div className="rounded-full bg-primary/15 p-2.5">
<CheckCircle2
aria-hidden="true"
className="h-7 w-7 text-primary"
/>
</div>
<div className="space-y-1">
<p className="text-lg font-semibold tracking-tight">
Today&apos;s quiz is complete
</p>
<p className="text-sm text-muted-foreground">
Your wellness check-in and journal are saved for today. You
can review your answers below, and come back tomorrow for
your next quiz.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Your pet today</CardTitle>
<CardDescription>
Based on the wellness check-in you already submitted.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<PixelAvatar mood={submission.condition.mood} size="md" />
<div className="grid w-full grid-cols-3 gap-2 text-center text-sm">
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Mood</p>
<p className="font-medium capitalize">
{submission.condition.mood}
</p>
</div>
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Energy</p>
<p className="font-medium">
{submission.condition.energy}/{WELLNESS_SCALE_MAX}
</p>
</div>
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Health</p>
<p className="font-medium">
{submission.condition.health}/{WELLNESS_SCALE_MAX}
</p>
</div>
</div>
</CardContent>
</Card>
</>
) : (
<div className="flex items-center justify-between">
<Badge>Today&apos;s check-in</Badge>
<Badge variant="outline">Not completed</Badge>
</div>
)}
<Card className={isCompleted ? "opacity-90" : undefined}>
<CardHeader>
<CardTitle className="text-base">
{isCompleted ? "Your answers today" : "Wellness"}
</CardTitle>
<CardDescription>
{isCompleted
? "These fields are locked until tomorrow's quiz."
: "Rate your wellness from 1 to 5. Sleep length uses hours."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<WellnessSlider
id="feeling"
label="How are you feeling today?"
value={answers.feeling}
disabled={isCompleted}
onChange={(value) => updateAnswer("feeling", value)}
/>
<WellnessSlider
id="stress"
label="How stressed are you today?"
value={answers.stress}
disabled={isCompleted}
onChange={(value) => updateAnswer("stress", value)}
/>
<WellnessSlider
id="energy"
label="How much energy do you have?"
value={answers.energy}
disabled={isCompleted}
onChange={(value) => updateAnswer("energy", value)}
/>
<WellnessSlider
id="sleep-length"
label="How long did you sleep?"
value={answers.sleepLength}
min={SLEEP_HOURS_MIN}
max={SLEEP_HOURS_MAX}
step={SLEEP_HOURS_STEP}
formatValue={formatSleepHours}
disabled={isCompleted}
onChange={(value) => updateAnswer("sleepLength", value)}
/>
<WellnessSlider
id="sleep-quality"
label="How good was your sleep?"
value={answers.sleepQuality}
disabled={isCompleted}
onChange={(value) => updateAnswer("sleepQuality", value)}
/>
</CardContent>
</Card>
<Card className={isCompleted ? "opacity-90" : undefined}>
<CardHeader>
<CardTitle className="text-base">Daily journal</CardTitle>
<CardDescription>
{isCompleted
? "Your journal entry for today."
: "Write about your day."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Label htmlFor="journal" className="sr-only">
Daily journal
</Label>
<textarea
id="journal"
value={journal}
disabled={isCompleted}
onChange={(event) => setJournal(event.target.value)}
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
placeholder="Write about your day."
/>
</CardContent>
</Card>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{!isCompleted ? (
<Button className="w-full" disabled={!canSubmit} onClick={handleSubmit}>
{isSubmitting ? "Saving..." : "Complete daily quiz"}
</Button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
import { routes } from "@/lib/routes";
export function DailyQuizLinkButton() {
const [isCompleted, setIsCompleted] = useState(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function loadStatus() {
setIsCompleted(await hasCompletedDailyQuizToday());
setIsReady(true);
}
void loadStatus();
}, []);
if (!isReady || isCompleted) {
return (
<Button className="w-full" variant="outline" disabled>
Open daily quiz
</Button>
);
}
return (
<Button asChild className="w-full" variant="outline">
<Link href={routes.dailyQuiz}>Open daily quiz</Link>
</Button>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { Label } from "@/components/ui/label";
type WellnessSliderProps = {
id: string;
label: string;
value: number;
min?: number;
max?: number;
step?: number;
unit?: string;
formatValue?: (value: number) => string;
disabled?: boolean;
onChange: (value: number) => void;
};
export function WellnessSlider({
id,
label,
value,
min = 1,
max = 5,
step = 1,
unit,
formatValue,
disabled = false,
onChange,
}: WellnessSliderProps) {
const displayValue = formatValue ? formatValue(value) : `${value}${unit ?? ""}`;
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<Label htmlFor={id}>{label}</Label>
<span className="text-muted-foreground">{displayValue}</span>
</div>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="w-full accent-primary disabled:opacity-60"
/>
</div>
);
}

View File

@@ -0,0 +1,429 @@
"use client";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
getDailyTasks,
type DailyTask,
} from "@/lib/daily-tasks";
import {
addHabit,
getCustomHabits,
isHabitCompletedToday,
removeHabit,
toggleHabitCompletion,
type Habit,
} from "@/lib/habits-storage";
import { getProfilePreferences } from "@/lib/profile-preferences-storage";
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
type HabitTrackerProps = {
mode?: "daily" | "manage";
};
const reasonLabels = {
custom: "Custom",
focus: "Your focus",
quiz: "Today's quiz",
} as const;
function DailyTaskList({
tasks,
getIsCompleted,
isPending,
getToggleError,
onToggle,
}: {
tasks: DailyTask[];
getIsCompleted: (habit: DailyTask) => boolean;
isPending: (habitId: string) => boolean;
getToggleError: (habitId: string) => string | null;
onToggle: (habitId: string) => void;
}) {
if (tasks.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No daily tasks yet. Set focus topics in Profile or complete the daily
quiz to personalize your list.
</p>
);
}
const sortedTasks = [...tasks].sort((a, b) => {
const aCompleted = getIsCompleted(a);
const bCompleted = getIsCompleted(b);
if (aCompleted === bCompleted) {
return 0;
}
return aCompleted ? 1 : -1;
});
return (
<>
{sortedTasks.map((habit) => {
const isCompleted = getIsCompleted(habit);
const pending = isPending(habit.id);
const toggleError = getToggleError(habit.id);
return (
<div
key={habit.id}
className={`flex items-start justify-between gap-3 rounded-lg border p-3 ${
isCompleted ? "bg-muted/40 opacity-80" : ""
}`}
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex items-center gap-2">
<Checkbox
id={habit.id}
checked={isCompleted}
disabled={pending}
onCheckedChange={() => onToggle(habit.id)}
/>
{pending ? (
<>
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin text-muted-foreground"
/>
<span className="sr-only">Saving habit completion</span>
</>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-2">
<Label htmlFor={habit.id} className="leading-snug">
{habit.label}
</Label>
<Badge
variant="outline"
className="w-fit px-2.5 py-0.5 text-[11px] font-normal"
>
{reasonLabels[habit.reason]}
</Badge>
{toggleError ? (
<p className="text-xs text-red-500">{toggleError}</p>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
{habit.streak} day streak
</Badge>
</div>
);
})}
</>
);
}
function CustomHabitList({
habits,
onRemove,
}: {
habits: Habit[];
onRemove: (habitId: string) => void;
}) {
if (habits.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No custom habits yet. Add one below to include it in your daily tasks.
</p>
);
}
return (
<>
{habits.map((habit) => (
<div
key={habit.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<span className="text-sm">{habit.label}</span>
<div className="flex items-center gap-2">
<Badge variant="secondary">{habit.streak} day streak</Badge>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => onRemove(habit.id)}
>
Remove
</Button>
</div>
</div>
))}
</>
);
}
export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
const [dailyTasks, setDailyTasks] = useState<DailyTask[]>([]);
const [customHabits, setCustomHabits] = useState<Habit[]>([]);
const [newHabitLabel, setNewHabitLabel] = useState("");
const [focusTopic, setFocusTopic] = useState<string | null>(null);
const [quizCompletedToday, setQuizCompletedToday] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [optimisticCompletion, setOptimisticCompletion] = useState<
Record<string, boolean>
>({});
const [pendingHabitIds, setPendingHabitIds] = useState<Set<string>>(
() => new Set(),
);
const [toggleErrors, setToggleErrors] = useState<Record<string, string>>({});
const getIsCompleted = useCallback(
(habit: DailyTask) => {
if (habit.id in optimisticCompletion) {
return optimisticCompletion[habit.id];
}
return isHabitCompletedToday(habit);
},
[optimisticCompletion],
);
const isPending = useCallback(
(habitId: string) => pendingHabitIds.has(habitId),
[pendingHabitIds],
);
const getToggleError = useCallback(
(habitId: string) => toggleErrors[habitId] ?? null,
[toggleErrors],
);
const refresh = useCallback(async () => {
try {
setError(null);
const [tasks, customs] = await Promise.all([
getDailyTasks(),
getCustomHabits(),
]);
setDailyTasks(tasks);
setCustomHabits(customs);
setFocusTopic((await getProfilePreferences()).focusTopic);
setQuizCompletedToday(await hasCompletedDailyQuizToday());
setIsReady(true);
} catch (refreshError) {
setError(
refreshError instanceof Error
? refreshError.message
: "Could not load habits.",
);
setIsReady(true);
}
}, []);
useEffect(() => {
void refresh();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
};
}, [refresh]);
const handleToggle = (habitId: string) => {
const habit = dailyTasks.find((task) => task.id === habitId);
if (!habit || pendingHabitIds.has(habitId)) {
return;
}
const nextCompleted = !getIsCompleted(habit);
setOptimisticCompletion((current) => ({
...current,
[habitId]: nextCompleted,
}));
setPendingHabitIds((current) => new Set(current).add(habitId));
setToggleErrors((current) => {
const next = { ...current };
delete next[habitId];
return next;
});
void (async () => {
try {
const updatedHabits = await toggleHabitCompletion(habitId);
setDailyTasks((current) =>
current.map((task) => {
const updated = updatedHabits.find((item) => item.id === task.id);
return updated ? { ...task, ...updated } : task;
}),
);
setOptimisticCompletion((current) => {
const next = { ...current };
delete next[habitId];
return next;
});
} catch (toggleError) {
setOptimisticCompletion((current) => {
const next = { ...current };
delete next[habitId];
return next;
});
setToggleErrors((current) => ({
...current,
[habitId]:
toggleError instanceof Error
? toggleError.message
: "Could not update habit.",
}));
} finally {
setPendingHabitIds((current) => {
const next = new Set(current);
next.delete(habitId);
return next;
});
}
})();
};
const handleAddHabit = async () => {
try {
setIsSaving(true);
setError(null);
await addHabit(newHabitLabel);
setNewHabitLabel("");
} catch (addError) {
setError(
addError instanceof Error ? addError.message : "Could not add habit.",
);
} finally {
setIsSaving(false);
}
};
const handleRemoveHabit = async (habitId: string) => {
try {
setIsSaving(true);
setError(null);
await removeHabit(habitId);
} catch (removeError) {
setError(
removeError instanceof Error
? removeError.message
: "Could not remove habit.",
);
} finally {
setIsSaving(false);
}
};
if (!isReady) {
return (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Loading habits...</p>
</CardContent>
</Card>
);
}
if (mode === "manage") {
return (
<div className="flex flex-col gap-6">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Card>
<CardHeader>
<CardTitle className="text-base">Custom habits</CardTitle>
<CardDescription>
Add your own habits. Custom habits always appear in your daily
task list.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CustomHabitList habits={customHabits} onRemove={handleRemoveHabit} />
<div className="flex gap-2">
<Input
value={newHabitLabel}
placeholder="Add a custom habit"
disabled={isSaving}
onChange={(event) => setNewHabitLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleAddHabit();
}
}}
/>
<Button
type="button"
disabled={isSaving || newHabitLabel.trim().length === 0}
onClick={() => void handleAddHabit()}
>
Add
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">How daily tasks are picked</CardTitle>
<CardDescription>
Your tasks combine focus preferences, custom habits, and today&apos;s
quiz.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
Focus topics:{" "}
{focusTopic ?? "None selected"}
</p>
<p>
Daily quiz today:{" "}
{quizCompletedToday ? "Completed" : "Not completed yet"}
</p>
<p>
Suggested habits can appear when your quiz shows low energy, high
stress, or poor sleep.
</p>
</CardContent>
</Card>
</div>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Today&apos;s tasks</CardTitle>
<CardDescription>
Personalized from your focus topics
{quizCompletedToday ? ", today's quiz," : ""} and custom habits.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<DailyTaskList
tasks={dailyTasks}
getIsCompleted={getIsCompleted}
isPending={isPending}
getToggleError={getToggleError}
onToggle={handleToggle}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
import { requireOnboardingComplete } from "@/lib/auth-helpers";
type AppAuthGateProps = {
children: React.ReactNode;
};
export async function AppAuthGate({ children }: AppAuthGateProps) {
await requireOnboardingComplete();
return children;
}

View File

@@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Home,
ListPlus,
ShoppingBag,
Sparkles,
UserRound,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { appNavItems, routes, type NavItem } from "@/lib/routes";
const gridColsClass = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
} as const;
const navIcons = {
[routes.avatar]: Home,
[routes.habits]: ListPlus,
[routes.dailyQuiz]: Sparkles,
[routes.shop]: ShoppingBag,
[routes.profile]: UserRound,
} as const;
type AppNavProps = {
items?: readonly NavItem[];
};
export function AppNav({ items = appNavItems }: AppNavProps) {
const pathname = usePathname();
const columnCount = Math.min(
items.length,
5,
) as keyof typeof gridColsClass;
return (
<nav className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div
className={cn(
"mx-auto grid max-w-lg gap-1 px-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2",
gridColsClass[columnCount],
)}
>
{items.map((item) => {
const Icon = navIcons[item.href];
const isActive =
pathname === item.href ||
(item.href !== routes.avatar && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center gap-1 rounded-lg px-1 py-2 text-[9px] font-medium leading-tight transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
<span>{item.shortLabel}</span>
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,25 @@
type AppShellProps = {
children: React.ReactNode;
title?: string;
description?: string;
};
export function AppShell({ children, title, description }: AppShellProps) {
return (
<div className="min-h-svh bg-background">
{(title || description) && (
<header className="border-b border-border/60 bg-card/40">
<div className="mx-auto max-w-lg px-5 py-5">
{title ? (
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
) : null}
{description ? (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
) : null}
</div>
</header>
)}
<main className="mx-auto max-w-lg px-5 py-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, LogIn, UserPlus } from "lucide-react";
import { routes } from "@/lib/routes";
import { cn } from "@/lib/utils";
const authNavItems = [
{ href: routes.home, label: "Home", icon: Home },
{ href: routes.login, label: "Sign in", icon: LogIn },
{ href: routes.signUp, label: "Sign up", icon: UserPlus },
] as const;
export function AuthBottomNav() {
const pathname = usePathname();
return (
<nav className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto grid max-w-lg grid-cols-3 gap-1 px-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
{authNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center gap-1 rounded-lg px-1 py-2 text-[9px] font-medium leading-tight transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import { AppNav } from "@/components/layout/app-nav";
import { createClient } from "@/lib/supabase/client";
export function GlobalBottomNav() {
const [showNav, setShowNav] = useState(false);
useEffect(() => {
const supabase = createClient();
async function syncNavState() {
const {
data: { user },
} = await supabase.auth.getUser();
setShowNav(
!!user && user.user_metadata?.onboarding_completed === true,
);
}
void syncNavState();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
void syncNavState();
});
return () => subscription.unsubscribe();
}, []);
if (!showNav) {
return null;
}
return <AppNav />;
}

View File

@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import {
getSessionUser,
hasCompletedOnboarding,
} from "@/lib/auth-helpers";
import { routes } from "@/lib/routes";
export async function LoggedInRedirect() {
const user = await getSessionUser();
if (user) {
redirect(
hasCompletedOnboarding(user) ? routes.avatar : routes.onboardingQuiz,
);
}
return null;
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import {
hasCompletedOnboarding,
requireSessionUser,
} from "@/lib/auth-helpers";
import { routes } from "@/lib/routes";
type OnboardingAuthGateProps = {
children: React.ReactNode;
};
export async function OnboardingAuthGate({ children }: OnboardingAuthGateProps) {
const user = await requireSessionUser();
if (hasCompletedOnboarding(user)) {
redirect(routes.avatar);
}
return children;
}

View File

@@ -0,0 +1,38 @@
import Link from "next/link";
import { Suspense } from "react";
import { AuthButton } from "@/components/auth-button";
import { EnvVarWarning } from "@/components/env-var-warning";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { routes } from "@/lib/routes";
import { hasEnvVars } from "@/lib/utils";
export function SiteHeader() {
return (
<header className="w-full border-b border-b-foreground/10">
<div className="mx-auto flex h-16 w-full max-w-5xl items-center justify-between px-5 text-sm">
<Link href={routes.home} className="font-semibold tracking-tight">
Habit Pet
</Link>
{!hasEnvVars ? (
<EnvVarWarning />
) : (
<Suspense>
<AuthButton />
</Suspense>
)}
</div>
</header>
);
}
export function SiteFooter() {
return (
<footer className="w-full border-t py-8 text-center text-xs text-muted-foreground">
<div className="mx-auto flex max-w-5xl flex-col items-center gap-4 px-5">
<p>Build habits. Care for your pet. Feel better every day.</p>
<ThemeSwitcher />
</div>
</footer>
);
}

View File

@@ -16,6 +16,8 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { routes } from "@/lib/routes";
export function LoginForm({
className,
...props
@@ -38,8 +40,8 @@ export function LoginForm({
password,
});
if (error) throw error;
// Update this route to redirect to an authenticated route. The user already has an active session.
router.push("/protected");
router.push(routes.avatar);
router.refresh();
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");
} finally {

View File

@@ -1,16 +1,19 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { createClient } from "@/lib/supabase/client";
import { routes } from "@/lib/routes";
export function LogoutButton() {
const router = useRouter();
const logout = async () => {
const supabase = createClient();
await supabase.auth.signOut();
router.push("/auth/login");
router.push(routes.home);
router.refresh();
};
return <Button onClick={logout}>Logout</Button>;

View File

@@ -0,0 +1,194 @@
"use client";
import { useEffect, useState } from "react";
import { LogoutButton } from "@/components/logout-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
avatarVibeOptions,
defaultProfilePreferences,
focusTopicOptions,
getProfilePreferences,
saveProfilePreferences,
type ProfilePreferences,
} from "@/lib/profile-preferences-storage";
type ProfilePreferencesFormProps = {
email?: string | null;
};
export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
const [preferences, setPreferences] = useState<ProfilePreferences>(
defaultProfilePreferences,
);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
async function loadPreferences() {
try {
setPreferences(await getProfilePreferences());
} catch (loadError) {
setError(
loadError instanceof Error
? loadError.message
: "Could not load profile preferences.",
);
} finally {
setIsReady(true);
}
}
void loadPreferences();
}, []);
const updatePreferences = async (updates: Partial<ProfilePreferences>) => {
const nextPreferences = { ...preferences, ...updates };
setPreferences(nextPreferences);
setIsSaving(true);
setError(null);
try {
const savedPreferences = await saveProfilePreferences(nextPreferences);
setPreferences(savedPreferences);
} catch (saveError) {
setPreferences(preferences);
setError(
saveError instanceof Error
? saveError.message
: "Could not save profile preferences.",
);
} finally {
setIsSaving(false);
}
};
if (!isReady) {
return (
<p className="text-sm text-muted-foreground">Loading profile...</p>
);
}
return (
<div className="flex flex-col gap-6">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Card>
<CardHeader>
<CardTitle className="text-base">Profile information</CardTitle>
<CardDescription>Your account details.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={email ?? "Guest user"} readOnly />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Focus topic preference</CardTitle>
<CardDescription>
Choose the one area you want your pet and daily tasks to focus on.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{focusTopicOptions.map((topic) => (
<Button
key={topic}
type="button"
variant={preferences.focusTopic === topic ? "default" : "outline"}
className="w-full justify-start"
disabled={isSaving}
onClick={() => void updatePreferences({ focusTopic: topic })}
>
{topic}
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Avatar preferences</CardTitle>
<CardDescription>Personalize your pet&apos;s vibe.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{avatarVibeOptions.map((vibe) => (
<Button
key={vibe}
type="button"
size="sm"
disabled={isSaving}
variant={preferences.avatarVibe === vibe ? "default" : "outline"}
onClick={() => void updatePreferences({ avatarVibe: vibe })}
>
{vibe}
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Notification preferences</CardTitle>
<CardDescription>
Control reminders for your daily quiz and habits.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<Label htmlFor="daily-reminder">Daily reminder</Label>
<Checkbox
id="daily-reminder"
disabled={isSaving}
checked={preferences.dailyReminderEnabled}
onCheckedChange={(checked) =>
void updatePreferences({ dailyReminderEnabled: checked === true })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-time">Daily reminder time</Label>
<Input
id="reminder-time"
type="time"
disabled={isSaving || !preferences.dailyReminderEnabled}
value={preferences.dailyReminderTime}
onChange={(event) =>
void updatePreferences({ dailyReminderTime: event.target.value })
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Account settings</CardTitle>
<CardDescription>Session and onboarding status.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<span className="text-sm">Onboarding</span>
<Badge variant="secondary">Complete</Badge>
</div>
<LogoutButton />
</CardContent>
</Card>
</div>
);
}

View File

@@ -16,6 +16,8 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { routes } from "@/lib/routes";
export function SignUpForm({
className,
...props
@@ -40,14 +42,19 @@ export function SignUpForm({
}
try {
const { error } = await supabase.auth.signUp({
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/protected`,
emailRedirectTo: `${window.location.origin}/auth/confirm?next=${encodeURIComponent(routes.onboardingQuiz)}`,
},
});
if (error) throw error;
if (data.session) {
router.push(routes.onboardingQuiz);
router.refresh();
return;
}
router.push("/auth/sign-up-success");
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");