diff --git a/README.md b/README.md index df5513c..4e8ab70 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,34 @@ # Habit Pet -Bootstrap repo for the Calgary hackathon Habit Pet project. +Calgary hackathon app for building habits while caring for a customizable pixel pet. ## Stack -- Next.js -- Supabase -- Vercel -- GitHub +- Next.js 16 (App Router) +- Supabase (auth + Postgres) +- Tailwind CSS + shadcn/ui +- Vercel deployment via GitHub Actions -## Scope of this bootstrap +## Features -This repo currently stops at: +- **Auth** — email/password sign up and login +- **Onboarding** — focus-topic quiz, then required avatar customization (DB-gated) +- **Avatar** — layered sprite pet with per-layer styles and hex colors, naming, equipped shop items +- **Habits** — catalog + custom habits, streaks, coin rewards +- **Daily quiz** — wellness sliders and journal that affect pet mood +- **Shop** — buy and equip items from `shop_items` / `user_items` (hats, glasses, backgrounds) +- **Profile** — focus topic, vibe, and reminder preferences -- Next.js app scaffold -- Supabase auth wiring from the starter -- environment variable template -- deployment-ready project structure +## User flow -This repo does not yet include MVP feature pages, avatar logic, habits, streaks, or shop logic. +1. Sign up / sign in +2. **Step 1:** onboarding quiz (focus topic only) +3. **Step 2:** avatar customization (required confirm + pet name) +4. Main app via bottom nav: Pet, Habits, Quiz, Shop, Profile + +Onboarding progress is stored in the database (`profiles.onboarding_quiz_complete`, `profiles.onboarding_complete`, `avatar_state.avatar_customized`) and mirrored in auth metadata for fast client checks. + +On the **Pet** tab, users rename their pet and switch between **Overview** and **Customize** sub-tabs. ## Local setup @@ -28,68 +38,80 @@ This repo does not yet include MVP feature pages, avatar logic, habits, streaks, npm install ``` -2. Create local environment file: +2. Generate placeholder character/shop sprites (if missing): + + ```bash + npm run generate:sprites + ``` + +3. Create local environment file: ```bash cp .env.example .env.local ``` -3. Add Supabase values to `.env.local`: +4. Add Supabase values to `.env.local`: ```env NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= ``` -4. Start the app: +5. Apply migrations locally (Supabase CLI): + + ```bash + supabase db reset + ``` + +6. Start the app: ```bash npm run dev ``` -5. Create a production build when needed: +## Avatar customization data - ```bash - npm run build - ``` +Stored in `avatar_state`: + +| Column | Purpose | +|--------|---------| +| `avatar_name` | Pet display name | +| `skin_color` | Skin layer hex color | +| `pants_style` / `pants_color` | Pants variant id + hex | +| `shoe_style` / `shoe_color` | Shoes variant id + hex | +| `torso_style` / `torso_color` | Torso variant id + hex | +| `eye_type` / `eye_color` | Eyes variant id + hex | +| `head_style` / `head_color` | Head variant id + hex | +| `avatar_customized` | True after onboarding confirm | +| `equipped_items` | Shop item ids currently equipped | + +Style columns are validated against allowed variant ids (`none`, `pants-1`, …). Shop accessories overlay equipped variants at render time without overwriting saved customization. + +Sprite assets: `public/character/{layer}/` (regenerate via `npm run generate:sprites`). + +## Shop + +- Sells character layer styles from `public/character/` (`head-1`, `pants-2`, etc.) +- Shop item `id` = variant id, `type` = layer id (`head`, `pants`, `shoes`, `torso`, `eyes`) +- Purchases unlock styles in `user_items`; owned styles appear in Customize and can be equipped from the shop +- Catalog seeded in `supabase/seed.sql` ## Supabase -Create a Supabase project, then copy the project URL and anon key from **Project Settings > API** into `.env.local` and Vercel project environment variables. +- Migrations: `supabase/migrations/` +- Seed: `supabase/seed.sql` +- Config: `supabase/config.toml` -The starter also supports Supabase's publishable key name, but this repo documents the anon key convention from the project plan. +Production deploys on push to `main` via `.github/workflows/deploy.yml` (lint, build, `supabase db push --include-seed`, Vercel). -### Database workflow +## Routes -- 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 - -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. +| Route | Access | +|-------|--------| +| `/` | Public | +| `/auth/*` | Public | +| `/onboarding/quiz` | Authenticated, quiz incomplete | +| `/onboarding/customize` | Authenticated, quiz complete, avatar not confirmed | +| `/avatar` | Authenticated, onboarding complete | +| `/habits`, `/daily/quiz`, `/shop`, `/profile` | Authenticated app | +| `/character-creator` | Redirects to `/avatar?tab=customize` | diff --git a/app/(app)/avatar/page.tsx b/app/(app)/avatar/page.tsx index da2310a..d56b728 100644 --- a/app/(app)/avatar/page.tsx +++ b/app/(app)/avatar/page.tsx @@ -1,19 +1,17 @@ -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 { Suspense } from "react"; + +import { AvatarPageContent } from "@/components/avatar/avatar-page-content"; import { AppShell } from "@/components/layout/app-shell"; export default function AvatarPage() { return ( -
- - - -
+ Loading...

}> + +
); } diff --git a/app/character-creator/page.tsx b/app/character-creator/page.tsx index cfe3e30..9d6407e 100644 --- a/app/character-creator/page.tsx +++ b/app/character-creator/page.tsx @@ -1,14 +1,7 @@ -import { CharacterCreator } from "@/components/character/character-creator"; +import { redirect } from "next/navigation"; + +import { routes } from "@/lib/routes"; export default function CharacterCreatorPage() { - return ( -
-
-

- Character Creation -

- -
-
- ); + redirect(`${routes.avatar}?tab=customize`); } diff --git a/app/layout.tsx b/app/layout.tsx index 916f99b..0326579 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist } from "next/font/google"; import { ThemeProvider } from "next-themes"; import { GlobalBottomNav } from "@/components/layout/global-bottom-nav"; +import { ToastProvider } from "@/components/ui/toast-provider"; import "./globals.css"; const defaultUrl = process.env.VERCEL_URL @@ -35,8 +36,10 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > -
{children}
- + +
{children}
+ +
diff --git a/app/onboarding/customize/page.tsx b/app/onboarding/customize/page.tsx new file mode 100644 index 0000000..98a8e39 --- /dev/null +++ b/app/onboarding/customize/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from "react"; + +import { OnboardingCustomizeAuthGate } from "@/components/layout/onboarding-customize-auth-gate"; +import { OnboardingCustomizeForm } from "@/components/onboarding/onboarding-customize-form"; + +export default function OnboardingCustomizePage() { + return ( + }> + + + + + ); +} diff --git a/app/onboarding/quiz/page.tsx b/app/onboarding/quiz/page.tsx index f8e2997..ad8c046 100644 --- a/app/onboarding/quiz/page.tsx +++ b/app/onboarding/quiz/page.tsx @@ -1,141 +1,14 @@ -"use client"; +import { Suspense } from "react"; -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; +import { OnboardingQuizAuthGate } from "@/components/layout/onboarding-quiz-auth-gate"; +import { OnboardingQuizForm } from "@/components/onboarding/onboarding-quiz-form"; 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/components/avatar/avatar-page-content.tsx b/components/avatar/avatar-page-content.tsx new file mode 100644 index 0000000..6e9b21a --- /dev/null +++ b/components/avatar/avatar-page-content.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { CharacterCreator } from "@/components/character/character-creator"; +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 { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/components/ui/toast-provider"; +import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; +import { + getAvatarCustomization, + saveAvatarCustomization, + saveAvatarName, + type AvatarCustomization, +} from "@/lib/avatar-customization-storage"; +import { getOwnedItemIds } from "@/lib/shop-storage"; +import { cn } from "@/lib/utils"; + +type AvatarTab = "home" | "customize"; + +export function AvatarPageContent() { + const { toast } = useToast(); + const searchParams = useSearchParams(); + const [activeTab, setActiveTab] = useState("home"); + const [customization, setCustomization] = useState( + null, + ); + const [avatarName, setAvatarName] = useState(""); + const [isSavingName, setIsSavingName] = useState(false); + const [isSavingAvatar, setIsSavingAvatar] = useState(false); + const [ownedVariantIds, setOwnedVariantIds] = useState([]); + + useEffect(() => { + if (searchParams.get("tab") === "customize") { + setActiveTab("customize"); + } + }, [searchParams]); + + const [loadError, setLoadError] = useState(null); + + const loadCustomization = useCallback(async () => { + try { + setLoadError(null); + const [next, owned] = await Promise.all([ + getAvatarCustomization(), + getOwnedItemIds(), + ]); + setCustomization(next); + setAvatarName(next.name); + setOwnedVariantIds(owned); + } catch (error) { + setLoadError( + error instanceof Error ? error.message : "Could not load your avatar.", + ); + } + }, []); + + useEffect(() => { + void loadCustomization(); + + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, loadCustomization); + return () => { + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, loadCustomization); + }; + }, [loadCustomization]); + + const handleSaveName = async () => { + setIsSavingName(true); + + try { + const savedName = await saveAvatarName(avatarName); + setAvatarName(savedName); + toast("Pet name saved.", "success"); + } catch (error) { + toast( + error instanceof Error ? error.message : "Could not save name.", + "error", + ); + } finally { + setIsSavingName(false); + } + }; + + const handleSaveAvatar = async (nextCustomization: AvatarCustomization) => { + setIsSavingAvatar(true); + + try { + await saveAvatarCustomization({ + ...nextCustomization, + name: avatarName.trim() || nextCustomization.name, + equippedItems: customization?.equippedItems ?? [], + }); + toast("Avatar updated.", "success"); + setActiveTab("home"); + await loadCustomization(); + } catch (error) { + toast( + error instanceof Error ? error.message : "Could not save avatar.", + "error", + ); + throw error; + } finally { + setIsSavingAvatar(false); + } + }; + + if (loadError) { + return

{loadError}

; + } + + if (!customization) { + return ( +

Loading your pet...

+ ); + } + + return ( +
+
+ {( + [ + { id: "home", label: "Overview" }, + { id: "customize", label: "Customize" }, + ] as const + ).map((tab) => ( + + ))} +
+ +
+ +
+ setAvatarName(event.target.value)} + /> + +
+
+ + {activeTab === "home" ? ( + <> + + + + + ) : ( + + )} +
+ ); +} diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx index 17de203..7dc7c67 100644 --- a/components/avatar/avatar-status-card.tsx +++ b/components/avatar/avatar-status-card.tsx @@ -3,13 +3,11 @@ import Link from "next/link"; import { useEffect, useState } from "react"; -import { PixelAvatar } from "@/components/avatar/pixel-avatar"; +import { CharacterLayerPreview } from "@/components/character/character-layer-preview"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, -} from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; +import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; import { defaultAvatarCondition, WELLNESS_SCALE_MAX, @@ -21,7 +19,11 @@ import { } from "@/lib/daily-quiz-storage"; import { routes } from "@/lib/routes"; -export function AvatarStatusCard() { +type AvatarStatusCardProps = { + customization: AvatarCustomization; +}; + +export function AvatarStatusCard({ customization }: AvatarStatusCardProps) { const [condition, setCondition] = useState( defaultAvatarCondition, ); @@ -54,7 +56,21 @@ export function AvatarStatusCard() { return ( - +
+

+ {customization.name} +

+

+ {condition.mood} +

+
+
Energy {condition.energy}/{WELLNESS_SCALE_MAX} diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx index b8c37fb..c83afb1 100644 --- a/components/character/character-creator.tsx +++ b/components/character/character-creator.tsx @@ -7,26 +7,24 @@ import { CharacterColorSliders } from "@/components/character/character-color-sl import { CharacterLayerPreview } from "@/components/character/character-layer-preview"; import { CharacterLayerTabs } from "@/components/character/character-layer-tabs"; import { CharacterPieceSelector } from "@/components/character/character-piece-selector"; -import { - Card, - CardContent, -} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; import { clampHsl } from "@/lib/character/color-utils"; import { CHARACTER_LAYERS, COLOR_PRESETS, DEFAULT_GRAY_COLOR, LAYER_DEFAULT_COLORS, + NONE_VARIANT_ID, buildDefaultVariants, getLayerById, type ColorPreset, } from "@/lib/character/presets"; import type { LayerColorState, LayerVariantState } from "@/lib/character/types"; -function buildInitialColors(): LayerColorState { - return { ...LAYER_DEFAULT_COLORS }; -} - function SectionLabel({ children }: { children: React.ReactNode }) { return (

@@ -35,10 +33,45 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } -export function CharacterCreator() { - const [colors, setColors] = useState(buildInitialColors); +type CharacterCreatorProps = { + initialCustomization?: AvatarCustomization; + name?: string; + onNameChange?: (name: string) => void; + showNameField?: boolean; + saveLabel?: string; + isSaving?: boolean; + ownedVariantIds?: string[]; + onSave?: (customization: AvatarCustomization) => Promise; +}; + +export function CharacterCreator({ + initialCustomization, + name: controlledName, + onNameChange, + ownedVariantIds = [], + showNameField = false, + saveLabel = "Save avatar", + isSaving = false, + onSave, +}: CharacterCreatorProps) { + const [internalName, setInternalName] = useState( + initialCustomization?.name ?? "Pixel Me", + ); + const name = controlledName ?? internalName; + + const setName = (nextName: string) => { + if (onNameChange) { + onNameChange(nextName); + return; + } + + setInternalName(nextName); + }; + const [colors, setColors] = useState( + initialCustomization?.colors ?? { ...LAYER_DEFAULT_COLORS }, + ); const [variants, setVariants] = useState( - buildDefaultVariants, + initialCustomization?.variants ?? buildDefaultVariants(), ); const [activePresetId, setActivePresetId] = useState( DEFAULT_GRAY_COLOR.id, @@ -46,10 +79,17 @@ export function CharacterCreator() { const [activeLayerId, setActiveLayerId] = useState< (typeof CHARACTER_LAYERS)[number]["id"] >("skin"); + const [saveError, setSaveError] = useState(null); const activeLayer = getLayerById(activeLayerId); const activeColor = colors[activeLayerId]; - const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : []; + const ownedSet = new Set(ownedVariantIds); + const pieceVariants = activeLayer.allowVariants + ? activeLayer.variants.filter( + (variant) => + variant.id === NONE_VARIANT_ID || ownedSet.has(variant.id), + ) + : []; const applyPreset = (preset: ColorPreset) => { setActivePresetId(preset.id); @@ -86,8 +126,45 @@ export function CharacterCreator() { })); }; + const buildCustomization = (): AvatarCustomization => ({ + name, + colors, + variants, + customized: true, + equippedItems: initialCustomization?.equippedItems ?? [], + }); + + const handleSave = async () => { + if (!onSave) { + return; + } + + setSaveError(null); + + try { + await onSave(buildCustomization()); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Could not save avatar.", + ); + } + }; + return (
+ {showNameField ? ( +
+ + setName(event.target.value)} + /> +
+ ) : null} + + + {onSave ? ( +
+ + {saveError ? ( +

{saveError}

+ ) : null} +
+ ) : null}
diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx index 069ff34..220e19a 100644 --- a/components/character/character-layer-preview.tsx +++ b/components/character/character-layer-preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; +import { applyEquippedItemsToVariants } from "@/lib/avatar-display"; import { drawTintedSprite, getCachedImage, @@ -17,15 +18,19 @@ import { cn } from "@/lib/utils"; type CharacterLayerPreviewProps = { colors: LayerColorState; variants: LayerVariantState; + equippedItems?: string[]; scale?: number; className?: string; + compact?: boolean; }; export function CharacterLayerPreview({ colors, variants, + equippedItems = [], scale = 10, className, + compact = false, }: CharacterLayerPreviewProps) { const canvasRef = useRef(null); const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 }); @@ -40,8 +45,13 @@ export function CharacterLayerPreview({ const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return; + const displayVariants = applyEquippedItemsToVariants( + variants, + equippedItems, + ); + const activeLayers = CHARACTER_LAYERS.flatMap((layer) => { - const selected = getSelectedVariant(layer.id, variants[layer.id]); + const selected = getSelectedVariant(layer.id, displayVariants[layer.id]); if (!selected) return []; return [{ layer, variant: selected }]; }); @@ -90,12 +100,13 @@ export function CharacterLayerPreview({ return () => { cancelled = true; }; - }, [colors, variants, scale]); + }, [colors, variants, equippedItems, scale]); return (
diff --git a/components/layout/global-bottom-nav.tsx b/components/layout/global-bottom-nav.tsx index a380b89..2b90b15 100644 --- a/components/layout/global-bottom-nav.tsx +++ b/components/layout/global-bottom-nav.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { AppNav } from "@/components/layout/app-nav"; +import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; +import { getOnboardingStatusClient } from "@/lib/onboarding-status"; import { createClient } from "@/lib/supabase/client"; export function GlobalBottomNav() { @@ -16,9 +18,13 @@ export function GlobalBottomNav() { data: { user }, } = await supabase.auth.getUser(); - setShowNav( - !!user && user.user_metadata?.onboarding_completed === true, - ); + if (!user) { + setShowNav(false); + return; + } + + const status = await getOnboardingStatusClient(); + setShowNav(status?.appComplete === true); } void syncNavState(); @@ -29,7 +35,12 @@ export function GlobalBottomNav() { void syncNavState(); }); - return () => subscription.unsubscribe(); + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncNavState); + + return () => { + subscription.unsubscribe(); + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncNavState); + }; }, []); if (!showNav) { diff --git a/components/layout/logged-in-redirect.tsx b/components/layout/logged-in-redirect.tsx index cece124..ca07c5f 100644 --- a/components/layout/logged-in-redirect.tsx +++ b/components/layout/logged-in-redirect.tsx @@ -1,18 +1,12 @@ import { redirect } from "next/navigation"; -import { - getSessionUser, - hasCompletedOnboarding, -} from "@/lib/auth-helpers"; -import { routes } from "@/lib/routes"; +import { getOnboardingRedirectPath, getSessionUser } from "@/lib/auth-helpers"; export async function LoggedInRedirect() { const user = await getSessionUser(); if (user) { - redirect( - hasCompletedOnboarding(user) ? routes.avatar : routes.onboardingQuiz, - ); + redirect(await getOnboardingRedirectPath()); } return null; diff --git a/components/layout/onboarding-auth-gate.tsx b/components/layout/onboarding-auth-gate.tsx index 0c4c075..7501587 100644 --- a/components/layout/onboarding-auth-gate.tsx +++ b/components/layout/onboarding-auth-gate.tsx @@ -1,10 +1,9 @@ import { redirect } from "next/navigation"; -import { - hasCompletedOnboarding, - requireSessionUser, -} from "@/lib/auth-helpers"; +import { requireSessionUser } from "@/lib/auth-helpers"; +import { fetchOnboardingStatusForUser } from "@/lib/onboarding-status"; import { routes } from "@/lib/routes"; +import { createClient } from "@/lib/supabase/server"; type OnboardingAuthGateProps = { children: React.ReactNode; @@ -12,8 +11,10 @@ type OnboardingAuthGateProps = { export async function OnboardingAuthGate({ children }: OnboardingAuthGateProps) { const user = await requireSessionUser(); + const supabase = await createClient(); + const status = await fetchOnboardingStatusForUser(user.id, supabase); - if (hasCompletedOnboarding(user)) { + if (status.appComplete) { redirect(routes.avatar); } diff --git a/components/layout/onboarding-customize-auth-gate.tsx b/components/layout/onboarding-customize-auth-gate.tsx new file mode 100644 index 0000000..888fead --- /dev/null +++ b/components/layout/onboarding-customize-auth-gate.tsx @@ -0,0 +1,30 @@ +import { redirect } from "next/navigation"; + +import { requireSessionUser } from "@/lib/auth-helpers"; +import { + fetchOnboardingStatusForUser, +} from "@/lib/onboarding-status"; +import { routes } from "@/lib/routes"; +import { createClient } from "@/lib/supabase/server"; + +type OnboardingCustomizeAuthGateProps = { + children: React.ReactNode; +}; + +export async function OnboardingCustomizeAuthGate({ + children, +}: OnboardingCustomizeAuthGateProps) { + const user = await requireSessionUser(); + const supabase = await createClient(); + const status = await fetchOnboardingStatusForUser(user.id, supabase); + + if (status.appComplete) { + redirect(routes.avatar); + } + + if (!status.quizComplete) { + redirect(routes.onboardingQuiz); + } + + return children; +} diff --git a/components/layout/onboarding-quiz-auth-gate.tsx b/components/layout/onboarding-quiz-auth-gate.tsx new file mode 100644 index 0000000..7f251db --- /dev/null +++ b/components/layout/onboarding-quiz-auth-gate.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; + +import { requireSessionUser } from "@/lib/auth-helpers"; +import { + fetchOnboardingStatusForUser, + getRedirectPathFromStatus, +} from "@/lib/onboarding-status"; +import { createClient } from "@/lib/supabase/server"; + +type OnboardingQuizAuthGateProps = { + children: React.ReactNode; +}; + +export async function OnboardingQuizAuthGate({ + children, +}: OnboardingQuizAuthGateProps) { + const user = await requireSessionUser(); + const supabase = await createClient(); + const status = await fetchOnboardingStatusForUser(user.id, supabase); + + if (status.quizComplete) { + redirect(getRedirectPathFromStatus(status)); + } + + return children; +} diff --git a/components/onboarding/onboarding-customize-form.tsx b/components/onboarding/onboarding-customize-form.tsx new file mode 100644 index 0000000..0c18c44 --- /dev/null +++ b/components/onboarding/onboarding-customize-form.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { CharacterCreator } from "@/components/character/character-creator"; +import { useToast } from "@/components/ui/toast-provider"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + defaultAvatarCustomization, + saveAvatarCustomization, + type AvatarCustomization, +} from "@/lib/avatar-customization-storage"; +import { routes } from "@/lib/routes"; + +export function OnboardingCustomizeForm() { + const router = useRouter(); + const { toast } = useToast(); + const [avatarName, setAvatarName] = useState(defaultAvatarCustomization.name); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSave = async (customization: AvatarCustomization) => { + setIsSubmitting(true); + + try { + await saveAvatarCustomization( + { + ...customization, + name: avatarName.trim() || customization.name, + }, + { completeOnboarding: true }, + ); + toast("Avatar confirmed. Welcome to Habit Pet!", "success"); + router.push(routes.avatar); + router.refresh(); + } catch (saveError) { + toast( + saveError instanceof Error + ? saveError.message + : "Could not save your avatar.", + "error", + ); + throw saveError; + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+

+ Step 2 of 2 +

+

+ Create your Habit Pet +

+

+ Customize your pet's look, give them a name, and confirm before + entering the app. +

+
+ + + + Avatar customization + + Pick styles and colors for each layer. You can change these later + from the Pet tab. + + + + + + +
+
+ ); +} diff --git a/components/onboarding/onboarding-quiz-form.tsx b/components/onboarding/onboarding-quiz-form.tsx new file mode 100644 index 0000000..fc1dd1b --- /dev/null +++ b/components/onboarding/onboarding-quiz-form.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { CharacterLayerPreview } from "@/components/character/character-layer-preview"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { defaultAvatarCustomization } from "@/lib/avatar-customization-storage"; +import { + focusTopicOptions, + saveOnboardingPreferences, +} from "@/lib/profile-preferences-storage"; +import { routes } from "@/lib/routes"; + +export function OnboardingQuizForm() { + const router = useRouter(); + const [focusTopic, setFocusTopic] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleContinue = async () => { + if (!focusTopic) { + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await saveOnboardingPreferences(focusTopic); + router.push(routes.onboardingCustomize); + router.refresh(); + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : "Could not save onboarding answers.", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ +
+

+ Step 1 of 2 +

+

+ Welcome to Habit Pet +

+

+ Choose a focus area, then design your pet in the next step. +

+
+
+ + + + + What habit do you most want to build first? + + + This helps personalize your daily tasks. + + + + {focusTopicOptions.map((option) => { + const selected = focusTopic === option; + + return ( + + ); + })} + + {error ?

{error}

: null} + + +
+
+
+
+ ); +} diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index 5a74fdf..5691a01 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; +import { TintedSpriteIcon } from "@/components/character/tinted-sprite-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -11,33 +12,46 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useToast } from "@/components/ui/toast-provider"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; +import { DEFAULT_GRAY_COLOR } from "@/lib/character/presets"; import { - getShopState, + equipShopItem, + getShopInventory, purchaseShopItem, - type ShopItem, + unequipShopItem, } from "@/lib/shop-storage"; +import { + getLayerLabel, + type ShopItemRecord, + type ShopLayerId, +} from "@/lib/shop-catalog"; -function formatItemType(type: string) { - return type.charAt(0).toUpperCase() + type.slice(1); -} +const SHOP_LAYER_ORDER: ShopLayerId[] = [ + "head", + "torso", + "pants", + "shoes", + "eyes", +]; export function ShopContent() { - const [coins, setCoins] = useState(null); - const [items, setItems] = useState([]); + const { toast } = useToast(); + const [items, setItems] = useState([]); const [ownedItemIds, setOwnedItemIds] = useState([]); + const [equippedItems, setEquippedItems] = useState([]); + const [coins, setCoins] = useState(null); const [error, setError] = useState(null); - const [purchaseError, setPurchaseError] = useState(null); - const [isLoading, setIsLoading] = useState(true); const [pendingItemId, setPendingItemId] = useState(null); const refreshShop = useCallback(async () => { try { setError(null); - const state = await getShopState(); - setCoins(state.coins); - setItems(state.items); - setOwnedItemIds(state.ownedItemIds); + const inventory = await getShopInventory(); + setItems(inventory.items); + setOwnedItemIds(inventory.ownedItemIds); + setEquippedItems(inventory.equippedItems); + setCoins(inventory.coins); } catch (refreshError) { setError( refreshError instanceof Error @@ -45,10 +59,6 @@ export function ShopContent() { : "Could not load the shop.", ); setCoins(0); - setItems([]); - setOwnedItemIds([]); - } finally { - setIsLoading(false); } }, []); @@ -61,30 +71,56 @@ export function ShopContent() { }; }, [refreshShop]); - const handlePurchase = async (item: ShopItem) => { - if (pendingItemId) { - return; - } + const handlePurchase = async (itemId: string) => { + setPendingItemId(itemId); try { - setPurchaseError(null); - setPendingItemId(item.id); - const remainingCoins = await purchaseShopItem(item.id); - setCoins(remainingCoins); - setOwnedItemIds((current) => - current.includes(item.id) ? current : [...current, item.id], - ); - } catch (purchaseFailure) { - setPurchaseError( - purchaseFailure instanceof Error - ? purchaseFailure.message - : "Could not complete purchase.", + const item = await purchaseShopItem(itemId); + toast(`Purchased ${item.name}.`, "success"); + await refreshShop(); + } catch (purchaseError) { + toast( + purchaseError instanceof Error + ? purchaseError.message + : "Purchase failed.", + "error", ); } finally { setPendingItemId(null); } }; + const handleEquipToggle = async (item: ShopItemRecord) => { + setPendingItemId(item.id); + + try { + const isEquipped = equippedItems.includes(item.id); + + if (isEquipped) { + await unequipShopItem(item.id); + toast(`Unequipped ${item.name}.`, "default"); + } else { + await equipShopItem(item.id); + toast(`Equipped ${item.name}.`, "success"); + } + + await refreshShop(); + } catch (equipError) { + toast( + equipError instanceof Error ? equipError.message : "Could not equip item.", + "error", + ); + } finally { + setPendingItemId(null); + } + }; + + const groupedItems = SHOP_LAYER_ORDER.map((layerId) => ({ + layerId, + label: getLayerLabel(layerId), + items: items.filter((item) => item.type === layerId), + })).filter((group) => group.items.length > 0); + return ( <>
@@ -95,63 +131,76 @@ export function ShopContent() {
{error ?

{error}

: null} - {purchaseError ? ( -

{purchaseError}

- ) : null} - {isLoading ? ( -

Loading shop items...

- ) : items.length === 0 ? ( -

- No items in the shop yet. Add rows to the shop_items table in - Supabase. -

- ) : ( -
- {items.map((item) => { - const isOwned = ownedItemIds.includes(item.id); - const canAfford = coins !== null && coins >= item.price; - const isPending = pendingItemId === item.id; +
+ {groupedItems.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((item) => { + const owned = ownedItemIds.includes(item.id); + const equipped = equippedItems.includes(item.id); + const canAfford = coins !== null && coins >= item.price; + const isPending = pendingItemId === item.id; - return ( - - -
-
- {item.name} - - {formatItemType(item.type)} - -
- {item.price} pts -
-
- - - -
- ); - })} -
- )} + return ( + + +
+
+
+ +
+
+ {item.name} + {item.id} +
+
+ {item.price} pts +
+
+ + {!owned ? ( + + ) : ( + + )} + +
+ ); + })} +
+ + ))} +
); } diff --git a/components/ui/toast-provider.tsx b/components/ui/toast-provider.tsx new file mode 100644 index 0000000..ba69e0a --- /dev/null +++ b/components/ui/toast-provider.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { cn } from "@/lib/utils"; + +type ToastVariant = "default" | "success" | "error"; + +type ToastMessage = { + id: number; + title: string; + variant: ToastVariant; +}; + +type ToastContextValue = { + toast: (title: string, variant?: ToastVariant) => void; +}; + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [messages, setMessages] = useState([]); + + const toast = useCallback((title: string, variant: ToastVariant = "default") => { + const id = Date.now() + Math.random(); + + setMessages((current) => [...current, { id, title, variant }]); + + window.setTimeout(() => { + setMessages((current) => current.filter((message) => message.id !== id)); + }, 3200); + }, []); + + const value = useMemo(() => ({ toast }), [toast]); + + return ( + + {children} +
+ {messages.map((message) => ( +
+ {message.title} +
+ ))} +
+
+ ); +} + +export function useToast() { + const context = useContext(ToastContext); + + if (!context) { + throw new Error("useToast must be used within ToastProvider."); + } + + return context; +} diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts index 604dabc..7148383 100644 --- a/lib/auth-helpers.ts +++ b/lib/auth-helpers.ts @@ -1,12 +1,11 @@ import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { + fetchOnboardingStatusForUser, + getRedirectPathFromStatus, +} from "@/lib/onboarding-status"; import { routes } from "@/lib/routes"; -import type { User } from "@supabase/supabase-js"; - -export function hasCompletedOnboarding(user: User) { - return user.user_metadata?.onboarding_completed === true; -} +import { createClient } from "@/lib/supabase/server"; export async function getSessionUser() { const supabase = await createClient(); @@ -32,10 +31,39 @@ export async function requireSessionUser() { return user; } +export async function getOnboardingStatusForCurrentUser() { + const user = await requireSessionUser(); + const supabase = await createClient(); + return fetchOnboardingStatusForUser(user.id, supabase); +} + +export async function getOnboardingRedirectPath() { + const status = await getOnboardingStatusForCurrentUser(); + return getRedirectPathFromStatus(status); +} + export async function requireOnboardingComplete() { const user = await requireSessionUser(); + const supabase = await createClient(); + const status = await fetchOnboardingStatusForUser(user.id, supabase); - if (!hasCompletedOnboarding(user)) { + if (!status.appComplete) { + redirect(getRedirectPathFromStatus(status)); + } + + return user; +} + +export async function requireOnboardingQuizComplete() { + const user = await requireSessionUser(); + const supabase = await createClient(); + const status = await fetchOnboardingStatusForUser(user.id, supabase); + + if (status.appComplete) { + redirect(routes.avatar); + } + + if (!status.quizComplete) { redirect(routes.onboardingQuiz); } diff --git a/lib/avatar-customization-storage.ts b/lib/avatar-customization-storage.ts new file mode 100644 index 0000000..86ab86e --- /dev/null +++ b/lib/avatar-customization-storage.ts @@ -0,0 +1,270 @@ +import { notifyHabitPetDataUpdated } from "@/lib/app-events"; +import { + hexToHsl, + hslToHex, + type HSL, +} from "@/lib/character/color-utils"; +import { + assertValidVariants, + normalizeVariantId, + normalizeVariants, +} from "@/lib/character/variant-validation"; +import { + buildDefaultVariants, + DEFAULT_GRAY_COLOR, + LAYER_DEFAULT_COLORS, + NONE_VARIANT_ID, +} from "@/lib/character/presets"; +import type { LayerColorState, LayerVariantState } from "@/lib/character/types"; +import { createClient } from "@/lib/supabase/client"; + +export type AvatarCustomization = { + name: string; + colors: LayerColorState; + variants: LayerVariantState; + customized: boolean; + equippedItems: string[]; +}; + +type AvatarStateRow = { + avatar_name: string | null; + skin_color: string | null; + pants_style: string | null; + pants_color: string | null; + shoe_style: string | null; + shoe_color: string | null; + torso_style: string | null; + torso_color: string | null; + eye_type: string | null; + eye_color: string | null; + head_style: string | null; + head_color: string | null; + avatar_customized: boolean | null; + equipped_items: string[] | null; +}; + +export const DEFAULT_AVATAR_NAME = "Pixel Me"; +const DEFAULT_HEX = DEFAULT_GRAY_COLOR.hex; +const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/; + +export const defaultAvatarCustomization: AvatarCustomization = { + name: DEFAULT_AVATAR_NAME, + colors: { ...LAYER_DEFAULT_COLORS }, + variants: buildDefaultVariants(), + customized: false, + equippedItems: [], +}; + +const avatarSelectFields = + "avatar_name, skin_color, pants_style, pants_color, shoe_style, shoe_color, torso_style, torso_color, eye_type, eye_color, head_style, head_color, avatar_customized, equipped_items"; + +function colorFromHex(hex: string | null | undefined, fallback: HSL): HSL { + if (!hex?.trim() || !HEX_COLOR_PATTERN.test(hex)) { + return { ...fallback }; + } + + try { + return hexToHsl(hex); + } catch { + return { ...fallback }; + } +} + +function normalizeHexColor(color: HSL, fallback: HSL) { + const hex = hslToHex(color); + + if (!HEX_COLOR_PATTERN.test(hex)) { + return hslToHex(fallback); + } + + return hex.toLowerCase(); +} + +export function mapRowToAvatarCustomization( + row: AvatarStateRow | null | undefined, +): AvatarCustomization { + if (!row) { + return { ...defaultAvatarCustomization }; + } + + const defaults = defaultAvatarCustomization; + + return { + name: row.avatar_name?.trim() || DEFAULT_AVATAR_NAME, + colors: { + skin: colorFromHex(row.skin_color, defaults.colors.skin), + pants: colorFromHex(row.pants_color, defaults.colors.pants), + shoes: colorFromHex(row.shoe_color, defaults.colors.shoes), + torso: colorFromHex(row.torso_color, defaults.colors.torso), + eyes: colorFromHex(row.eye_color, defaults.colors.eyes), + head: colorFromHex(row.head_color, defaults.colors.head), + }, + variants: { + skin: "skin-1", + pants: normalizeVariantId("pants", row.pants_style), + shoes: normalizeVariantId("shoes", row.shoe_style), + torso: normalizeVariantId("torso", row.torso_style), + eyes: normalizeVariantId("eyes", row.eye_type), + head: normalizeVariantId("head", row.head_style), + }, + customized: row.avatar_customized === true, + equippedItems: row.equipped_items ?? [], + }; +} + +export function mapAvatarCustomizationToRow(customization: AvatarCustomization) { + const normalizedVariants = normalizeVariants(customization.variants); + assertValidVariants(normalizedVariants); + + const { colors, name } = customization; + const defaults = defaultAvatarCustomization.colors; + + return { + avatar_name: name.trim() || DEFAULT_AVATAR_NAME, + skin_color: normalizeHexColor(colors.skin, defaults.skin), + pants_style: normalizedVariants.pants, + pants_color: normalizeHexColor(colors.pants, defaults.pants), + shoe_style: normalizedVariants.shoes, + shoe_color: normalizeHexColor(colors.shoes, defaults.shoes), + torso_style: normalizedVariants.torso, + torso_color: normalizeHexColor(colors.torso, defaults.torso), + eye_type: normalizedVariants.eyes, + eye_color: normalizeHexColor(colors.eyes, defaults.eyes), + head_style: normalizedVariants.head, + head_color: normalizeHexColor(colors.head, defaults.head), + avatar_customized: true, + updated_at: new Date().toISOString(), + }; +} + +async function getAuthenticatedUserId() { + const supabase = createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + return null; + } + + return user.id; +} + +export async function getAvatarCustomization(): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return { ...defaultAvatarCustomization }; + } + + const supabase = createClient(); + const { data, error } = await supabase + .from("avatar_state") + .select(avatarSelectFields) + .eq("user_id", userId) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + if (!data) { + const { error: insertError } = await supabase.from("avatar_state").insert({ + user_id: userId, + avatar_name: DEFAULT_AVATAR_NAME, + skin_color: DEFAULT_HEX, + pants_style: NONE_VARIANT_ID, + pants_color: DEFAULT_HEX, + shoe_style: NONE_VARIANT_ID, + shoe_color: DEFAULT_HEX, + torso_style: NONE_VARIANT_ID, + torso_color: DEFAULT_HEX, + eye_type: NONE_VARIANT_ID, + eye_color: DEFAULT_HEX, + head_style: NONE_VARIANT_ID, + head_color: DEFAULT_HEX, + }); + + if (insertError) { + throw new Error(insertError.message); + } + + return { ...defaultAvatarCustomization }; + } + + return mapRowToAvatarCustomization(data as AvatarStateRow); +} + +export async function saveAvatarCustomization( + customization: AvatarCustomization, + options?: { completeOnboarding?: boolean }, +) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to save your avatar."); + } + + const supabase = createClient(); + const payload = mapAvatarCustomizationToRow(customization); + + const { error } = await supabase + .from("avatar_state") + .update(payload) + .eq("user_id", userId); + + if (error) { + throw new Error(error.message); + } + + if (options?.completeOnboarding) { + const { error: profileError } = await supabase + .from("profiles") + .update({ onboarding_complete: true }) + .eq("id", userId); + + if (profileError) { + throw new Error(profileError.message); + } + + const { error: authError } = await supabase.auth.updateUser({ + data: { + onboarding_completed: true, + onboarding_quiz_completed: true, + }, + }); + + if (authError) { + throw new Error(authError.message); + } + } + + notifyHabitPetDataUpdated(); + return customization; +} + +export async function saveAvatarName(name: string) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to rename your avatar."); + } + + const trimmed = name.trim() || DEFAULT_AVATAR_NAME; + const supabase = createClient(); + const { error } = await supabase + .from("avatar_state") + .update({ + avatar_name: trimmed, + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); + return trimmed; +} diff --git a/lib/avatar-display.ts b/lib/avatar-display.ts new file mode 100644 index 0000000..1795371 --- /dev/null +++ b/lib/avatar-display.ts @@ -0,0 +1,37 @@ +import type { LayerVariantState } from "@/lib/character/types"; +import { parseShopStyleItem, parseVariantId } from "@/lib/shop-catalog"; + +/** Apply equipped shop styles (variant ids like head-1) onto saved customization. */ +export function applyEquippedItemsToVariants( + variants: LayerVariantState, + equippedItems: string[], +): LayerVariantState { + const next = { ...variants }; + + for (const itemId of equippedItems) { + const style = parseVariantId(itemId); + + if (style) { + next[style.layerId] = style.variantId; + } + } + + return next; +} + +export function applyEquippedShopRecordsToVariants( + variants: LayerVariantState, + equippedItems: Array<{ id: string; type: string }>, +): LayerVariantState { + const next = { ...variants }; + + for (const item of equippedItems) { + const style = parseShopStyleItem(item.id, item.type); + + if (style) { + next[style.layerId] = style.variantId; + } + } + + return next; +} diff --git a/lib/character/color-utils.ts b/lib/character/color-utils.ts index ae615a1..135ae95 100644 --- a/lib/character/color-utils.ts +++ b/lib/character/color-utils.ts @@ -120,6 +120,16 @@ export function rgbToHex({ r, g, b }: RGB): string { .join("")}`; } +export function hslToHex(hsl: HSL): string { + const rgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(rgb); +} + +export function hexToHsl(hex: string): HSL { + const rgb = hexToRgb(hex); + return rgbToHsl(rgb.r, rgb.g, rgb.b); +} + /** Tint a monochrome sprite by mapping pixel brightness to the target color. */ export function tintImageData(imageData: ImageData, color: RGB): void { const { data } = imageData; diff --git a/lib/character/variant-validation.ts b/lib/character/variant-validation.ts new file mode 100644 index 0000000..6b98f81 --- /dev/null +++ b/lib/character/variant-validation.ts @@ -0,0 +1,79 @@ +import type { CharacterLayerId } from "./presets"; +import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "./presets"; +import type { LayerVariantState } from "./types"; + +const STYLE_FIELD_BY_LAYER: Record< + Exclude, + keyof LayerVariantState +> = { + pants: "pants", + shoes: "shoes", + torso: "torso", + eyes: "eyes", + head: "head", +}; + +const VALID_VARIANT_IDS: Record = { + skin: ["skin-1"], + pants: ["none", "pants-1", "pants-2", "pants-3"], + shoes: ["none", "shoes-1", "shoes-2", "shoes-3"], + torso: ["none", "torso-1", "torso-2", "torso-3", "torso-4"], + eyes: ["none", "eyes-1", "eyes-2"], + head: [ + "none", + "head-1", + "head-2", + "head-3", + "head-4", + "head-5", + "head-6", + "head-7", + ], +}; + +export function getValidVariantIds(layerId: CharacterLayerId): readonly string[] { + return VALID_VARIANT_IDS[layerId]; +} + +export function isValidVariantId(layerId: CharacterLayerId, variantId: string) { + return VALID_VARIANT_IDS[layerId].includes(variantId); +} + +export function normalizeVariantId( + layerId: CharacterLayerId, + variantId: string | null | undefined, +): string { + if (layerId === "skin") { + return "skin-1"; + } + + const trimmed = variantId?.trim(); + + if (trimmed && isValidVariantId(layerId, trimmed)) { + return trimmed; + } + + return NONE_VARIANT_ID; +} + +export function normalizeVariants( + variants: Partial | null | undefined, +): LayerVariantState { + return CHARACTER_LAYERS.reduce((state, layer) => { + state[layer.id] = normalizeVariantId( + layer.id, + variants?.[layer.id as keyof LayerVariantState], + ); + return state; + }, {} as LayerVariantState); +} + +export function assertValidVariants(variants: LayerVariantState) { + for (const layer of CHARACTER_LAYERS) { + if (!isValidVariantId(layer.id, variants[layer.id])) { + throw new Error(`Invalid ${layer.label.toLowerCase()} style selected.`); + } + } +} + +export { STYLE_FIELD_BY_LAYER, VALID_VARIANT_IDS }; diff --git a/lib/onboarding-status.ts b/lib/onboarding-status.ts new file mode 100644 index 0000000..15b6d3e --- /dev/null +++ b/lib/onboarding-status.ts @@ -0,0 +1,93 @@ +import { routes } from "@/lib/routes"; +import { createClient as createBrowserClient } from "@/lib/supabase/client"; +import { createClient as createServerClient } from "@/lib/supabase/server"; + +export type OnboardingStatus = { + quizComplete: boolean; + avatarComplete: boolean; + appComplete: boolean; +}; + +type StatusRow = { + onboarding_quiz_complete: boolean | null; + onboarding_complete: boolean | null; +}; + +type AvatarRow = { + avatar_customized: boolean | null; +}; + +export function getRedirectPathFromStatus(status: OnboardingStatus) { + if (status.appComplete) { + return routes.avatar; + } + + if (status.quizComplete) { + return routes.onboardingCustomize; + } + + return routes.onboardingQuiz; +} + +export function mapRowsToOnboardingStatus( + profile: StatusRow | null | undefined, + avatar: AvatarRow | null | undefined, +): OnboardingStatus { + return { + quizComplete: profile?.onboarding_quiz_complete === true, + avatarComplete: avatar?.avatar_customized === true, + appComplete: profile?.onboarding_complete === true, + }; +} + +export async function fetchOnboardingStatusForUser( + userId: string, + supabase: Awaited>, +): Promise { + const [{ data: profile }, { data: avatar }] = await Promise.all([ + supabase + .from("profiles") + .select("onboarding_quiz_complete, onboarding_complete") + .eq("id", userId) + .maybeSingle(), + supabase + .from("avatar_state") + .select("avatar_customized") + .eq("user_id", userId) + .maybeSingle(), + ]); + + return mapRowsToOnboardingStatus( + profile as StatusRow | null, + avatar as AvatarRow | null, + ); +} + +export async function getOnboardingStatusClient(): Promise { + const supabase = createBrowserClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return null; + } + + const [{ data: profile }, { data: avatar }] = await Promise.all([ + supabase + .from("profiles") + .select("onboarding_quiz_complete, onboarding_complete") + .eq("id", user.id) + .maybeSingle(), + supabase + .from("avatar_state") + .select("avatar_customized") + .eq("user_id", user.id) + .maybeSingle(), + ]); + + return mapRowsToOnboardingStatus( + profile as StatusRow | null, + avatar as AvatarRow | null, + ); +} diff --git a/lib/profile-preferences-storage.ts b/lib/profile-preferences-storage.ts index 39f95ae..6e9e927 100644 --- a/lib/profile-preferences-storage.ts +++ b/lib/profile-preferences-storage.ts @@ -167,7 +167,10 @@ function getOnboardingAnswersFromMetadata( async function saveProfileRow( userId: string, preferences: ProfilePreferences, - options?: { markOnboardingComplete?: boolean }, + options?: { + markOnboardingComplete?: boolean; + markQuizComplete?: boolean; + }, ) { const supabase = createClient(); const serialized = serializeFocusTopicsField(preferences); @@ -175,9 +178,10 @@ async function saveProfileRow( serialized, [serialized], ]; - const onboardingFields = options?.markOnboardingComplete - ? { onboarding_complete: true } - : {}; + const onboardingFields = { + ...(options?.markOnboardingComplete ? { onboarding_complete: true } : {}), + ...(options?.markQuizComplete ? { onboarding_quiz_complete: true } : {}), + }; let lastError: string | null = null; @@ -296,10 +300,7 @@ export async function saveProfilePreferences(preferences: ProfilePreferences) { return normalizedPreferences; } -export async function saveOnboardingPreferences( - focusTopic: string, - avatarVibe: string, -) { +export async function saveOnboardingPreferences(focusTopic: string) { const userId = await getAuthenticatedUserId(); if (!userId) { @@ -307,16 +308,15 @@ export async function saveOnboardingPreferences( } const supabase = createClient(); - const preferences = normalizePreferences({ focusTopic, avatarVibe }); + const preferences = normalizePreferences({ focusTopic }); - await saveProfileRow(userId, preferences, { markOnboardingComplete: true }); + await saveProfileRow(userId, preferences, { markQuizComplete: true }); const { error: authError } = await supabase.auth.updateUser({ data: { - onboarding_completed: true, + onboarding_quiz_completed: true, onboarding_answers: { focus: preferences.focusTopic, - avatarVibe: preferences.avatarVibe, }, }, }); diff --git a/lib/routes.ts b/lib/routes.ts index 905eefd..f0575e1 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -4,6 +4,7 @@ export const routes = { signUp: "/auth/sign-up", signUpSuccess: "/auth/sign-up-success", onboardingQuiz: "/onboarding/quiz", + onboardingCustomize: "/onboarding/customize", avatar: "/avatar", habits: "/habits", dailyQuiz: "/daily/quiz", @@ -27,6 +28,10 @@ export function isAuthPath(pathname: string) { return pathname.startsWith("/auth"); } +export function isOnboardingPath(pathname: string) { + return pathname.startsWith("/onboarding"); +} + export function isPublicPath(pathname: string) { return pathname === routes.home || isAuthPath(pathname); } diff --git a/lib/shop-catalog.ts b/lib/shop-catalog.ts new file mode 100644 index 0000000..cc9d419 --- /dev/null +++ b/lib/shop-catalog.ts @@ -0,0 +1,87 @@ +import type { CharacterLayerId } from "@/lib/character/presets"; +import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets"; + +export type ShopLayerId = Exclude; + +export type ShopItemRecord = { + id: string; + name: string; + type: ShopLayerId; + price: number; + image_path: string; +}; + +export type ShopStyleItem = { + layerId: ShopLayerId; + variantId: string; +}; + +const SHOP_LAYER_IDS = new Set([ + "pants", + "shoes", + "torso", + "eyes", + "head", +]); + +export function isShopLayerId(value: string): value is ShopLayerId { + return SHOP_LAYER_IDS.has(value as ShopLayerId); +} + +/** Parse a character variant id (e.g. head-1) into layer + variant. */ +export function parseVariantId(variantId: string): ShopStyleItem | null { + for (const layerId of SHOP_LAYER_IDS) { + if (variantId.startsWith(`${layerId}-`)) { + return { layerId, variantId }; + } + } + + return null; +} + +export function parseShopStyleItem( + itemId: string, + itemType: string, +): ShopStyleItem | null { + if (!isShopLayerId(itemType) || !itemId.startsWith(`${itemType}-`)) { + return null; + } + + return { layerId: itemType, variantId: itemId }; +} + +export function getShopCatalogFromPresets(): ShopItemRecord[] { + return CHARACTER_LAYERS.flatMap((layer) => { + if (layer.id === "skin") { + return []; + } + + return layer.variants + .filter((variant) => variant.id !== NONE_VARIANT_ID) + .map((variant) => ({ + id: variant.id, + name: variant.label, + type: layer.id as ShopLayerId, + price: getDefaultPriceForLayer(layer.id as ShopLayerId, variant.id), + image_path: variant.src, + })); + }); +} + +function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) { + const number = Number.parseInt(variantId.split("-").pop() ?? "1", 10); + + const baseByLayer: Record = { + pants: 20, + shoes: 20, + torso: 25, + eyes: 15, + head: 30, + }; + + return baseByLayer[layerId] + (number - 1) * 5; +} + +export function getLayerLabel(layerId: ShopLayerId) { + return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId; +} diff --git a/lib/shop-storage.ts b/lib/shop-storage.ts index 598a805..67ea7e1 100644 --- a/lib/shop-storage.ts +++ b/lib/shop-storage.ts @@ -1,45 +1,11 @@ import { notifyHabitPetDataUpdated } from "@/lib/app-events"; -import { getCoins, spendCoins, adjustCoins } from "@/lib/avatar-progression-storage"; +import { adjustCoins, getCoins } from "@/lib/avatar-progression-storage"; +import { + parseVariantId, + type ShopItemRecord, +} from "@/lib/shop-catalog"; import { createClient } from "@/lib/supabase/client"; -export type ShopItem = { - id: string; - name: string; - type: string; - price: number; -}; - -type ShopItemRow = { - id: string; - name: string; - type: string; -}; - -function getPriceForType(type: string) { - switch (type.toLowerCase()) { - case "accessory": - return 30; - case "background": - return 40; - case "outfit": - return 50; - case "consumable": - case "snack": - return 25; - default: - return 50; - } -} - -function mapShopItemRow(row: ShopItemRow): ShopItem { - return { - id: row.id, - name: row.name, - type: row.type, - price: getPriceForType(row.type), - }; -} - async function getAuthenticatedUserId() { const supabase = createClient(); const { @@ -54,19 +20,18 @@ async function getAuthenticatedUserId() { return user.id; } -export async function getShopItems(): Promise { +export async function getShopItems(): Promise { const supabase = createClient(); const { data, error } = await supabase .from("shop_items") - .select("id, name, type") - .order("type", { ascending: true }) - .order("name", { ascending: true }); + .select("id, name, type, price, image_path") + .order("price", { ascending: true }); if (error) { throw new Error(error.message); } - return ((data ?? []) as ShopItemRow[]).map(mapShopItemRow); + return (data ?? []) as ShopItemRecord[]; } export async function getOwnedItemIds(): Promise { @@ -89,7 +54,28 @@ export async function getOwnedItemIds(): Promise { return (data ?? []).map((row) => row.item_id as string); } -export async function purchaseShopItem(itemId: string): Promise { +export async function getEquippedItems(): Promise { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + return []; + } + + const supabase = createClient(); + const { data, error } = await supabase + .from("avatar_state") + .select("equipped_items") + .eq("user_id", userId) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + return (data?.equipped_items as string[] | null) ?? []; +} + +export async function purchaseShopItem(itemId: string) { const userId = await getAuthenticatedUserId(); if (!userId) { @@ -97,9 +83,9 @@ export async function purchaseShopItem(itemId: string): Promise { } const supabase = createClient(); - const { data: itemRow, error: itemError } = await supabase + const { data: item, error: itemError } = await supabase .from("shop_items") - .select("id, name, type") + .select("id, name, type, price, image_path") .eq("id", itemId) .maybeSingle(); @@ -107,28 +93,23 @@ export async function purchaseShopItem(itemId: string): Promise { throw new Error(itemError.message); } - if (!itemRow) { - throw new Error("Item not found."); + if (!item) { + throw new Error("That shop item is no longer available."); } - const item = mapShopItemRow(itemRow as ShopItemRow); + const owned = await getOwnedItemIds(); - const { data: existingPurchase, error: existingError } = await supabase - .from("user_items") - .select("item_id") - .eq("user_id", userId) - .eq("item_id", itemId) - .maybeSingle(); - - if (existingError) { - throw new Error(existingError.message); - } - - if (existingPurchase) { + if (owned.includes(itemId)) { throw new Error("You already own this item."); } - const remainingCoins = await spendCoins(item.price); + const coins = await getCoins(); + + if (coins < item.price) { + throw new Error("Not enough points for this purchase."); + } + + await adjustCoins(-item.price); const { error: purchaseError } = await supabase.from("user_items").insert({ user_id: userId, @@ -141,19 +122,106 @@ export async function purchaseShopItem(itemId: string): Promise { } notifyHabitPetDataUpdated(); - return remainingCoins; + return { + ...(item as ShopItemRecord), + type: item.type as ShopItemRecord["type"], + }; } -export async function getShopState(): Promise<{ - coins: number; - items: ShopItem[]; - ownedItemIds: string[]; -}> { - const [coins, items, ownedItemIds] = await Promise.all([ - getCoins(), +export async function equipShopItem(itemId: string) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to equip items."); + } + + const owned = await getOwnedItemIds(); + + if (!owned.includes(itemId)) { + throw new Error("Purchase this item before equipping it."); + } + + const supabase = createClient(); + const { data: item, error: itemError } = await supabase + .from("shop_items") + .select("id, type") + .eq("id", itemId) + .maybeSingle(); + + if (itemError || !item) { + throw new Error("Could not find that shop item."); + } + + const current = await getEquippedItems(); + const nextStyle = parseVariantId(itemId); + + const withoutSameLayer = current.filter((equippedId) => { + if (equippedId === itemId) { + return false; + } + + const equippedStyle = parseVariantId(equippedId); + + if (!nextStyle || !equippedStyle) { + return true; + } + + return equippedStyle.layerId !== nextStyle.layerId; + }); + const next = [...withoutSameLayer, itemId]; + + const { error } = await supabase + .from("avatar_state") + .update({ + equipped_items: next, + equipped_item: itemId, + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); + return next; +} + +export async function unequipShopItem(itemId: string) { + const userId = await getAuthenticatedUserId(); + + if (!userId) { + throw new Error("You must be signed in to unequip items."); + } + + const current = await getEquippedItems(); + const next = current.filter((id) => id !== itemId); + + const supabase = createClient(); + const { error } = await supabase + .from("avatar_state") + .update({ + equipped_items: next, + equipped_item: next[0] ?? "none", + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId); + + if (error) { + throw new Error(error.message); + } + + notifyHabitPetDataUpdated(); + return next; +} + +export async function getShopInventory() { + const [items, ownedItemIds, equippedItems, coins] = await Promise.all([ getShopItems(), getOwnedItemIds(), + getEquippedItems(), + getCoins(), ]); - return { coins, items, ownedItemIds }; + return { items, ownedItemIds, equippedItems, coins }; } diff --git a/package-lock.json b/package-lock.json index ba38b58..8fd3459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-slot": "^1.2.2", - "@supabase/ssr": "latest", - "@supabase/supabase-js": "latest", + "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.106.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.511.0", @@ -29,7 +29,10 @@ "autoprefixer": "^10.4.20", "eslint": "^9", "eslint-config-next": "15.3.1", + "pg": "^8.16.0", + "pngjs": "^7.0.0", "postcss": "^8", + "supabase": "^2.101.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "typescript": "^5", @@ -2952,6 +2955,130 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.101.0.tgz", + "integrity": "sha512-SoWYzu2CIE+rADWsZH6H0YZYF8nV2YMelZLbjAqzahb5RRgFOTMWGhKCUj6g4KlNvocUnU19JObUJ7oCx/9aHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.101.0.tgz", + "integrity": "sha512-rPES03BF9KYkirq3X1Yll+T+eORueRvC858Yp2+2aQMvB4qxi6k+WI+ilCQ7NGzOJ3G0jl/ZG/KsSjZIa4AJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.101.0.tgz", + "integrity": "sha512-E+hN1GRIFEi+U1Jf4Omw/gjwpSRTpJ5jai58tQana15AaXt3GCoG7z2YzWVnzD/mvAubTqA7SLHsV+A2lcdTGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.101.0.tgz", + "integrity": "sha512-B5JFFKKpJLLMwn8vWUB7j5euZPytiDOLo9WYXp0mshg45OaFKjRcDImSzBzx4k0W8MK2x75rFEURca90Ic60Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.101.0.tgz", + "integrity": "sha512-7iU9uA+8pbmW5uz+yGxU0fM83a+8SH/gtKmSSRZrOu9vIGcugg9gLbN9GlWkG5P7I7wEjnoVKb7Jzea6onSeQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.101.0.tgz", + "integrity": "sha512-pr9Ar1/aVaQVqjNSfwRgXHWJznQTIaxblGO+hKIl9k55Ajahiq8GConCwx6DjX1Wa9VW//5Dji+O0pkmFSfcHw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.101.0.tgz", + "integrity": "sha512-mSnFDhTIAe9cr1iifv+DTQzpY/0WphywED2YT3yMGeu94jjxrXrreQkywxEaeSXTnc4ys1umtnWOB1CaBlYKag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.101.0.tgz", + "integrity": "sha512-xzZpybVWq62VF102abZr1EP3BbA2bYecwYm3G2dJk+6ua7nAIodu1fiGHAvBSR0BLGQmesNb6KCneawNL3XBsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@supabase/functions-js": { "version": "2.106.1", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", @@ -8897,6 +9024,103 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8936,6 +9160,16 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9131,6 +9365,49 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9960,6 +10237,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/srvx": { "version": "0.8.9", "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", @@ -10260,6 +10547,26 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supabase": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.101.0.tgz", + "integrity": "sha512-9wqlvXGeI+VjOMUKOGEpcZzTJGLia6IOv8LWN3W2u8F6Xiv4ZqAuTuXCp3MWfkhV5x9KQaBtN5d6IiLEMWzkJA==", + "dev": true, + "license": "MIT", + "bin": { + "supabase": "dist/supabase.js" + }, + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.101.0", + "@supabase/cli-darwin-x64": "2.101.0", + "@supabase/cli-linux-arm64": "2.101.0", + "@supabase/cli-linux-arm64-musl": "2.101.0", + "@supabase/cli-linux-x64": "2.101.0", + "@supabase/cli-linux-x64-musl": "2.101.0", + "@supabase/cli-windows-arm64": "2.101.0", + "@supabase/cli-windows-x64": "2.101.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11210,6 +11517,16 @@ "node": ">= 6.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 000db8e..3ced42c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "generate:sprites": "node scripts/generate-character-sprites.mjs" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.1", @@ -31,6 +32,7 @@ "autoprefixer": "^10.4.20", "eslint": "^9", "eslint-config-next": "15.3.1", + "pngjs": "^7.0.0", "postcss": "^8", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", diff --git a/public/character/eyes/.gitkeep b/public/character/eyes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/character/head/.gitkeep b/public/character/head/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/character/pants/.gitkeep b/public/character/pants/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/character/torso/.gitkeep b/public/character/torso/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/generate-character-sprites.mjs b/scripts/generate-character-sprites.mjs new file mode 100644 index 0000000..7b19637 --- /dev/null +++ b/scripts/generate-character-sprites.mjs @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PNG } from "pngjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const publicDir = path.join(root, "public"); + +const W = 32; +const H = 48; + +const RED = [255, 0, 0]; +const DARK_RED = [51, 0, 0]; +const WHITE = [255, 255, 255]; +const MAGENTA = [255, 0, 255]; + +function createCanvas() { + return new PNG({ width: W, height: H }); +} + +function setPixel(png, x, y, [r, g, b], alpha = 255) { + if (x < 0 || y < 0 || x >= W || y >= H) { + return; + } + + const index = (W * y + x) << 2; + png.data[index] = r; + png.data[index + 1] = g; + png.data[index + 2] = b; + png.data[index + 3] = alpha; +} + +function fillRect(png, x, y, width, height, color, alpha = 255) { + for (let row = 0; row < height; row += 1) { + for (let col = 0; col < width; col += 1) { + setPixel(png, x + col, y + row, color, alpha); + } + } +} + +function writeSprite(relativePath, png) { + const filePath = path.join(publicDir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, PNG.sync.write(png)); + console.log(`wrote ${relativePath}`); +} + +function drawSkin() { + const png = createCanvas(); + fillRect(png, 11, 6, 10, 10, RED); + fillRect(png, 10, 16, 12, 14, RED); + fillRect(png, 8, 30, 6, 12, RED); + fillRect(png, 18, 30, 6, 12, RED); + fillRect(png, 6, 18, 4, 10, RED); + fillRect(png, 22, 18, 4, 10, RED); + fillRect(png, 12, 8, 8, 2, DARK_RED); + writeSprite("character/skin/skin-1.png", png); +} + +function drawPantsVariant(index, widthOffset) { + const png = createCanvas(); + fillRect(png, 8 + widthOffset, 28, 7 - widthOffset, 14, RED); + fillRect(png, 17, 28, 7 - widthOffset, 14, RED); + if (index === 2) { + fillRect(png, 8, 26, 16, 4, RED); + } + if (index === 3) { + fillRect(png, 7, 24, 18, 6, RED); + } + writeSprite(`character/pants/pants-${index}.png`, png); +} + +function drawShoesVariant(index) { + const png = createCanvas(); + fillRect(png, 6, 40, 8, 4, RED); + fillRect(png, 18, 40, 8, 4, RED); + if (index >= 2) { + fillRect(png, 5, 42, 10, 2, DARK_RED); + fillRect(png, 17, 42, 10, 2, DARK_RED); + } + if (index === 3) { + fillRect(png, 4, 38, 12, 3, RED); + fillRect(png, 16, 38, 12, 3, RED); + } + writeSprite(`character/shoes/shoes-${index}.png`, png); +} + +function drawTorsoVariant(index) { + const png = createCanvas(); + fillRect(png, 9, 16, 14, 14, RED); + if (index >= 2) { + fillRect(png, 7, 18, 3, 10, RED); + fillRect(png, 22, 18, 3, 10, RED); + } + if (index >= 3) { + fillRect(png, 11, 18, 10, 8, DARK_RED); + } + if (index === 4) { + fillRect(png, 10, 14, 12, 4, RED); + } + writeSprite(`character/torso/torso-${index}.png`, png); +} + +function drawEyesVariant(index) { + const png = createCanvas(); + fillRect(png, 12, 10, 3, 3, index === 1 ? RED : WHITE); + fillRect(png, 17, 10, 3, 3, index === 1 ? RED : WHITE); + fillRect(png, 13, 11, 1, 1, MAGENTA); + fillRect(png, 18, 11, 1, 1, MAGENTA); + if (index === 2) { + fillRect(png, 11, 9, 5, 5, WHITE); + fillRect(png, 16, 9, 5, 5, WHITE); + fillRect(png, 13, 11, 2, 2, RED); + fillRect(png, 18, 11, 2, 2, RED); + } + writeSprite(`character/eyes/eyes-${index}.png`, png); +} + +function drawHeadVariant(index) { + const png = createCanvas(); + const y = index <= 3 ? 4 : 2; + fillRect(png, 10, y, 12, index >= 5 ? 12 : 10, RED); + if (index === 2) { + fillRect(png, 8, y + 2, 3, 3, RED); + fillRect(png, 21, y + 2, 3, 3, RED); + } + if (index === 3) { + fillRect(png, 9, y - 1, 14, 3, RED); + } + if (index >= 4) { + fillRect(png, 11, y + 1, 10, 4, DARK_RED); + } + if (index >= 6) { + fillRect(png, 9, y - 2, 14, 4, RED); + } + if (index === 7) { + fillRect(png, 7, y, 4, 8, RED); + fillRect(png, 21, y, 4, 8, RED); + } + writeSprite(`character/head/head-${index}.png`, png); +} + +drawSkin(); +for (let index = 1; index <= 3; index += 1) { + drawPantsVariant(index, index - 1); + drawShoesVariant(index); +} +for (let index = 1; index <= 4; index += 1) { + drawTorsoVariant(index); +} +for (let index = 1; index <= 2; index += 1) { + drawEyesVariant(index); +} +for (let index = 1; index <= 7; index += 1) { + drawHeadVariant(index); +} + +console.log("Character sprites generated."); diff --git a/supabase/migrations/20260524180000_avatar_layer_colors_and_styles.sql b/supabase/migrations/20260524180000_avatar_layer_colors_and_styles.sql new file mode 100644 index 0000000..965aaf3 --- /dev/null +++ b/supabase/migrations/20260524180000_avatar_layer_colors_and_styles.sql @@ -0,0 +1,67 @@ +-- Layered avatar customization: hex colors per layer and sprite variant ids. + +alter table public.avatar_state + drop constraint if exists avatar_state_skin_tone_check, + drop constraint if exists avatar_state_head_style_check, + drop constraint if exists avatar_state_torso_style_check, + drop constraint if exists avatar_state_shoe_style_check, + drop constraint if exists avatar_state_eye_type_check; + +alter table public.avatar_state + rename column skin_tone to skin_color; + +alter table public.avatar_state + alter column skin_color type text using + case skin_color + when 'peach' then '#FFDFC4' + when 'tan' then '#A87149' + when 'brown' then '#8D5524' + when 'olive' then '#C68642' + when 'deep' then '#3B2212' + else coalesce(nullif(skin_color, ''), '#808080') + end; + +alter table public.avatar_state + alter column skin_color set default '#808080'; + +alter table public.avatar_state + add column if not exists pants_style text not null default 'none', + add column if not exists pants_color text not null default '#808080', + add column if not exists head_color text not null default '#808080', + add column if not exists torso_color text not null default '#808080', + add column if not exists shoe_color text not null default '#808080', + add column if not exists avatar_customized boolean not null default false; + +alter table public.avatar_state + alter column head_style set default 'none', + alter column torso_style set default 'none', + alter column shoe_style set default 'none', + alter column eye_type set default 'none', + alter column eye_color set default '#808080'; + +update public.avatar_state +set + head_style = 'none', + torso_style = 'none', + shoe_style = 'none', + eye_type = 'none', + skin_color = coalesce(nullif(skin_color, ''), '#808080'), + pants_color = '#808080', + head_color = '#808080', + torso_color = '#808080', + shoe_color = '#808080', + eye_color = coalesce(nullif(eye_color, ''), '#808080') +where avatar_customized = false; + +comment on column public.avatar_state.skin_color is 'Skin layer tint hex color.'; +comment on column public.avatar_state.pants_style is 'Pants sprite variant id or none.'; +comment on column public.avatar_state.pants_color is 'Pants layer tint hex color.'; +comment on column public.avatar_state.shoe_style is 'Shoes sprite variant id or none.'; +comment on column public.avatar_state.shoe_color is 'Shoes layer tint hex color.'; +comment on column public.avatar_state.torso_style is 'Torso sprite variant id or none.'; +comment on column public.avatar_state.torso_color is 'Torso layer tint hex color.'; +comment on column public.avatar_state.eye_type is 'Eyes sprite variant id or none.'; +comment on column public.avatar_state.eye_color is 'Eyes layer tint hex color.'; +comment on column public.avatar_state.head_style is 'Head sprite variant id or none.'; +comment on column public.avatar_state.head_color is 'Head layer tint hex color.'; +comment on column public.avatar_state.avatar_customized is 'True after the user confirms avatar setup during onboarding.'; diff --git a/supabase/migrations/20260524190000_onboarding_flags_and_variant_validation.sql b/supabase/migrations/20260524190000_onboarding_flags_and_variant_validation.sql new file mode 100644 index 0000000..107abcb --- /dev/null +++ b/supabase/migrations/20260524190000_onboarding_flags_and_variant_validation.sql @@ -0,0 +1,62 @@ +-- Onboarding flags on profiles, equipped shop items, variant id validation. + +alter table public.profiles + add column if not exists onboarding_quiz_complete boolean not null default false; + +comment on column public.profiles.onboarding_quiz_complete is + 'True after the user completes the focus-topic onboarding quiz.'; +comment on column public.profiles.onboarding_complete is + 'True after the user confirms avatar customization and enters the app.'; + +alter table public.avatar_state + add column if not exists equipped_items text[] not null default '{}'; + +comment on column public.avatar_state.equipped_items is + 'Shop item ids currently equipped on the avatar (accessories and backgrounds).'; + +alter table public.avatar_state + drop constraint if exists avatar_state_pants_style_check, + drop constraint if exists avatar_state_shoe_style_check, + drop constraint if exists avatar_state_torso_style_check, + drop constraint if exists avatar_state_eye_type_check, + drop constraint if exists avatar_state_head_style_check; + +alter table public.avatar_state + add constraint avatar_state_pants_style_check + check (pants_style in ('none', 'pants-1', 'pants-2', 'pants-3')), + add constraint avatar_state_shoe_style_check + check (shoe_style in ('none', 'shoes-1', 'shoes-2', 'shoes-3')), + add constraint avatar_state_torso_style_check + check (torso_style in ('none', 'torso-1', 'torso-2', 'torso-3', 'torso-4')), + add constraint avatar_state_eye_type_check + check (eye_type in ('none', 'eyes-1', 'eyes-2')), + add constraint avatar_state_head_style_check + check (head_style in ('none', 'head-1', 'head-2', 'head-3', 'head-4', 'head-5', 'head-6', 'head-7')); + +alter table public.avatar_state + drop constraint if exists avatar_state_skin_color_check, + drop constraint if exists avatar_state_pants_color_check, + drop constraint if exists avatar_state_shoe_color_check, + drop constraint if exists avatar_state_torso_color_check, + drop constraint if exists avatar_state_eye_color_check, + drop constraint if exists avatar_state_head_color_check; + +alter table public.avatar_state + add constraint avatar_state_skin_color_check + check (skin_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_pants_color_check + check (pants_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_shoe_color_check + check (shoe_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_torso_color_check + check (torso_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_eye_color_check + check (eye_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_head_color_check + check (head_color ~ '^#[0-9A-Fa-f]{6}$'); + +-- Backfill quiz completion for users who already finished onboarding. +update public.profiles +set onboarding_quiz_complete = true +where onboarding_complete = true + and onboarding_quiz_complete = false; diff --git a/supabase/seed.sql b/supabase/seed.sql index 66eb8bb..dacd10e 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,8 +1,32 @@ -insert into shop_items (id, name, type, price, image_path) +-- Character layer styles sold in the shop (id = variant id, type = layer id). + +delete from public.user_items +where item_id in ('hat_blue', 'glasses_round', 'room_sunset'); + +delete from public.shop_items +where id in ('hat_blue', 'glasses_round', 'room_sunset'); + +insert into public.shop_items (id, name, type, price, image_path) values - ('hat_blue', 'Blue Hat', 'accessory', 20, '/items/hat-blue.png'), - ('glasses_round', 'Round Glasses', 'accessory', 30, '/items/glasses-round.png'), - ('room_sunset', 'Sunset Room', 'background', 40, '/backgrounds/room-sunset.png') + ('pants-1', 'Pants 1', 'pants', 20, '/character/pants/pants-1.png'), + ('pants-2', 'Pants 2', 'pants', 25, '/character/pants/pants-2.png'), + ('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), + ('shoes-1', 'Shoes 1', 'shoes', 20, '/character/shoes/shoes-1.png'), + ('shoes-2', 'Shoes 2', 'shoes', 25, '/character/shoes/shoes-2.png'), + ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), + ('torso-1', 'Torso 1', 'torso', 25, '/character/torso/torso-1.png'), + ('torso-2', 'Torso 2', 'torso', 30, '/character/torso/torso-2.png'), + ('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), + ('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), + ('eyes-1', 'Eyes 1', 'eyes', 15, '/character/eyes/eyes-1.png'), + ('eyes-2', 'Eyes 2', 'eyes', 20, '/character/eyes/eyes-2.png'), + ('head-1', 'Head 1', 'head', 30, '/character/head/head-1.png'), + ('head-2', 'Head 2', 'head', 35, '/character/head/head-2.png'), + ('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), + ('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), + ('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'), + ('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'), + ('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png') on conflict (id) do update set name = excluded.name,