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

19
app/(app)/avatar/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { AvatarStatusCard } from "@/components/avatar/avatar-status-card";
import { DailyQuizLinkButton } from "@/components/daily-quiz/daily-quiz-link-button";
import { HabitTracker } from "@/components/habits/habit-tracker";
import { AppShell } from "@/components/layout/app-shell";
export default function AvatarPage() {
return (
<AppShell
title="Your Habit Pet"
description="Today's tasks are personalized from your preferences and daily quiz."
>
<div className="flex flex-col gap-6">
<AvatarStatusCard />
<HabitTracker />
<DailyQuizLinkButton />
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,13 @@
import { DailyQuizForm } from "@/components/daily-quiz/daily-quiz-form";
import { AppShell } from "@/components/layout/app-shell";
export default function DailyQuizPage() {
return (
<AppShell
title="Daily quiz"
description="Complete your once-per-day wellness sliders and journal."
>
<DailyQuizForm />
</AppShell>
);
}

13
app/(app)/habits/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { HabitTracker } from "@/components/habits/habit-tracker";
import { AppShell } from "@/components/layout/app-shell";
export default function HabitsPage() {
return (
<AppShell
title="Habits"
description="Add custom habits and see how your daily tasks are chosen."
>
<HabitTracker mode="manage" />
</AppShell>
);
}

15
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Suspense } from "react";
import { AppAuthGate } from "@/components/layout/app-auth-gate";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense fallback={<div className="min-h-svh bg-background" />}>
<AppAuthGate>{children}</AppAuthGate>
</Suspense>
);
}

View File

@@ -0,0 +1,28 @@
import { Suspense } from "react";
import { ProfilePreferencesForm } from "@/components/profile/profile-preferences-form";
import { AppShell } from "@/components/layout/app-shell";
import { getSessionUser } from "@/lib/auth-helpers";
async function ProfileContent() {
const user = await getSessionUser();
return <ProfilePreferencesForm email={user?.email} />;
}
export default function ProfilePage() {
return (
<AppShell
title="Profile & preferences"
description="Edit account details and personalize your experience."
>
<Suspense
fallback={
<p className="text-sm text-muted-foreground">Loading profile...</p>
}
>
<ProfileContent />
</Suspense>
</AppShell>
);
}

66
app/(app)/shop/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import { AppShell } from "@/components/layout/app-shell";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
const shopItems = [
{
id: "hat",
name: "Pixel cap",
price: 50,
description: "A cozy cap for your habit pet.",
},
{
id: "bed",
name: "Cloud bed",
price: 120,
description: "Boost recovery after tough days.",
},
{
id: "snack",
name: "Berry snack",
price: 25,
description: "A small treat for good streaks.",
},
];
export default function ShopPage() {
return (
<AppShell
title="Shop"
description="Spend points earned from habits and daily check-ins."
>
<div className="mb-4 flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-3">
<span className="text-sm text-muted-foreground">Your balance</span>
<Badge variant="secondary">120 points</Badge>
</div>
<div className="grid gap-4">
{shopItems.map((item) => (
<Card key={item.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{item.name}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</div>
<Badge>{item.price} pts</Badge>
</div>
</CardHeader>
<CardContent>
<Button className="w-full" variant="outline">
Buy
</Button>
</CardContent>
</Card>
))}
</div>
</AppShell>
);
}

View File

@@ -1,4 +1,5 @@
import { createClient } from "@/lib/supabase/server";
import { routes } from "@/lib/routes";
import { type EmailOtpType } from "@supabase/supabase-js";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";
@@ -7,7 +8,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? "/";
const next = searchParams.get("next") ?? routes.onboardingQuiz;
if (token_hash && type) {
const supabase = await createClient();

14
app/auth/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { AuthBottomNav } from "@/components/layout/auth-bottom-nav";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<AuthBottomNav />
</>
);
}

View File

