Merge character customization branch and resolve avatar page conflict

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masongga
2026-05-24 00:17:14 -06:00
60 changed files with 3023 additions and 405 deletions

120
README.md
View File

@@ -1,24 +1,34 @@
# Habit Pet # 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 ## Stack
- Next.js - Next.js 16 (App Router)
- Supabase - Supabase (auth + Postgres)
- Vercel - Tailwind CSS + shadcn/ui
- GitHub - 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 ## User flow
- Supabase auth wiring from the starter
- environment variable template
- deployment-ready project structure
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 ## Local setup
@@ -41,55 +51,61 @@ This repo does not yet include MVP feature pages, avatar logic, habits, streaks,
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
``` ```
4. Start the app: 4. Apply migrations locally (Supabase CLI):
```bash
supabase db reset
```
5. Start the app:
```bash ```bash
npm run dev npm run dev
``` ```
5. Create a production build when needed: ## Avatar customization data
```bash Stored in `avatar_state`:
npm run build
``` | 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 ## 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/`. | Route | Access |
- 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. | `/` | Public |
- Production database sync runs from GitHub Actions on `main` using `supabase db push --linked --include-seed` before the app deploy relies on those changes. | `/auth/*` | Public |
| `/onboarding/quiz` | Authenticated, quiz incomplete |
## Deploy | `/onboarding/customize` | Authenticated, quiz complete, avatar not confirmed |
| `/avatar` | Authenticated, onboarding complete |
This repo includes `.github/workflows/deploy.yml`, which runs on pushes to `main`. | `/habits`, `/daily/quiz`, `/shop`, `/profile` | Authenticated app |
| `/character-creator` | Redirects to `/avatar?tab=customize` |
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.

View File

@@ -1,19 +1,17 @@
import { AvatarStatusCard } from "@/components/avatar/avatar-status-card"; import { Suspense } from "react";
import { DailyQuizLinkButton } from "@/components/daily-quiz/daily-quiz-link-button";
import { HabitTracker } from "@/components/habits/habit-tracker"; import { AvatarPageContent } from "@/components/avatar/avatar-page-content";
import { AppShell } from "@/components/layout/app-shell"; import { AppShell } from "@/components/layout/app-shell";
export default function AvatarPage() { export default function AvatarPage() {
return ( return (
<AppShell <AppShell
title="Your Habit Pet" title="Your Habit Pet"
description="Today's tasks are personalized by AI from your quiz, journal, and preferences." description="Customize your pet, complete AI-personalized daily tasks, and track their mood from your wellness check-in."
> >
<div className="flex flex-col gap-6"> <Suspense fallback={<p className="text-sm text-muted-foreground">Loading...</p>}>
<AvatarStatusCard /> <AvatarPageContent />
<HabitTracker /> </Suspense>
<DailyQuizLinkButton />
</div>
</AppShell> </AppShell>
); );
} }

View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { routes } from "@/lib/routes";
export default function CharacterCreatorPage() {
redirect(`${routes.avatar}?tab=customize`);
}

View File

@@ -3,6 +3,7 @@ import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { GlobalBottomNav } from "@/components/layout/global-bottom-nav"; import { GlobalBottomNav } from "@/components/layout/global-bottom-nav";
import { ToastProvider } from "@/components/ui/toast-provider";
import "./globals.css"; import "./globals.css";
const defaultUrl = process.env.VERCEL_URL const defaultUrl = process.env.VERCEL_URL
@@ -35,8 +36,10 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<ToastProvider>
<div className="min-h-svh pb-24">{children}</div> <div className="min-h-svh pb-24">{children}</div>
<GlobalBottomNav /> <GlobalBottomNav />
</ToastProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -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 (
<Suspense fallback={<div className="min-h-svh bg-background" />}>
<OnboardingCustomizeAuthGate>
<OnboardingCustomizeForm />
</OnboardingCustomizeAuthGate>
</Suspense>
);
}

View File

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

View File

