diff --git a/README.md b/README.md index df5513c..54836dd 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 @@ -41,55 +51,61 @@ This repo does not yet include MVP feature pages, avatar logic, habits, streaks, NEXT_PUBLIC_SUPABASE_ANON_KEY= ``` -4. Start the app: +4. Apply migrations locally (Supabase CLI): + + ```bash + supabase db reset + ``` + +5. 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 are added manually under `public/character/{layer}/` (e.g. `public/character/head/head-1.png`). Paths must match the variant ids in `lib/character/presets.ts` and `supabase/seed.sql`. + +## 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 5f5d672..878014c 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 new file mode 100644 index 0000000..9d6407e --- /dev/null +++ b/app/character-creator/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; + +import { routes } from "@/lib/routes"; + +export default function CharacterCreatorPage() { + 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 1a1d87a..fc42088 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 { useCallback, 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, @@ -23,7 +21,11 @@ import { import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage"; import { routes } from "@/lib/routes"; -export function AvatarStatusCard() { +type AvatarStatusCardProps = { + customization: AvatarCustomization; +}; + +export function AvatarStatusCard({ customization }: AvatarStatusCardProps) { const [condition, setCondition] = useState( defaultAvatarCondition, ); @@ -69,7 +71,21 @@ export function AvatarStatusCard() { return ( - +
+

+ {customization.name} +

+

+ {condition.mood} +

+
+
Energy {condition.energy}/{WELLNESS_SCALE_MAX} diff --git a/components/character/character-color-presets.tsx b/components/character/character-color-presets.tsx new file mode 100644 index 0000000..31772dc --- /dev/null +++ b/components/character/character-color-presets.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { ColorPreset } from "@/lib/character/presets"; + +type CharacterColorPresetsProps = { + presets: ColorPreset[]; + activeId?: string; + onSelect: (preset: ColorPreset) => void; +}; + +export function CharacterColorPresets({ + presets, + activeId, + onSelect, +}: CharacterColorPresetsProps) { + return ( +
+ {presets.map((preset) => ( +
+ ); +} diff --git a/components/character/character-color-sliders.tsx b/components/character/character-color-sliders.tsx new file mode 100644 index 0000000..6d51e6e --- /dev/null +++ b/components/character/character-color-sliders.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { + LIGHTNESS_MIN, + clampHsl, + displayToLightness, + lightnessToDisplay, + type HSL, +} from "@/lib/character/color-utils"; +import { cn } from "@/lib/utils"; + +type ColorSliderProps = { + label: string; + value: number; + displayValue?: number; + min: number; + max: number; + gradient?: string; + onChange: (value: number) => void; +}; + +export function ColorSlider({ + label, + value, + displayValue, + min, + max, + gradient, + onChange, +}: ColorSliderProps) { + const shownValue = displayValue ?? value; + + return ( +
+
+ + + {shownValue} + +
+ onChange(Number(event.target.value))} + className={cn( + "h-2.5 w-full cursor-pointer appearance-none rounded-full border border-border/40", + "[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:shadow-sm", + "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-foreground [&::-moz-range-thumb]:shadow-sm", + )} + style={ + gradient + ? { + background: gradient, + } + : undefined + } + /> +
+ ); +} + +type CharacterColorSlidersProps = { + color: HSL; + onChange: (color: HSL) => void; +}; + +export function CharacterColorSliders({ + color, + onChange, +}: CharacterColorSlidersProps) { + const hueGradient = + "linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))"; + + const saturationGradient = `linear-gradient(to right, hsl(${color.h}, 0%, ${color.l}%), hsl(${color.h}, 100%, ${color.l}%))`; + + const lightnessGradient = `linear-gradient(to right, hsl(${color.h}, ${color.s}%, ${LIGHTNESS_MIN}%), hsl(${color.h}, ${color.s}%, 50%), hsl(${color.h}, ${color.s}%, 100%))`; + + const brightnessDisplay = lightnessToDisplay(color.l); + + return ( +
+ onChange({ ...color, h })} + /> + onChange({ ...color, s })} + /> + + onChange(clampHsl({ ...color, l: displayToLightness(display) })) + } + /> +
+ ); +} diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx new file mode 100644 index 0000000..9e64cea --- /dev/null +++ b/components/character/character-creator.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useState } from "react"; + +import { CharacterColorPresets } from "@/components/character/character-color-presets"; +import { CharacterColorSliders } from "@/components/character/character-color-sliders"; +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 { 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"; +import { isVariantUnlocked } from "@/lib/character/variant-access"; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +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( + initialCustomization?.variants ?? buildDefaultVariants(), + ); + const [activePresetId, setActivePresetId] = useState( + DEFAULT_GRAY_COLOR.id, + ); + const [activeLayerId, setActiveLayerId] = useState< + (typeof CHARACTER_LAYERS)[number]["id"] + >("skin"); + const [saveError, setSaveError] = useState(null); + + const activeLayer = getLayerById(activeLayerId); + const activeColor = colors[activeLayerId]; + const ownedSet = new Set(ownedVariantIds); + const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : []; + const lockedVariantIds = new Set( + pieceVariants + .filter( + (variant) => + variant.id !== NONE_VARIANT_ID && + !isVariantUnlocked(variant.id, ownedSet), + ) + .map((variant) => variant.id), + ); + + const applyPreset = (preset: ColorPreset) => { + setActivePresetId(preset.id); + setColors((current) => ({ + ...current, + [activeLayerId]: { ...preset.hsl }, + })); + }; + + const updateActiveColor = (nextColor: typeof activeColor) => { + setActivePresetId(""); + setColors((current) => ({ + ...current, + [activeLayerId]: clampHsl(nextColor), + })); + }; + + const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => { + setActiveLayerId(layerId); + const layerColor = colors[layerId]; + const matchingPreset = COLOR_PRESETS.find( + (preset) => + preset.hsl.h === layerColor.h && + preset.hsl.s === layerColor.s && + preset.hsl.l === layerColor.l, + ); + setActivePresetId(matchingPreset?.id ?? ""); + }; + + const handleVariantChange = (variantId: string) => { + if (!isVariantUnlocked(variantId, ownedSet)) { + return; + } + + setVariants((current) => ({ + ...current, + [activeLayerId]: variantId, + })); + }; + + 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} + + + +
+ + + + + + + + +
+ Style +
+ +
+
+ +
+ Color +
+ +
+
+ +
+ Adjust + +
+ + {onSave ? ( +
+ + {saveError ? ( +

{saveError}

+ ) : null} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx new file mode 100644 index 0000000..ee415d4 --- /dev/null +++ b/components/character/character-layer-preview.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { applyEquippedItemsToVariants } from "@/lib/avatar-display"; +import { + drawTintedSprite, + getCachedImage, + hslToRgb, +} from "@/lib/character/color-utils"; +import { + CHARACTER_LAYERS, + getSelectedVariant, +} from "@/lib/character/presets"; +import type { LayerColorState, LayerVariantState } from "@/lib/character/types"; +import { cn } from "@/lib/utils"; + +const EMPTY_EQUIPPED_ITEMS: string[] = []; + +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 }); + const resolvedEquippedItems = equippedItems ?? EMPTY_EQUIPPED_ITEMS; + const equippedKey = resolvedEquippedItems.join("\0"); + const equippedItemsRef = useRef(resolvedEquippedItems); + equippedItemsRef.current = resolvedEquippedItems; + + useEffect(() => { + let cancelled = false; + const items = equippedItemsRef.current; + + async function render() { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return; + + try { + const displayVariants = applyEquippedItemsToVariants( + variants, + items, + ); + + const activeLayers = CHARACTER_LAYERS.flatMap((layer) => { + const selected = getSelectedVariant(layer.id, displayVariants[layer.id]); + if (!selected) return []; + return [{ layer, variant: selected }]; + }); + + const images = await Promise.all( + activeLayers.map(async ({ layer, variant }) => ({ + layer, + image: await getCachedImage(variant.src), + })), + ); + + if (cancelled || images.length === 0) return; + + const width = images[0]?.image.width ?? 0; + const height = images[0]?.image.height ?? 0; + + canvas.width = width; + canvas.height = height; + + const nextWidth = width * scale; + const nextHeight = height * scale; + setDisplaySize((current) => + current.width === nextWidth && current.height === nextHeight + ? current + : { width: nextWidth, height: nextHeight }, + ); + + ctx.clearRect(0, 0, width, height); + + for (const { layer, image } of images) { + const layerColor = colors[layer.id]; + const rgb = hslToRgb(layerColor.h, layerColor.s, layerColor.l); + + drawTintedSprite( + ctx, + image, + rgb, + layer.id === "eyes" + ? { + mode: "eyes", + skinColor: hslToRgb( + colors.skin.h, + colors.skin.s, + colors.skin.l, + ), + } + : undefined, + ); + } + } catch { + // Ignore missing or broken sprite assets so the UI stays responsive. + } + } + + void render(); + + return () => { + cancelled = true; + }; + }, [colors, variants, equippedKey, scale]); + + return ( +
+ +
+ ); +} diff --git a/components/character/character-layer-tabs.tsx b/components/character/character-layer-tabs.tsx new file mode 100644 index 0000000..9333a27 --- /dev/null +++ b/components/character/character-layer-tabs.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { CHARACTER_LAYERS, type CharacterLayerId } from "@/lib/character/presets"; +import { cn } from "@/lib/utils"; + +type CharacterLayerTabsProps = { + activeLayerId: CharacterLayerId; + onLayerChange: (layerId: CharacterLayerId) => void; +}; + +export function CharacterLayerTabs({ + activeLayerId, + onLayerChange, +}: CharacterLayerTabsProps) { + return ( +
+ {CHARACTER_LAYERS.map((layer) => ( + + ))} +
+ ); +} diff --git a/components/character/character-piece-selector.tsx b/components/character/character-piece-selector.tsx new file mode 100644 index 0000000..15f9f1f --- /dev/null +++ b/components/character/character-piece-selector.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { CircleSlash, Lock } from "lucide-react"; + +import type { HSL } from "@/lib/character/color-utils"; +import { NONE_VARIANT_ID, PIECE_SLOT_COUNT } from "@/lib/character/presets"; +import { cn } from "@/lib/utils"; +import type { LayerVariant } from "@/lib/character/presets"; + +import { TintedSpriteIcon } from "./tinted-sprite-icon"; + +type CharacterPieceSelectorProps = { + variants: LayerVariant[]; + lockedVariantIds?: ReadonlySet; + activeId: string; + color: HSL; + skinColor?: HSL; + onSelect: (variantId: string) => void; +}; + +export function CharacterPieceSelector({ + variants, + lockedVariantIds, + activeId, + color, + skinColor, + onSelect, +}: CharacterPieceSelectorProps) { + const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) => + variants[index] ?? null, + ); + + return ( +
+ {slots.map((variant, index) => { + if (!variant) { + return ( +
+ ); + } + + if (variant.id === NONE_VARIANT_ID) { + return ( + + ); + } + + const locked = lockedVariantIds?.has(variant.id) ?? false; + + return ( + + ); + })} +
+ ); +} diff --git a/components/character/tinted-sprite-icon.tsx b/components/character/tinted-sprite-icon.tsx new file mode 100644 index 0000000..5b4afa7 --- /dev/null +++ b/components/character/tinted-sprite-icon.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { + drawTintedSprite, + getCachedImage, + hslToRgb, + type HSL, +} from "@/lib/character/color-utils"; +import { cn } from "@/lib/utils"; + +type TintedSpriteIconProps = { + src: string; + color: HSL; + skinColor?: HSL; + size?: number; + className?: string; +}; + +export function TintedSpriteIcon({ + src, + color, + skinColor, + size = 40, + className, +}: TintedSpriteIconProps) { + const canvasRef = useRef(null); + + useEffect(() => { + let cancelled = false; + + async function render() { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return; + + try { + const image = await getCachedImage(src); + if (cancelled) return; + + canvas.width = image.width; + canvas.height = image.height; + ctx.clearRect(0, 0, image.width, image.height); + + const rgb = hslToRgb(color.h, color.s, color.l); + const isEyesLayer = src.includes("/character/eyes/"); + + drawTintedSprite( + ctx, + image, + rgb, + isEyesLayer && skinColor + ? { + mode: "eyes", + skinColor: hslToRgb(skinColor.h, skinColor.s, skinColor.l), + } + : undefined, + ); + } catch { + // Ignore missing or broken sprite assets so the UI stays responsive. + } + } + + void render(); + + return () => { + cancelled = true; + }; + }, [src, color.h, color.s, color.l, skinColor?.h, skinColor?.s, skinColor?.l]); + + 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..95d767c --- /dev/null +++ b/components/onboarding/onboarding-quiz-form.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +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 new file mode 100644 index 0000000..135ae95 --- /dev/null +++ b/lib/character/color-utils.ts @@ -0,0 +1,258 @@ +export type RGB = { r: number; g: number; b: number }; +export type HSL = { h: number; s: number; l: number }; + +export const LIGHTNESS_MIN = 5; + +export function lightnessToDisplay(lightness: number): number { + return Math.round( + ((lightness - LIGHTNESS_MIN) / (100 - LIGHTNESS_MIN)) * 100, + ); +} + +export function displayToLightness(display: number): number { + return Math.round( + LIGHTNESS_MIN + (display / 100) * (100 - LIGHTNESS_MIN), + ); +} + +export function clampHsl(hsl: HSL): HSL { + return { + h: hsl.h, + s: Math.max(0, Math.min(100, hsl.s)), + l: Math.max(LIGHTNESS_MIN, Math.min(100, hsl.l)), + }; +} + +export function hslToRgb(h: number, s: number, l: number): RGB { + const saturation = s / 100; + const lightness = l / 100; + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = lightness - chroma / 2; + + let r = 0; + let g = 0; + let b = 0; + + if (h < 60) { + r = chroma; + g = x; + } else if (h < 120) { + r = x; + g = chroma; + } else if (h < 180) { + g = chroma; + b = x; + } else if (h < 240) { + g = x; + b = chroma; + } else if (h < 300) { + r = x; + b = chroma; + } else { + r = chroma; + b = x; + } + + return { + r: Math.round((r + m) * 255), + g: Math.round((g + m) * 255), + b: Math.round((b + m) * 255), + }; +} + +export function rgbToHsl(r: number, g: number, b: number): HSL { + const red = r / 255; + const green = g / 255; + const blue = b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const lightness = (max + min) / 2; + + let hue = 0; + let saturation = 0; + + if (max !== min) { + const delta = max - min; + saturation = + lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); + + switch (max) { + case red: + hue = ((green - blue) / delta + (green < blue ? 6 : 0)) * 60; + break; + case green: + hue = ((blue - red) / delta + 2) * 60; + break; + default: + hue = ((red - green) / delta + 4) * 60; + break; + } + } + + return { + h: Math.round(hue), + s: Math.round(saturation * 100), + l: Math.round(lightness * 100), + }; +} + +export function hexToRgb(hex: string): RGB { + const normalized = hex.replace("#", ""); + const value = + normalized.length === 3 + ? normalized + .split("") + .map((char) => char + char) + .join("") + : normalized; + + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +export function rgbToHex({ r, g, b }: RGB): string { + return `#${[r, g, b] + .map((channel) => channel.toString(16).padStart(2, "0")) + .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; + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + if (alpha === 0) continue; + + applyMonochromeTint(data, i, color); + } +} + +function isWhitePixel(r: number, g: number, b: number): boolean { + return r >= 240 && g >= 240 && b >= 240; +} + +function isMagentaPixel(r: number, g: number, b: number): boolean { + return r >= 200 && g <= 80 && b >= 200; +} + +function applyMonochromeTint( + data: Uint8ClampedArray, + index: number, + color: RGB, + brightness?: number, +): void { + const pixelBrightness = + brightness ?? + Math.max(data[index], data[index + 1], data[index + 2]) / 255; + + data[index] = Math.round(color.r * pixelBrightness); + data[index + 1] = Math.round(color.g * pixelBrightness); + data[index + 2] = Math.round(color.b * pixelBrightness); +} + +/** Darkest shadow in the skin template — magenta maps here, not bright red. */ +const SKIN_TEMPLATE_SHADOW = hexToRgb("#330000"); +const SKIN_SHADOW_BRIGHTNESS = + Math.max( + SKIN_TEMPLATE_SHADOW.r, + SKIN_TEMPLATE_SHADOW.g, + SKIN_TEMPLATE_SHADOW.b, + ) / 255; + +/** Eyes: white stays white, magenta uses skin color, other pixels use eye color. */ +export function tintEyesImageData( + imageData: ImageData, + eyeColor: RGB, + skinColor: RGB, +): void { + const { data } = imageData; + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + if (alpha === 0) continue; + + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + if (isWhitePixel(r, g, b)) { + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + continue; + } + + if (isMagentaPixel(r, g, b)) { + applyMonochromeTint(data, i, skinColor, SKIN_SHADOW_BRIGHTNESS); + continue; + } + + applyMonochromeTint(data, i, eyeColor); + } +} + +export type TintOptions = { + mode?: "monochrome" | "eyes"; + skinColor?: RGB; +}; + +export function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load image: ${src}`)); + image.src = src; + }); +} + +const imageCache = new Map(); + +export async function getCachedImage(src: string): Promise { + if (!imageCache.has(src)) { + imageCache.set(src, await loadImage(src)); + } + return imageCache.get(src)!; +} + +export function drawTintedSprite( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + color: RGB, + options?: TintOptions, +): void { + const width = image.width; + const height = image.height; + const offscreen = document.createElement("canvas"); + offscreen.width = width; + offscreen.height = height; + + const offscreenCtx = offscreen.getContext("2d", { willReadFrequently: true }); + if (!offscreenCtx) return; + + offscreenCtx.drawImage(image, 0, 0); + const imageData = offscreenCtx.getImageData(0, 0, width, height); + + if (options?.mode === "eyes" && options.skinColor) { + tintEyesImageData(imageData, color, options.skinColor); + } else { + tintImageData(imageData, color); + } + + offscreenCtx.putImageData(imageData, 0, 0); + ctx.drawImage(offscreen, 0, 0); +} diff --git a/lib/character/presets.ts b/lib/character/presets.ts new file mode 100644 index 0000000..0889070 --- /dev/null +++ b/lib/character/presets.ts @@ -0,0 +1,193 @@ +import { + clampHsl, + hexToRgb, + hslToRgb, + rgbToHex, + rgbToHsl, + type HSL, +} from "./color-utils"; +import type { LayerVariantState } from "./types"; + +export type ColorPreset = { + id: string; + name: string; + hex: string; + hsl: HSL; +}; + +export type LayerVariant = { + id: string; + label: string; + src: string; +}; + +export const NONE_VARIANT_ID = "none"; + +export const NONE_VARIANT: LayerVariant = { + id: NONE_VARIANT_ID, + label: "None", + src: "", +}; + +export type CharacterLayerId = + | "skin" + | "pants" + | "shoes" + | "torso" + | "eyes" + | "head"; + +export type CharacterLayer = { + id: CharacterLayerId; + label: string; + allowVariants: boolean; + variants: LayerVariant[]; +}; + +function presetFromHsl(id: string, name: string, hsl: HSL): ColorPreset { + const clamped = clampHsl(hsl); + return { + id, + name, + hsl: clamped, + hex: rgbToHex(hslToRgb(clamped.h, clamped.s, clamped.l)), + }; +} + +function presetFromHex(id: string, name: string, hex: string): ColorPreset { + const rgb = hexToRgb(hex); + return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b)); +} + +function layerVariants( + layerId: Exclude, + label: string, + count: number, +): LayerVariant[] { + return Array.from({ length: count }, (_, index) => { + const number = index + 1; + return { + id: `${layerId}-${number}`, + label: `${label} ${number}`, + src: `/character/${layerId}/${layerId}-${number}.png`, + }; + }); +} + +function layerVariantsWithNone( + layerId: Exclude, + label: string, + count: number, +): LayerVariant[] { + return [NONE_VARIANT, ...layerVariants(layerId, label, count)]; +} + +export const COLOR_PRESETS: ColorPreset[] = [ + presetFromHsl("gray", "Gray", { h: 0, s: 0, l: 50 }), + presetFromHsl("white", "White", { h: 0, s: 0, l: 100 }), + presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }), + presetFromHex("red", "Red", "#FF2020"), + presetFromHex("orange", "Orange", "#FF8800"), + presetFromHex("yellow", "Yellow", "#FFD700"), + presetFromHex("green", "Green", "#22AA44"), + presetFromHex("blue", "Blue", "#2266FF"), + presetFromHex("indigo", "Indigo", "#4B0082"), + presetFromHex("porcelain", "Porcelain", "#FFDFC4"), + presetFromHex("fair", "Fair", "#F0C9A5"), + presetFromHex("light", "Light", "#E0AC69"), + presetFromHex("medium", "Medium", "#C68642"), + presetFromHex("tan", "Tan", "#A87149"), + presetFromHex("brown", "Brown", "#8D5524"), + presetFromHex("dark", "Dark", "#5C3317"), + presetFromHex("deep", "Deep", "#3B2212"), + presetFromHex("onyx", "Onyx", "#140C08"), +]; + +export const DEFAULT_GRAY_COLOR = + COLOR_PRESETS.find((preset) => preset.id === "gray")!; + +export const DEFAULT_SKIN_COLOR = DEFAULT_GRAY_COLOR; + +export const PIECE_SLOT_COUNT = 8; + +/** Stack order: bottom → top */ +export const CHARACTER_LAYERS: CharacterLayer[] = [ + { + id: "skin", + label: "Skin", + allowVariants: false, + variants: [ + { id: "skin-1", label: "Skin 1", src: "/character/skin/skin-1.png" }, + ], + }, + { + id: "pants", + label: "Pants", + allowVariants: true, + variants: layerVariantsWithNone("pants", "Pants", 3), + }, + { + id: "shoes", + label: "Shoes", + allowVariants: true, + variants: layerVariantsWithNone("shoes", "Shoes", 3), + }, + { + id: "torso", + label: "Torso", + allowVariants: true, + variants: layerVariantsWithNone("torso", "Torso", 4), + }, + { + id: "eyes", + label: "Eyes", + allowVariants: true, + variants: layerVariantsWithNone("eyes", "Eyes", 2), + }, + { + id: "head", + label: "Head", + allowVariants: true, + variants: layerVariantsWithNone("head", "Head", 7), + }, +]; + +export const LAYER_DEFAULT_COLORS: Record = { + skin: DEFAULT_GRAY_COLOR.hsl, + pants: DEFAULT_GRAY_COLOR.hsl, + shoes: DEFAULT_GRAY_COLOR.hsl, + torso: DEFAULT_GRAY_COLOR.hsl, + eyes: DEFAULT_GRAY_COLOR.hsl, + head: DEFAULT_GRAY_COLOR.hsl, +}; + +export function buildDefaultVariants(): LayerVariantState { + return CHARACTER_LAYERS.reduce((state, layer) => { + state[layer.id] = layer.allowVariants + ? NONE_VARIANT_ID + : (layer.variants[0]?.id ?? ""); + return state; + }, {} as LayerVariantState); +} + +export function getLayerById(layerId: CharacterLayerId): CharacterLayer { + return CHARACTER_LAYERS.find((layer) => layer.id === layerId)!; +} + +export function getSelectedVariant( + layerId: CharacterLayerId, + variantId: string, +): LayerVariant | undefined { + if (variantId === NONE_VARIANT_ID) return undefined; + + const variant = getLayerById(layerId).variants.find( + (entry) => entry.id === variantId, + ); + + if (!variant?.src) return undefined; + + return variant; +} + +/** @deprecated Use DEFAULT_SKIN_COLOR */ +export const DEFAULT_BASE_COLOR = DEFAULT_SKIN_COLOR; diff --git a/lib/character/types.ts b/lib/character/types.ts new file mode 100644 index 0000000..4eaee31 --- /dev/null +++ b/lib/character/types.ts @@ -0,0 +1,5 @@ +import type { HSL } from "./color-utils"; +import type { CharacterLayerId } from "./presets"; + +export type LayerColorState = Record; +export type LayerVariantState = Record; diff --git a/lib/character/variant-access.ts b/lib/character/variant-access.ts new file mode 100644 index 0000000..016e716 --- /dev/null +++ b/lib/character/variant-access.ts @@ -0,0 +1,39 @@ +import { NONE_VARIANT_ID } from "@/lib/character/presets"; + +/** First N numbered styles per layer are free; higher numbers are shop-only. */ +export const FREE_STYLE_COUNT = 2; + +export function getVariantStyleNumber(variantId: string): number | null { + if (variantId === NONE_VARIANT_ID) { + return null; + } + + const match = variantId.match(/-(\d+)$/); + return match ? Number.parseInt(match[1], 10) : null; +} + +export function isFreeStyleVariant(variantId: string): boolean { + const styleNumber = getVariantStyleNumber(variantId); + return styleNumber !== null && styleNumber <= FREE_STYLE_COUNT; +} + +export function isShopOnlyStyleVariant(variantId: string): boolean { + const styleNumber = getVariantStyleNumber(variantId); + return styleNumber !== null && styleNumber > FREE_STYLE_COUNT; +} + +export function isVariantUnlocked( + variantId: string, + ownedVariantIds: ReadonlySet | readonly string[], +): boolean { + if (variantId === NONE_VARIANT_ID || isFreeStyleVariant(variantId)) { + return true; + } + + const owned = + ownedVariantIds instanceof Set + ? ownedVariantIds + : new Set(ownedVariantIds); + + return owned.has(variantId); +} 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 10afe06..e7dd6d4 100644 --- a/lib/profile-preferences-storage.ts +++ b/lib/profile-preferences-storage.ts @@ -178,7 +178,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); @@ -186,9 +189,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; @@ -307,10 +311,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) { @@ -318,16 +319,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..e0b6b67 --- /dev/null +++ b/lib/shop-catalog.ts @@ -0,0 +1,91 @@ +import { isShopOnlyStyleVariant } from "@/lib/character/variant-access"; +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 && isShopOnlyStyleVariant(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/public/character/eyes/eyes-1.png b/public/character/eyes/eyes-1.png new file mode 100644 index 0000000..1b2a17a Binary files /dev/null and b/public/character/eyes/eyes-1.png differ diff --git a/public/character/eyes/eyes-2.png b/public/character/eyes/eyes-2.png new file mode 100644 index 0000000..c26920c Binary files /dev/null and b/public/character/eyes/eyes-2.png differ diff --git a/public/character/head/head-1.png b/public/character/head/head-1.png new file mode 100644 index 0000000..19a7a71 Binary files /dev/null and b/public/character/head/head-1.png differ diff --git a/public/character/head/head-2.png b/public/character/head/head-2.png new file mode 100644 index 0000000..f3c8cda Binary files /dev/null and b/public/character/head/head-2.png differ diff --git a/public/character/head/head-3.png b/public/character/head/head-3.png new file mode 100644 index 0000000..32ee7f9 Binary files /dev/null and b/public/character/head/head-3.png differ diff --git a/public/character/head/head-4.png b/public/character/head/head-4.png new file mode 100644 index 0000000..a0b47d3 Binary files /dev/null and b/public/character/head/head-4.png differ diff --git a/public/character/head/head-5.png b/public/character/head/head-5.png new file mode 100644 index 0000000..a024c0c Binary files /dev/null and b/public/character/head/head-5.png differ diff --git a/public/character/head/head-6.png b/public/character/head/head-6.png new file mode 100644 index 0000000..0ff3b9d Binary files /dev/null and b/public/character/head/head-6.png differ diff --git a/public/character/head/head-7.png b/public/character/head/head-7.png new file mode 100644 index 0000000..721749b Binary files /dev/null and b/public/character/head/head-7.png differ diff --git a/public/character/pants/pants-1.png b/public/character/pants/pants-1.png new file mode 100644 index 0000000..0d8d741 Binary files /dev/null and b/public/character/pants/pants-1.png differ diff --git a/public/character/pants/pants-2.png b/public/character/pants/pants-2.png new file mode 100644 index 0000000..82fca3c Binary files /dev/null and b/public/character/pants/pants-2.png differ diff --git a/public/character/pants/pants-3.png b/public/character/pants/pants-3.png new file mode 100644 index 0000000..0df9b4a Binary files /dev/null and b/public/character/pants/pants-3.png differ diff --git a/public/character/shoes/shoes-1.png b/public/character/shoes/shoes-1.png new file mode 100644 index 0000000..6bed3ef Binary files /dev/null and b/public/character/shoes/shoes-1.png differ diff --git a/public/character/shoes/shoes-2.png b/public/character/shoes/shoes-2.png new file mode 100644 index 0000000..eefb405 Binary files /dev/null and b/public/character/shoes/shoes-2.png differ diff --git a/public/character/shoes/shoes-3.png b/public/character/shoes/shoes-3.png new file mode 100644 index 0000000..735378e Binary files /dev/null and b/public/character/shoes/shoes-3.png differ diff --git a/public/character/skin/skin-1.png b/public/character/skin/skin-1.png new file mode 100644 index 0000000..b597929 Binary files /dev/null and b/public/character/skin/skin-1.png differ diff --git a/public/character/torso/torso-1.png b/public/character/torso/torso-1.png new file mode 100644 index 0000000..d383293 Binary files /dev/null and b/public/character/torso/torso-1.png differ diff --git a/public/character/torso/torso-2.png b/public/character/torso/torso-2.png new file mode 100644 index 0000000..694c60b Binary files /dev/null and b/public/character/torso/torso-2.png differ diff --git a/public/character/torso/torso-3.png b/public/character/torso/torso-3.png new file mode 100644 index 0000000..98273ee Binary files /dev/null and b/public/character/torso/torso-3.png differ diff --git a/public/character/torso/torso-4.png b/public/character/torso/torso-4.png new file mode 100644 index 0000000..9ebdc7b Binary files /dev/null and b/public/character/torso/torso-4.png differ 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..8abfbe3 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,8 +1,36 @@ -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', + 'pants-1', + 'pants-2', + 'shoes-1', + 'shoes-2', + 'torso-1', + 'torso-2', + 'eyes-1', + 'eyes-2', + 'head-1', + 'head-2' +); + +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-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), + ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), + ('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), + ('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.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,