@@ -16,12 +16,13 @@ export default function Page() {
<CardTitle className="text-2xl">
Thank you for signing up!
</CardTitle>
<CardDescription>Check your email to confirm</CardDescription>
<CardDescription>
{`You're ready to go`}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
You&apos;ve successfully signed up. Please check your email to
confirm your account before signing in.
Your account is ready. You can sign in and start using Habit Pet.
</p>
</CardContent>
</Card>

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { GlobalBottomNav } from "@/components/layout/global-bottom-nav";
import "./globals.css";
const defaultUrl = process.env.VERCEL_URL
@@ -10,7 +12,7 @@ const defaultUrl = process.env.VERCEL_URL
export const metadata: Metadata = {
metadataBase: new URL(defaultUrl),
title: "Habit Pet",
description: "Hackathon bootstrap for a Next.js and Supabase habit pet app.",
description: "Build habits, care for your pixel pet, and track wellness every day.",
};
const geistSans = Geist({
@@ -33,7 +35,8 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<div className="min-h-svh pb-24">{children}</div>
<GlobalBottomNav />
</ThemeProvider>
</body>
</html>

15
app/onboarding/layout.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Suspense } from "react";
import { OnboardingAuthGate } from "@/components/layout/onboarding-auth-gate";
export default function OnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense fallback={<div className="min-h-svh bg-background" />}>
<OnboardingAuthGate>{children}</OnboardingAuthGate>
</Suspense>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
avatarVibeOptions,
focusTopicOptions,
saveOnboardingPreferences,
} from "@/lib/profile-preferences-storage";
import { routes } from "@/lib/routes";
const questions = [
{
id: "focus",
prompt: "What habit do you most want to build first?",
options: focusTopicOptions,
},
{
id: "avatarVibe",
prompt: "Pick a starter vibe for your pet.",
options: avatarVibeOptions,
},
] as const;
export default function OnboardingQuizPage() {
const router = useRouter();
const [step, setStep] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentQuestion = questions[step];
const isLastStep = step === questions.length - 1;
const handleSelect = (value: string) => {
setAnswers((current) => ({
...current,
[currentQuestion.id]: value,
}));
};
const handleContinue = async () => {
if (!answers[currentQuestion.id]) {
return;
}
if (!isLastStep) {
setStep((current) => current + 1);
return;
}
setIsSubmitting(true);
setError(null);
try {
await saveOnboardingPreferences(answers.focus, answers.avatarVibe);
router.push(routes.avatar);
router.refresh();
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: "Could not save onboarding answers.",
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-svh bg-background">
<main className="mx-auto flex min-h-svh max-w-lg flex-col justify-center px-5 py-10">
<div className="mb-8 flex flex-col items-center gap-4 text-center">
<PixelAvatar mood="neutral" size="md" />
<div>
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
Step {step + 1} of {questions.length}
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">
Welcome to Habit Pet
</h1>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{currentQuestion.prompt}</CardTitle>
<CardDescription>
This helps personalize your pet and starting habits.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{currentQuestion.options.map((option) => {
const selected = answers[currentQuestion.id] === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelect(option)}
className={`w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors ${
selected
? "border-primary bg-primary/10"
: "hover:bg-muted"
}`}
>
<Label className="cursor-pointer">{option}</Label>
</button>
);
})}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Button
className="w-full"
disabled={!answers[currentQuestion.id] || isSubmitting}
onClick={handleContinue}
>
{isSubmitting
? "Saving..."
: isLastStep
? "Meet your pet"
: "Continue"}
</Button>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@@ -1,58 +1,91 @@
import { DeployButton } from "@/components/deploy-button";
import { EnvVarWarning } from "@/components/env-var-warning";
import { AuthButton } from "@/components/auth-button";
import { Hero } from "@/components/hero";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { ConnectSupabaseSteps } from "@/components/tutorial/connect-supabase-steps";
import { SignUpUserSteps } from "@/components/tutorial/sign-up-user-steps";
import { hasEnvVars } from "@/lib/utils";
import Link from "next/link";
import { Suspense } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { LoggedInRedirect } from "@/components/layout/logged-in-redirect";
import { SiteFooter, SiteHeader } from "@/components/layout/site-header";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { routes } from "@/lib/routes";
export default function Home() {
return (
<main className="min-h-screen flex flex-col items-center">
<div className="flex-1 w-full flex flex-col gap-20 items-center">
<nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
<div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm">
<div className="flex gap-5 items-center font-semibold">
<Link href={"/"}>Habit Pet</Link>
<div className="flex items-center gap-2">
<DeployButton />
</div>
</div>
{!hasEnvVars ? (
<EnvVarWarning />
) : (
<Suspense>
<AuthButton />
</Suspense>
)}
</div>
</nav>
<div className="flex-1 flex flex-col gap-20 max-w-5xl p-5">
<Hero />
<main className="flex-1 flex flex-col gap-6 px-4">
<h2 className="font-medium text-xl mb-4">Next steps</h2>
{hasEnvVars ? <SignUpUserSteps /> : <ConnectSupabaseSteps />}
</main>
</div>
<main className="flex min-h-svh flex-col">
<Suspense fallback={null}>
<LoggedInRedirect />
</Suspense>
<footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16">
<p>
Powered by{" "}
<a
href="https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs"
target="_blank"
className="font-bold hover:underline"
rel="noreferrer"
>
Supabase
</a>
</p>
<ThemeSwitcher />
</footer>
<SiteHeader />
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-12 px-5 py-12">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<div className="space-y-6">
<p className="text-sm uppercase tracking-[0.25em] text-muted-foreground">
Calgary Hackathon MVP
</p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
Build habits. Care for your pixel pet.
</h1>
<p className="max-w-xl text-base text-muted-foreground sm:text-lg">
Habit Pet turns daily check-ins, wellness sliders, and journaling
into a simple game. Keep streaks alive, take your once-per-day
quiz, and watch your pet change with you.
</p>
<div className="flex flex-wrap gap-3">
<Button asChild size="lg">
<Link href={routes.signUp}>Get started</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href={routes.login}>Sign in</Link>
</Button>
</div>
</div>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-6 pt-8">
<PixelAvatar mood="happy" />
<div className="space-y-2 text-center">
<p className="text-sm font-medium">Your daily loop</p>
<p className="text-sm text-muted-foreground">
Habits, wellness, journal, quiz, shop.
</p>
</div>
</CardContent>
</Card>
</section>
<section className="grid gap-4 md:grid-cols-3">
{[
{
title: "Daily habits",
description: "Custom checkmarks and streak tracking.",
},
{
title: "Wellness + journal",
description: "Sliders and a daily reflection box.",
},
{
title: "Pet that responds",
description: "Avatar mood changes from your input.",
},
].map((feature) => (
<Card key={feature.title}>
<CardHeader>
<CardTitle className="text-base">{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
</Card>
))}
</section>
</div>
<SiteFooter />
</main>
);
}

View File

@@ -1,43 +1,7 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { InfoIcon } from "lucide-react";
import { FetchDataSteps } from "@/components/tutorial/fetch-data-steps";
import { Suspense } from "react";
async function UserDetails() {
const supabase = await createClient();
const { data, error } = await supabase.auth.getClaims();
if (error || !data?.claims) {
redirect("/auth/login");
}
return JSON.stringify(data.claims, null, 2);
}
import { routes } from "@/lib/routes";
export default function ProtectedPage() {
return (
<div className="flex-1 w-full flex flex-col gap-12">
<div className="w-full">
<div className="bg-accent text-sm p-3 px-5 rounded-md text-foreground flex gap-3 items-center">
<InfoIcon size="16" strokeWidth={2} />
This is a protected page that you can only see as an authenticated
user
</div>
</div>
<div className="flex flex-col gap-2 items-start">
<h2 className="font-bold text-2xl mb-4">Your user details</h2>
<pre className="text-xs font-mono p-3 rounded border max-h-32 overflow-auto">
<Suspense>
<UserDetails />
</Suspense>
</pre>
</div>
<div>
<h2 className="font-bold text-2xl mb-4">Next steps</h2>
<FetchDataSteps />
</div>
</div>
);
redirect(routes.avatar);
}