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:
@@ -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>
|
||||
);
|
||||
|
||||
82
components/avatar/avatar-status-card.tsx
Normal file
82
components/avatar/avatar-status-card.tsx
Normal 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's quiz</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
51
components/avatar/pixel-avatar.tsx
Normal file
51
components/avatar/pixel-avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
components/daily-quiz/daily-quiz-form.tsx
Normal file
261
components/daily-quiz/daily-quiz-form.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
}
|
||||
36
components/daily-quiz/daily-quiz-link-button.tsx
Normal file
36
components/daily-quiz/daily-quiz-link-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
components/daily-quiz/wellness-slider.tsx
Normal file
51
components/daily-quiz/wellness-slider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
429
components/habits/habit-tracker.tsx
Normal file
429
components/habits/habit-tracker.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
10
components/layout/app-auth-gate.tsx
Normal file
10
components/layout/app-auth-gate.tsx
Normal 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;
|
||||
}
|
||||
76
components/layout/app-nav.tsx
Normal file
76
components/layout/app-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/layout/app-shell.tsx
Normal file
25
components/layout/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
components/layout/auth-bottom-nav.tsx
Normal file
45
components/layout/auth-bottom-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
components/layout/global-bottom-nav.tsx
Normal file
40
components/layout/global-bottom-nav.tsx
Normal 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 />;
|
||||
}
|
||||
19
components/layout/logged-in-redirect.tsx
Normal file
19
components/layout/logged-in-redirect.tsx
Normal 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;
|
||||
}
|
||||
21
components/layout/onboarding-auth-gate.tsx
Normal file
21
components/layout/onboarding-auth-gate.tsx
Normal 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;
|
||||
}
|
||||
38
components/layout/site-header.tsx
Normal file
38
components/layout/site-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
194
components/profile/profile-preferences-form.tsx
Normal file
194
components/profile/profile-preferences-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user