@@ -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<AvatarTab>("home");
const [customization, setCustomization] = useState<AvatarCustomization | null>(
null,
);
const [avatarName, setAvatarName] = useState("");
const [isSavingName, setIsSavingName] = useState(false);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const [ownedVariantIds, setOwnedVariantIds] = useState<string[]>([]);
useEffect(() => {
if (searchParams.get("tab") === "customize") {
setActiveTab("customize");
}
}, [searchParams]);
const [loadError, setLoadError] = useState<string | null>(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 <p className="text-sm text-red-500">{loadError}</p>;
}
if (!customization) {
return (
<p className="text-sm text-muted-foreground">Loading your pet...</p>
);
}
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/30 p-1">
{(
[
{ id: "home", label: "Overview" },
{ id: "customize", label: "Customize" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
))}
</div>
<div className="grid gap-3 rounded-lg border bg-card p-4">
<Label htmlFor="avatar-name-shared">Pet name</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="avatar-name-shared"
value={avatarName}
maxLength={32}
onChange={(event) => setAvatarName(event.target.value)}
/>
<Button
type="button"
variant="outline"
disabled={isSavingName}
onClick={() => void handleSaveName()}
>
{isSavingName ? "Saving..." : "Save name"}
</Button>
</div>
</div>
{activeTab === "home" ? (
<>
<AvatarStatusCard customization={customization} />
<HabitTracker />
<DailyQuizLinkButton />
</>
) : (
<CharacterCreator
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${avatarName}`}
initialCustomization={{
...customization,
name: avatarName,
}}
name={avatarName}
onNameChange={setAvatarName}
ownedVariantIds={ownedVariantIds}
showNameField
saveLabel={isSavingAvatar ? "Saving..." : "Save changes"}
isSaving={isSavingAvatar}
onSave={handleSaveAvatar}
/>
)}
</div>
);
}

View File

@@ -3,13 +3,11 @@
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useState } from "react"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Card, CardContent } from "@/components/ui/card";
Card, import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
CardContent,
} from "@/components/ui/card";
import { import {
defaultAvatarCondition, defaultAvatarCondition,
WELLNESS_SCALE_MAX, WELLNESS_SCALE_MAX,
@@ -23,7 +21,11 @@ import {
import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage"; import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
export function AvatarStatusCard() { type AvatarStatusCardProps = {
customization: AvatarCustomization;
};
export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
const [condition, setCondition] = useState<AvatarCondition>( const [condition, setCondition] = useState<AvatarCondition>(
defaultAvatarCondition, defaultAvatarCondition,
); );
@@ -69,7 +71,21 @@ export function AvatarStatusCard() {
return ( return (
<Card> <Card>
<CardContent className="flex flex-col items-center gap-4 pt-6"> <CardContent className="flex flex-col items-center gap-4 pt-6">
<PixelAvatar mood={condition.mood} /> <div className="text-center">
<p className="text-lg font-semibold tracking-tight">
{customization.name}
</p>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{condition.mood}
</p>
</div>
<CharacterLayerPreview
colors={customization.colors}
variants={customization.variants}
equippedItems={customization.equippedItems}
scale={8}
className="w-full max-w-xs"
/>
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
<Badge variant="secondary"> <Badge variant="secondary">
Energy {condition.energy}/{WELLNESS_SCALE_MAX} Energy {condition.energy}/{WELLNESS_SCALE_MAX}

View File

@@ -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 (
<div className="grid grid-cols-9 gap-2">
{presets.map((preset) => (
<button
key={preset.id}
type="button"
title={preset.name}
aria-label={preset.name}
aria-pressed={activeId === preset.id}
onClick={() => onSelect(preset)}
className={cn(
"aspect-square size-full max-h-9 max-w-9 rounded-md border transition-colors",
activeId === preset.id
? "border-foreground ring-1 ring-foreground/40 ring-offset-1 ring-offset-background"
: "border-border/60 hover:border-border",
)}
style={{ backgroundColor: preset.hex }}
/>
))}
</div>
);
}

View File

@@ -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 (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>{label}</Label>
<span className="text-xs tabular-nums text-muted-foreground">
{shownValue}
</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(event) => 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
}
/>
</div>
);
}
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 (
<div className="grid gap-5">
<ColorSlider
label="Hue"
value={color.h}
min={0}
max={360}
gradient={hueGradient}
onChange={(h) => onChange({ ...color, h })}
/>
<ColorSlider
label="Saturation"
value={color.s}
min={0}
max={100}
gradient={saturationGradient}
onChange={(s) => onChange({ ...color, s })}
/>
<ColorSlider
label="Brightness"
value={brightnessDisplay}
displayValue={brightnessDisplay}
min={0}
max={100}
gradient={lightnessGradient}
onChange={(display) =>
onChange(clampHsl({ ...color, l: displayToLightness(display) }))
}
/>
</div>
);
}

View File

@@ -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 (
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{children}
</h3>
);
}
type CharacterCreatorProps = {
initialCustomization?: AvatarCustomization;
name?: string;
onNameChange?: (name: string) => void;
showNameField?: boolean;
saveLabel?: string;
isSaving?: boolean;
ownedVariantIds?: string[];
onSave?: (customization: AvatarCustomization) => Promise<void>;
};
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<LayerColorState>(
initialCustomization?.colors ?? { ...LAYER_DEFAULT_COLORS },
);
const [variants, setVariants] = useState<LayerVariantState>(
initialCustomization?.variants ?? buildDefaultVariants(),
);
const [activePresetId, setActivePresetId] = useState<string>(
DEFAULT_GRAY_COLOR.id,
);
const [activeLayerId, setActiveLayerId] = useState<
(typeof CHARACTER_LAYERS)[number]["id"]
>("skin");
const [saveError, setSaveError] = useState<string | null>(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 (
<div className="grid gap-5">
{showNameField ? (
<div className="grid max-w-md gap-2">
<Label htmlFor="avatar-name">Pet name</Label>
<Input
id="avatar-name"
value={name}
maxLength={32}
placeholder="Name your pet"
onChange={(event) => setName(event.target.value)}
/>
</div>
) : null}
<CharacterLayerTabs
activeLayerId={activeLayerId}
onLayerChange={handleLayerChange}
/>
<div className="grid items-stretch gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<Card className="flex flex-col overflow-hidden border-border/60 shadow-sm">
<CardContent className="flex flex-1 items-center justify-center p-6">
<CharacterLayerPreview
colors={colors}
variants={variants}
scale={10}
className="w-full"
/>
</CardContent>
</Card>
<Card className="flex flex-col border-border/60 shadow-sm">
<CardContent className="flex flex-1 flex-col gap-5 p-6">
<section className="grid h-[5.75rem] shrink-0 gap-3">
<SectionLabel>Style</SectionLabel>
<div className="min-h-14">
<CharacterPieceSelector
variants={pieceVariants}
lockedVariantIds={lockedVariantIds}
activeId={variants[activeLayerId]}
color={activeColor}
skinColor={colors.skin}
onSelect={handleVariantChange}
/>
</div>
</section>
<section className="grid shrink-0 gap-3">
<SectionLabel>Color</SectionLabel>
<div className="min-h-[4.75rem]">
<CharacterColorPresets
presets={COLOR_PRESETS}
activeId={activePresetId}
onSelect={applyPreset}
/>
</div>
</section>
<section className="grid shrink-0 gap-4">
<SectionLabel>Adjust</SectionLabel>
<CharacterColorSliders
color={activeColor}
onChange={updateActiveColor}
/>
</section>
{onSave ? (
<div className="grid gap-2 pt-1">
<Button
id="avatar-customization-save"
type="button"
disabled={isSaving}
onClick={() => void handleSave()}
>
{isSaving ? "Saving..." : saveLabel}
</Button>
{saveError ? (
<p className="text-sm text-red-500">{saveError}</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -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<HTMLCanvasElement>(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 (
<div
className={cn(
"relative flex items-center justify-center",
compact ? "h-[180px]" : "h-[320px]",
className,
)}
>
<canvas
ref={canvasRef}
className="block"
style={{
imageRendering: "pixelated",
width: displaySize.width || undefined,
height: displaySize.height || undefined,
}}
/>
</div>
);
}

View File

@@ -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 (
<div
role="tablist"
aria-label="Character layers"
className="flex gap-0.5 overflow-x-auto rounded-lg border bg-muted/30 p-1"
>
{CHARACTER_LAYERS.map((layer) => (
<button
key={layer.id}
type="button"
role="tab"
aria-selected={activeLayerId === layer.id}
onClick={() => onLayerChange(layer.id)}
className={cn(
"min-w-[4.5rem] flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors",
activeLayerId === layer.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{layer.label}
</button>
))}
</div>
);
}

View File

@@ -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<string>;
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 (
<div className="grid grid-cols-8 gap-2">
{slots.map((variant, index) => {
if (!variant) {
return (
<div
key={`empty-${index}`}
aria-hidden
className="size-14 rounded-lg border border-dashed border-border/50 bg-muted/15"
/>
);
}
if (variant.id === NONE_VARIANT_ID) {
return (
<button
key={variant.id}
type="button"
title={variant.label}
aria-label={variant.label}
aria-pressed={activeId === variant.id}
onClick={() => onSelect(variant.id)}
className={cn(
"flex size-14 items-center justify-center rounded-lg border bg-muted/20 transition-colors",
activeId === variant.id
? "border-foreground bg-muted/35 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
: "border-border/60 hover:border-border hover:bg-muted/30",
)}
>
<CircleSlash
className="size-6 text-muted-foreground"
strokeWidth={1.75}
aria-hidden
/>
</button>
);
}
const locked = lockedVariantIds?.has(variant.id) ?? false;
return (
<button
key={variant.id}
type="button"
disabled={locked}
aria-disabled={locked}
title={
locked ? `${variant.label} — unlock in the shop` : variant.label
}
aria-label={
locked ? `${variant.label}, locked — unlock in the shop` : variant.label
}
aria-pressed={activeId === variant.id}
onClick={() => onSelect(variant.id)}
className={cn(
"relative flex size-14 items-center justify-center rounded-lg border bg-zinc-950/80 p-1.5 transition-colors",
locked
? "cursor-not-allowed border-border/40 opacity-80"
: activeId === variant.id
? "border-foreground bg-zinc-900 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
: "border-border/60 hover:border-border hover:bg-zinc-900/60",
)}
>
<TintedSpriteIcon
src={variant.src}
color={color}
skinColor={skinColor}
size={36}
/>
{locked ? (
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/75"
>
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
</span>
) : null}
</button>
);
})}
</div>
);
}

View File

@@ -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<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
aria-hidden
className={cn("block max-h-full max-w-full", className)}
style={{
imageRendering: "pixelated",
width: size,
height: size,
}}
/>
);
}

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppNav } from "@/components/layout/app-nav"; 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"; import { createClient } from "@/lib/supabase/client";
export function GlobalBottomNav() { export function GlobalBottomNav() {
@@ -16,9 +18,13 @@ export function GlobalBottomNav() {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
setShowNav( if (!user) {
!!user && user.user_metadata?.onboarding_completed === true, setShowNav(false);
); return;
}
const status = await getOnboardingStatusClient();
setShowNav(status?.appComplete === true);
} }
void syncNavState(); void syncNavState();
@@ -29,7 +35,12 @@ export function GlobalBottomNav() {
void syncNavState(); 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) { if (!showNav) {

View File

@@ -1,18 +1,12 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import { getOnboardingRedirectPath, getSessionUser } from "@/lib/auth-helpers";
getSessionUser,
hasCompletedOnboarding,
} from "@/lib/auth-helpers";
import { routes } from "@/lib/routes";
export async function LoggedInRedirect() { export async function LoggedInRedirect() {
const user = await getSessionUser(); const user = await getSessionUser();
if (user) { if (user) {
redirect( redirect(await getOnboardingRedirectPath());
hasCompletedOnboarding(user) ? routes.avatar : routes.onboardingQuiz,
);
} }
return null; return null;

View File

@@ -1,10 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import { requireSessionUser } from "@/lib/auth-helpers";
hasCompletedOnboarding, import { fetchOnboardingStatusForUser } from "@/lib/onboarding-status";
requireSessionUser,
} from "@/lib/auth-helpers";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
import { createClient } from "@/lib/supabase/server";
type OnboardingAuthGateProps = { type OnboardingAuthGateProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -12,8 +11,10 @@ type OnboardingAuthGateProps = {
export async function OnboardingAuthGate({ children }: OnboardingAuthGateProps) { export async function OnboardingAuthGate({ children }: OnboardingAuthGateProps) {
const user = await requireSessionUser(); const user = await requireSessionUser();
const supabase = await createClient();
const status = await fetchOnboardingStatusForUser(user.id, supabase);
if (hasCompletedOnboarding(user)) { if (status.appComplete) {
redirect(routes.avatar); redirect(routes.avatar);
} }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 (
<div className="min-h-svh bg-background">
<main className="mx-auto flex min-h-svh max-w-6xl flex-col justify-center px-4 py-10">
<div className="mb-8 space-y-2 text-center">
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
Step 2 of 2
</p>
<h1 className="text-3xl font-semibold tracking-tight">
Create your Habit Pet
</h1>
<p className="mx-auto max-w-2xl text-sm text-muted-foreground">
Customize your pet&apos;s look, give them a name, and confirm before
entering the app.
</p>
</div>
<Card className="border-border/60 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Avatar customization</CardTitle>
<CardDescription>
Pick styles and colors for each layer. You can change these later
from the Pet tab.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CharacterCreator
initialCustomization={defaultAvatarCustomization}
name={avatarName}
onNameChange={setAvatarName}
showNameField
saveLabel={isSubmitting ? "Saving..." : "Confirm avatar"}
isSaving={isSubmitting}
onSave={handleSave}
/>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@@ -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<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-svh bg-background">
<main className="mx-auto flex min-h-svh max-w-lg flex-col justify-center px-5 py-10">
<div className="mb-8 text-center">
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
Step 1 of 2
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">
Welcome to Habit Pet
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Choose a focus area, then design your pet in the next step.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
What habit do you most want to build first?
</CardTitle>
<CardDescription>
This helps personalize your daily tasks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{focusTopicOptions.map((option) => {
const selected = focusTopic === option;
return (
<button
key={option}
type="button"
onClick={() => setFocusTopic(option)}
className={`w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors ${
selected
? "border-primary bg-primary/10"
: "hover:bg-muted"
}`}
>
<Label className="cursor-pointer">{option}</Label>
</button>
);
})}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Button
className="w-full"
disabled={!focusTopic || isSubmitting}
onClick={() => void handleContinue()}
>
{isSubmitting ? "Saving..." : "Continue to avatar"}
</Button>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { TintedSpriteIcon } from "@/components/character/tinted-sprite-icon";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -11,33 +12,46 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useToast } from "@/components/ui/toast-provider";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
import { import {
getShopState, equipShopItem,
getShopInventory,
purchaseShopItem, purchaseShopItem,
type ShopItem, unequipShopItem,
} from "@/lib/shop-storage"; } from "@/lib/shop-storage";
import {
getLayerLabel,
type ShopItemRecord,
type ShopLayerId,
} from "@/lib/shop-catalog";
function formatItemType(type: string) { const SHOP_LAYER_ORDER: ShopLayerId[] = [
return type.charAt(0).toUpperCase() + type.slice(1); "head",
} "torso",
"pants",
"shoes",
"eyes",
];
export function ShopContent() { export function ShopContent() {
const [coins, setCoins] = useState<number | null>(null); const { toast } = useToast();
const [items, setItems] = useState<ShopItem[]>([]); const [items, setItems] = useState<ShopItemRecord[]>([]);
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]); const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
const [equippedItems, setEquippedItems] = useState<string[]>([]);
const [coins, setCoins] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [purchaseError, setPurchaseError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [pendingItemId, setPendingItemId] = useState<string | null>(null); const [pendingItemId, setPendingItemId] = useState<string | null>(null);
const refreshShop = useCallback(async () => { const refreshShop = useCallback(async () => {
try { try {
setError(null); setError(null);
const state = await getShopState(); const inventory = await getShopInventory();
setCoins(state.coins); setItems(inventory.items);
setItems(state.items); setOwnedItemIds(inventory.ownedItemIds);
setOwnedItemIds(state.ownedItemIds); setEquippedItems(inventory.equippedItems);
setCoins(inventory.coins);
} catch (refreshError) { } catch (refreshError) {
setError( setError(
refreshError instanceof Error refreshError instanceof Error
@@ -45,10 +59,6 @@ export function ShopContent() {
: "Could not load the shop.", : "Could not load the shop.",
); );
setCoins(0); setCoins(0);
setItems([]);
setOwnedItemIds([]);
} finally {
setIsLoading(false);
} }
}, []); }, []);
@@ -61,30 +71,56 @@ export function ShopContent() {
}; };
}, [refreshShop]); }, [refreshShop]);
const handlePurchase = async (item: ShopItem) => { const handlePurchase = async (itemId: string) => {
if (pendingItemId) { setPendingItemId(itemId);
return;
}
try { try {
setPurchaseError(null); const item = await purchaseShopItem(itemId);
setPendingItemId(item.id); toast(`Purchased ${item.name}.`, "success");
const remainingCoins = await purchaseShopItem(item.id); await refreshShop();
setCoins(remainingCoins); } catch (purchaseError) {
setOwnedItemIds((current) => toast(
current.includes(item.id) ? current : [...current, item.id], purchaseError instanceof Error
); ? purchaseError.message
} catch (purchaseFailure) { : "Purchase failed.",
setPurchaseError( "error",
purchaseFailure instanceof Error
? purchaseFailure.message
: "Could not complete purchase.",
); );
} finally { } finally {
setPendingItemId(null); 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 ( return (
<> <>
<div className="mb-4 flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-3"> <div className="mb-4 flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-3">
@@ -95,63 +131,76 @@ export function ShopContent() {
</div> </div>
{error ? <p className="mb-4 text-sm text-red-500">{error}</p> : null} {error ? <p className="mb-4 text-sm text-red-500">{error}</p> : null}
{purchaseError ? (
<p className="mb-4 text-sm text-red-500">{purchaseError}</p>
) : null}
{isLoading ? ( <div className="grid gap-6">
<p className="text-sm text-muted-foreground">Loading shop items...</p> {groupedItems.map((group) => (
) : items.length === 0 ? ( <section key={group.layerId} className="grid gap-3">
<p className="text-sm text-muted-foreground"> <h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
No items in the shop yet. Add rows to the shop_items table in {group.label}
Supabase. </h3>
</p> <div className="grid gap-4 sm:grid-cols-2">
) : ( {group.items.map((item) => {
<div className="grid gap-4"> const owned = ownedItemIds.includes(item.id);
{items.map((item) => { const equipped = equippedItems.includes(item.id);
const isOwned = ownedItemIds.includes(item.id);
const canAfford = coins !== null && coins >= item.price; const canAfford = coins !== null && coins >= item.price;
const isPending = pendingItemId === item.id; const isPending = pendingItemId === item.id;
return ( return (
<Card key={item.id}> <Card key={item.id}>
<CardHeader> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-lg border bg-zinc-950/80 p-1.5">
<TintedSpriteIcon
src={item.image_path}
color={DEFAULT_GRAY_COLOR.hsl}
size={36}
/>
</div>
<div> <div>
<CardTitle className="text-base">{item.name}</CardTitle> <CardTitle className="text-base">{item.name}</CardTitle>
<CardDescription> <CardDescription>{item.id}</CardDescription>
{formatItemType(item.type)} </div>
</CardDescription>
</div> </div>
<Badge>{item.price} pts</Badge> <Badge>{item.price} pts</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="grid gap-2">
{!owned ? (
<Button <Button
className="w-full" className="w-full"
variant={isOwned ? "secondary" : "outline"} variant="outline"
disabled={ disabled={coins === null || !canAfford || isPending}
isOwned || onClick={() => void handlePurchase(item.id)}
coins === null ||
isPending ||
(!isOwned && !canAfford)
}
onClick={() => void handlePurchase(item)}
> >
{isOwned {isPending
? "Owned" ? "Processing..."
: isPending
? "Purchasing..."
: canAfford : canAfford
? "Buy" ? "Buy"
: "Not enough points"} : "Not enough points"}
</Button> </Button>
) : (
<Button
className="w-full"
variant={equipped ? "default" : "outline"}
disabled={isPending}
onClick={() => void handleEquipToggle(item)}
>
{isPending
? "Updating..."
: equipped
? "Equipped"
: "Equip"}
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
})} })}
</div> </div>
)} </section>
))}
</div>
</> </>
); );
} }

View File

@@ -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<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<ToastMessage[]>([]);
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 (
<ToastContext.Provider value={value}>
{children}
<div
aria-live="polite"
className="pointer-events-none fixed bottom-24 right-4 z-50 flex w-[min(100vw-2rem,20rem)] flex-col gap-2"
>
{messages.map((message) => (
<div
key={message.id}
className={cn(
"rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur-sm",
message.variant === "success" &&
"border-emerald-500/30 bg-emerald-500/15 text-emerald-100",
message.variant === "error" &&
"border-red-500/30 bg-red-500/15 text-red-100",
message.variant === "default" &&
"border-border/60 bg-background/95 text-foreground",
)}
>
{message.title}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within ToastProvider.");
}
return context;
}

View File

@@ -1,12 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import {
fetchOnboardingStatusForUser,
getRedirectPathFromStatus,
} from "@/lib/onboarding-status";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
import type { User } from "@supabase/supabase-js"; import { createClient } from "@/lib/supabase/server";
export function hasCompletedOnboarding(user: User) {
return user.user_metadata?.onboarding_completed === true;
}
export async function getSessionUser() { export async function getSessionUser() {
const supabase = await createClient(); const supabase = await createClient();
@@ -32,10 +31,39 @@ export async function requireSessionUser() {
return user; 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() { export async function requireOnboardingComplete() {
const user = await requireSessionUser(); 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); redirect(routes.onboardingQuiz);
} }

View File

@@ -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<AvatarCustomization> {
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;
}

37
lib/avatar-display.ts Normal file
View File

@@ -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;
}

View File

@@ -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<HTMLImageElement> {
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<string, HTMLImageElement>();
export async function getCachedImage(src: string): Promise<HTMLImageElement> {
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);
}

193
lib/character/presets.ts Normal file
View File

@@ -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<CharacterLayerId, "skin">,
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<CharacterLayerId, "skin">,
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<CharacterLayerId, HSL> = {
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;

5
lib/character/types.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { HSL } from "./color-utils";
import type { CharacterLayerId } from "./presets";
export type LayerColorState = Record<CharacterLayerId, HSL>;
export type LayerVariantState = Record<CharacterLayerId, string>;

View File

@@ -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<string> | 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);
}

View File

@@ -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<CharacterLayerId, "skin">,
keyof LayerVariantState
> = {
pants: "pants",
shoes: "shoes",
torso: "torso",
eyes: "eyes",
head: "head",
};
const VALID_VARIANT_IDS: Record<CharacterLayerId, readonly string[]> = {
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<LayerVariantState> | 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 };

93
lib/onboarding-status.ts Normal file
View File

@@ -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<ReturnType<typeof createServerClient>>,
): Promise<OnboardingStatus> {
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<OnboardingStatus | null> {
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,
);
}

View File

@@ -178,7 +178,10 @@ function getOnboardingAnswersFromMetadata(
async function saveProfileRow( async function saveProfileRow(
userId: string, userId: string,
preferences: ProfilePreferences, preferences: ProfilePreferences,
options?: { markOnboardingComplete?: boolean }, options?: {
markOnboardingComplete?: boolean;
markQuizComplete?: boolean;
},
) { ) {
const supabase = createClient(); const supabase = createClient();
const serialized = serializeFocusTopicsField(preferences); const serialized = serializeFocusTopicsField(preferences);
@@ -186,9 +189,10 @@ async function saveProfileRow(
serialized, serialized,
[serialized], [serialized],
]; ];
const onboardingFields = options?.markOnboardingComplete const onboardingFields = {
? { onboarding_complete: true } ...(options?.markOnboardingComplete ? { onboarding_complete: true } : {}),
: {}; ...(options?.markQuizComplete ? { onboarding_quiz_complete: true } : {}),
};
let lastError: string | null = null; let lastError: string | null = null;
@@ -307,10 +311,7 @@ export async function saveProfilePreferences(preferences: ProfilePreferences) {
return normalizedPreferences; return normalizedPreferences;
} }
export async function saveOnboardingPreferences( export async function saveOnboardingPreferences(focusTopic: string) {
focusTopic: string,
avatarVibe: string,
) {
const userId = await getAuthenticatedUserId(); const userId = await getAuthenticatedUserId();
if (!userId) { if (!userId) {
@@ -318,16 +319,15 @@ export async function saveOnboardingPreferences(
} }
const supabase = createClient(); 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({ const { error: authError } = await supabase.auth.updateUser({
data: { data: {
onboarding_completed: true, onboarding_quiz_completed: true,
onboarding_answers: { onboarding_answers: {
focus: preferences.focusTopic, focus: preferences.focusTopic,
avatarVibe: preferences.avatarVibe,
}, },
}, },
}); });

View File

@@ -4,6 +4,7 @@ export const routes = {
signUp: "/auth/sign-up", signUp: "/auth/sign-up",
signUpSuccess: "/auth/sign-up-success", signUpSuccess: "/auth/sign-up-success",
onboardingQuiz: "/onboarding/quiz", onboardingQuiz: "/onboarding/quiz",
onboardingCustomize: "/onboarding/customize",
avatar: "/avatar", avatar: "/avatar",
habits: "/habits", habits: "/habits",
dailyQuiz: "/daily/quiz", dailyQuiz: "/daily/quiz",
@@ -27,6 +28,10 @@ export function isAuthPath(pathname: string) {
return pathname.startsWith("/auth"); return pathname.startsWith("/auth");
} }
export function isOnboardingPath(pathname: string) {
return pathname.startsWith("/onboarding");
}
export function isPublicPath(pathname: string) { export function isPublicPath(pathname: string) {
return pathname === routes.home || isAuthPath(pathname); return pathname === routes.home || isAuthPath(pathname);
} }

91
lib/shop-catalog.ts Normal file
View File

@@ -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<CharacterLayerId, "skin">;
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<ShopLayerId>([
"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<ShopLayerId, number> = {
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;
}

View File

@@ -1,45 +1,11 @@
import { notifyHabitPetDataUpdated } from "@/lib/app-events"; 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"; 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() { async function getAuthenticatedUserId() {
const supabase = createClient(); const supabase = createClient();
const { const {
@@ -54,19 +20,18 @@ async function getAuthenticatedUserId() {
return user.id; return user.id;
} }
export async function getShopItems(): Promise<ShopItem[]> { export async function getShopItems(): Promise<ShopItemRecord[]> {
const supabase = createClient(); const supabase = createClient();
const { data, error } = await supabase const { data, error } = await supabase
.from("shop_items") .from("shop_items")
.select("id, name, type") .select("id, name, type, price, image_path")
.order("type", { ascending: true }) .order("price", { ascending: true });
.order("name", { ascending: true });
if (error) { if (error) {
throw new Error(error.message); throw new Error(error.message);
} }
return ((data ?? []) as ShopItemRow[]).map(mapShopItemRow); return (data ?? []) as ShopItemRecord[];
} }
export async function getOwnedItemIds(): Promise<string[]> { export async function getOwnedItemIds(): Promise<string[]> {
@@ -89,7 +54,28 @@ export async function getOwnedItemIds(): Promise<string[]> {
return (data ?? []).map((row) => row.item_id as string); return (data ?? []).map((row) => row.item_id as string);
} }
export async function purchaseShopItem(itemId: string): Promise<number> { export async function getEquippedItems(): Promise<string[]> {
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(); const userId = await getAuthenticatedUserId();
if (!userId) { if (!userId) {
@@ -97,9 +83,9 @@ export async function purchaseShopItem(itemId: string): Promise<number> {
} }
const supabase = createClient(); const supabase = createClient();
const { data: itemRow, error: itemError } = await supabase const { data: item, error: itemError } = await supabase
.from("shop_items") .from("shop_items")
.select("id, name, type") .select("id, name, type, price, image_path")
.eq("id", itemId) .eq("id", itemId)
.maybeSingle(); .maybeSingle();
@@ -107,28 +93,23 @@ export async function purchaseShopItem(itemId: string): Promise<number> {
throw new Error(itemError.message); throw new Error(itemError.message);
} }
if (!itemRow) { if (!item) {
throw new Error("Item not found."); 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 if (owned.includes(itemId)) {
.from("user_items")
.select("item_id")
.eq("user_id", userId)
.eq("item_id", itemId)
.maybeSingle();
if (existingError) {
throw new Error(existingError.message);
}
if (existingPurchase) {
throw new Error("You already own this item."); 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({ const { error: purchaseError } = await supabase.from("user_items").insert({
user_id: userId, user_id: userId,
@@ -141,19 +122,106 @@ export async function purchaseShopItem(itemId: string): Promise<number> {
} }
notifyHabitPetDataUpdated(); notifyHabitPetDataUpdated();
return remainingCoins; return {
...(item as ShopItemRecord),
type: item.type as ShopItemRecord["type"],
};
} }
export async function getShopState(): Promise<{ export async function equipShopItem(itemId: string) {
coins: number; const userId = await getAuthenticatedUserId();
items: ShopItem[];
ownedItemIds: string[]; if (!userId) {
}> { throw new Error("You must be signed in to equip items.");
const [coins, items, ownedItemIds] = await Promise.all([ }
getCoins(),
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(), getShopItems(),
getOwnedItemIds(), getOwnedItemIds(),
getEquippedItems(),
getCoins(),
]); ]);
return { coins, items, ownedItemIds }; return { items, ownedItemIds, equippedItems, coins };
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -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.';

View File

@@ -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;

View File

@@ -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 values
('hat_blue', 'Blue Hat', 'accessory', 20, '/items/hat-blue.png'), ('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'),
('glasses_round', 'Round Glasses', 'accessory', 30, '/items/glasses-round.png'), ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'),
('room_sunset', 'Sunset Room', 'background', 40, '/backgrounds/room-sunset.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 on conflict (id) do update
set set
name = excluded.name, name = excluded.name,