diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7fe08ed --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,61 @@ +name: Deploy + +on: + push: + branches: + - main + +concurrency: + group: deploy-production-main + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Set up Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: 2.101.0 + + - name: Link Supabase project + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }} + SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }} + run: supabase link --project-ref "$SUPABASE_PROJECT_REF" --password "$SUPABASE_DB_PASSWORD" + + - name: Apply pending migrations and seed baseline data + env: + SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + run: supabase db push --linked --include-seed + + - name: Set up Vercel CLI + run: npm install --global vercel@54.4.1 + + - name: Deploy to Vercel production + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: vercel deploy --prod --yes --token "$VERCEL_TOKEN" diff --git a/.gitignore b/.gitignore index 473d96c..ab59853 100644 --- a/.gitignore +++ b/.gitignore @@ -31,12 +31,17 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env*.local +# env files .env +.env.* +!.env.example +!.env*.example +!.env*.sample +!.env*.template # vercel .vercel +supabase/.temp/ # typescript *.tsbuildinfo diff --git a/README.md b/README.md index 97f7659..df5513c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This repo currently stops at: - environment variable template - deployment-ready project structure -This repo does not yet include MVP feature pages, SQL schema, avatar logic, habits, streaks, or shop logic. +This repo does not yet include MVP feature pages, avatar logic, habits, streaks, or shop logic. ## Local setup @@ -59,11 +59,37 @@ Create a Supabase project, then copy the project URL and anon key from **Project The starter also supports Supabase's publishable key name, but this repo documents the anon key convention from the project plan. +### Database workflow + +- Schema migrations live under `supabase/migrations/`. +- Baseline catalog data lives in `supabase/seed.sql` and is safe to rerun during deploys because it uses idempotent upserts. +- Local CLI config lives in `supabase/config.toml` for `supabase start`, `supabase db reset`, and related commands. +- Production database sync runs from GitHub Actions on `main` using `supabase db push --linked --include-seed` before the app deploy relies on those changes. + ## Deploy -1. Push the repo to GitHub. -2. Import the repo into Vercel. -3. Add the same Supabase environment variables in Vercel. -4. Deploy. +This repo includes `.github/workflows/deploy.yml`, which runs on pushes to `main`. + +The workflow: + +- checks out the repo +- runs `npm ci`, `npm run lint`, and `npm run build` +- links the Supabase CLI to the production project +- applies pending database migrations and reruns `supabase/seed.sql` via `supabase db push --linked --include-seed` +- deploys the app to Vercel production only after the database sync succeeds + +### Required GitHub secrets + +- `SUPABASE_ACCESS_TOKEN` +- `SUPABASE_PROJECT_REF` +- `SUPABASE_DB_PASSWORD` +- `VERCEL_TOKEN` +- `VERCEL_ORG_ID` +- `VERCEL_PROJECT_ID` + +### Required Vercel environment variables + +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` Frontend and backend are both served from this Next.js app. Supabase provides auth and database services. diff --git a/app/(app)/avatar/page.tsx b/app/(app)/avatar/page.tsx new file mode 100644 index 0000000..da2310a --- /dev/null +++ b/app/(app)/avatar/page.tsx @@ -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 ( + +
+ + + +
+
+ ); +} diff --git a/app/(app)/daily/quiz/page.tsx b/app/(app)/daily/quiz/page.tsx new file mode 100644 index 0000000..8b3a684 --- /dev/null +++ b/app/(app)/daily/quiz/page.tsx @@ -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 ( + + + + ); +} diff --git a/app/(app)/habits/page.tsx b/app/(app)/habits/page.tsx new file mode 100644 index 0000000..b5a34e2 --- /dev/null +++ b/app/(app)/habits/page.tsx @@ -0,0 +1,13 @@ +import { HabitTracker } from "@/components/habits/habit-tracker"; +import { AppShell } from "@/components/layout/app-shell"; + +export default function HabitsPage() { + return ( + + + + ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..ee5f790 --- /dev/null +++ b/app/(app)/layout.tsx @@ -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 ( + }> + {children} + + ); +} diff --git a/app/(app)/profile/page.tsx b/app/(app)/profile/page.tsx new file mode 100644 index 0000000..a5aedab --- /dev/null +++ b/app/(app)/profile/page.tsx @@ -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 ; +} + +export default function ProfilePage() { + return ( + + Loading profile...

+ } + > + +
+
+ ); +} diff --git a/app/(app)/shop/page.tsx b/app/(app)/shop/page.tsx new file mode 100644 index 0000000..5a16088 --- /dev/null +++ b/app/(app)/shop/page.tsx @@ -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 ( + +
+ Your balance + 120 points +
+ +
+ {shopItems.map((item) => ( + + +
+
+ {item.name} + {item.description} +
+ {item.price} pts +
+
+ + + +
+ ))} +
+
+ ); +} diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts index b1b1086..22c5fa4 100644 --- a/app/auth/confirm/route.ts +++ b/app/auth/confirm/route.ts @@ -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(); diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx new file mode 100644 index 0000000..0120eac --- /dev/null +++ b/app/auth/layout.tsx @@ -0,0 +1,14 @@ +import { AuthBottomNav } from "@/components/layout/auth-bottom-nav"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + {children} + + + ); +} diff --git a/app/auth/sign-up-success/page.tsx b/app/auth/sign-up-success/page.tsx index 88f16d3..06bac40 100644 --- a/app/auth/sign-up-success/page.tsx +++ b/app/auth/sign-up-success/page.tsx @@ -16,12 +16,13 @@ export default function Page() { Thank you for signing up! - Check your email to confirm + + {`You're ready to go`} +

- You'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.

diff --git a/app/layout.tsx b/app/layout.tsx index 4c803d6..916f99b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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} +
{children}
+ diff --git a/app/onboarding/layout.tsx b/app/onboarding/layout.tsx new file mode 100644 index 0000000..de3c223 --- /dev/null +++ b/app/onboarding/layout.tsx @@ -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 ( + }> + {children} + + ); +} diff --git a/app/onboarding/quiz/page.tsx b/app/onboarding/quiz/page.tsx new file mode 100644 index 0000000..f8e2997 --- /dev/null +++ b/app/onboarding/quiz/page.tsx @@ -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>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+ +
+

