Merge character customization branch and resolve avatar page conflict
Co-authored-by: Cursor <cursoragent@cursor.com>
120
README.md
@@ -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.
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
7
app/character-creator/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function CharacterCreatorPage() {
|
||||||
|
redirect(`${routes.avatar}?tab=customize`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
14
app/onboarding/customize/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
191
components/avatar/avatar-page-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
38
components/character/character-color-presets.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
components/character/character-color-sliders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
components/character/character-creator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
components/character/character-layer-preview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
components/character/character-layer-tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
components/character/character-piece-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
components/character/tinted-sprite-icon.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
components/layout/onboarding-customize-auth-gate.tsx
Normal 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;
|
||||||
|
}
|
||||||
26
components/layout/onboarding-quiz-auth-gate.tsx
Normal 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;
|
||||||
|
}
|
||||||
94
components/onboarding/onboarding-customize-form.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/onboarding/onboarding-quiz-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
79
components/ui/toast-provider.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
270
lib/avatar-customization-storage.ts
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
258
lib/character/color-utils.ts
Normal 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
@@ -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
@@ -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>;
|
||||||
39
lib/character/variant-access.ts
Normal 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);
|
||||||
|
}
|
||||||
79
lib/character/variant-validation.ts
Normal 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
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/character/eyes/eyes-1.png
Normal file
|
After Width: | Height: | Size: 431 B |
BIN
public/character/eyes/eyes-2.png
Normal file
|
After Width: | Height: | Size: 438 B |
BIN
public/character/head/head-1.png
Normal file
|
After Width: | Height: | Size: 282 B |
BIN
public/character/head/head-2.png
Normal file
|
After Width: | Height: | Size: 283 B |
BIN
public/character/head/head-3.png
Normal file
|
After Width: | Height: | Size: 250 B |
BIN
public/character/head/head-4.png
Normal file
|
After Width: | Height: | Size: 315 B |
BIN
public/character/head/head-5.png
Normal file
|
After Width: | Height: | Size: 312 B |
BIN
public/character/head/head-6.png
Normal file
|
After Width: | Height: | Size: 333 B |
BIN
public/character/head/head-7.png
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
public/character/pants/pants-1.png
Normal file
|
After Width: | Height: | Size: 637 B |
BIN
public/character/pants/pants-2.png
Normal file
|
After Width: | Height: | Size: 296 B |
BIN
public/character/pants/pants-3.png
Normal file
|
After Width: | Height: | Size: 351 B |
BIN
public/character/shoes/shoes-1.png
Normal file
|
After Width: | Height: | Size: 258 B |
BIN
public/character/shoes/shoes-2.png
Normal file
|
After Width: | Height: | Size: 269 B |
BIN
public/character/shoes/shoes-3.png
Normal file
|
After Width: | Height: | Size: 267 B |
BIN
public/character/skin/skin-1.png
Normal file
|
After Width: | Height: | Size: 583 B |
BIN
public/character/torso/torso-1.png
Normal file
|
After Width: | Height: | Size: 409 B |
BIN
public/character/torso/torso-2.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
public/character/torso/torso-3.png
Normal file
|
After Width: | Height: | Size: 396 B |
BIN
public/character/torso/torso-4.png
Normal file
|
After Width: | Height: | Size: 419 B |
@@ -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.';
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||