+ Step {step + 1} of {questions.length} +

+

+ Welcome to Habit Pet +

+
+
+ + + + {currentQuestion.prompt} + + This helps personalize your pet and starting habits. + + + + {currentQuestion.options.map((option) => { + const selected = answers[currentQuestion.id] === option; + + return ( + + ); + })} + + {error ?

{error}

: null} + + +
+
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index a1659d2..ceea821 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ( -
-
- -
- -
-

Next steps

- {hasEnvVars ? : } -
-
+
+ + + - + + +
+
+
+

+ Calgary Hackathon MVP +

+

+ Build habits. Care for your pixel pet. +

+

+ 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. +

+
+ + +
+
+ + + + +
+

Your daily loop

+

+ Habits, wellness, journal, quiz, shop. +

+
+
+
+
+ +
+ {[ + { + 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) => ( + + + {feature.title} + {feature.description} + + + ))} +
+ +
); } diff --git a/app/protected/page.tsx b/app/protected/page.tsx index 4f6caa7..65ffbc6 100644 --- a/app/protected/page.tsx +++ b/app/protected/page.tsx @@ -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 ( -
-
-
- - This is a protected page that you can only see as an authenticated - user -
-
-
-

Your user details

-
-          
-            
-          
-        
-
-
-

Next steps

- -
-
- ); + redirect(routes.avatar); } diff --git a/components/auth-button.tsx b/components/auth-button.tsx index 29f696c..f55cc89 100644 --- a/components/auth-button.tsx +++ b/components/auth-button.tsx @@ -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 ? ( -
- Hey, {user.email}! +
+
) : (
); diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx new file mode 100644 index 0000000..17de203 --- /dev/null +++ b/components/avatar/avatar-status-card.tsx @@ -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( + 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 ( + + +

+ Loading your pet... +

+
+
+ ); + } + + return ( + + + +
+ + Energy {condition.energy}/{WELLNESS_SCALE_MAX} + + + Health {condition.health}/{WELLNESS_SCALE_MAX} + + + {hasCheckedInToday ? "Checked in today" : "Quiz pending"} + +
+

+ {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."} +

+ {!hasCheckedInToday ? ( + + ) : null} +
+
+ ); +} diff --git a/components/avatar/pixel-avatar.tsx b/components/avatar/pixel-avatar.tsx new file mode 100644 index 0000000..86aa9ba --- /dev/null +++ b/components/avatar/pixel-avatar.tsx @@ -0,0 +1,51 @@ +import { cn } from "@/lib/utils"; +import type { AvatarMood } from "@/lib/avatar-state"; + +const moodStyles: Record = { + 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 ( +
+
+
+ + +
+
+
+

+ {mood} +

+
+ ); +} diff --git a/components/daily-quiz/daily-quiz-form.tsx b/components/daily-quiz/daily-quiz-form.tsx new file mode 100644 index 0000000..e5d48b6 --- /dev/null +++ b/components/daily-quiz/daily-quiz-form.tsx @@ -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( + defaultDailyQuizAnswers, + ); + const [journal, setJournal] = useState(""); + const [submission, setSubmission] = useState( + null, + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(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 = ( + 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 ( +

Loading today's quiz...

+ ); + } + + return ( +
+ {isCompleted && submission ? ( + <> + + +
+
+
+

+ Today's quiz is complete +

+

+ Your wellness check-in and journal are saved for today. You + can review your answers below, and come back tomorrow for + your next quiz. +

+
+
+
+ + + + Your pet today + + Based on the wellness check-in you already submitted. + + + + +
+
+

Mood

+

+ {submission.condition.mood} +

+
+
+

Energy

+

+ {submission.condition.energy}/{WELLNESS_SCALE_MAX} +

+
+
+

Health

+

+ {submission.condition.health}/{WELLNESS_SCALE_MAX} +

+
+
+
+
+ + ) : ( +
+ Today's check-in + Not completed +
+ )} + + + + + {isCompleted ? "Your answers today" : "Wellness"} + + + {isCompleted + ? "These fields are locked until tomorrow's quiz." + : "Rate your wellness from 1 to 5. Sleep length uses hours."} + + + + updateAnswer("feeling", value)} + /> + updateAnswer("stress", value)} + /> + updateAnswer("energy", value)} + /> + updateAnswer("sleepLength", value)} + /> + updateAnswer("sleepQuality", value)} + /> + + + + + + Daily journal + + {isCompleted + ? "Your journal entry for today." + : "Write about your day."} + + + + +