Stable version with additional styling
This commit is contained in:
@@ -7,7 +7,7 @@ export default function AvatarPage() {
|
|||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Your Habit Pet"
|
title="Your Habit Pet"
|
||||||
description="Customize your pet, complete AI-personalized daily tasks, and track their mood from your wellness check-in."
|
description="Watch your pet roam, breathe, and react to how you care for them."
|
||||||
>
|
>
|
||||||
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading...</p>}>
|
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading...</p>}>
|
||||||
<AvatarPageContent />
|
<AvatarPageContent />
|
||||||
|
|||||||
18
app/(app)/customize/page.tsx
Normal file
18
app/(app)/customize/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { CustomizePageContent } from "@/components/avatar/customize-page-content";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
|
||||||
|
export default function CustomizePage() {
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
wide
|
||||||
|
title="Customize your pet"
|
||||||
|
description="Change your pet's name, styles, colors, and room."
|
||||||
|
>
|
||||||
|
<Suspense fallback={<p className="text-xs text-muted-foreground">Loading...</p>}>
|
||||||
|
<CustomizePageContent />
|
||||||
|
</Suspense>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ export default function HabitsPage() {
|
|||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Habits"
|
title="Habits"
|
||||||
description="Add custom habits and see how your daily tasks are chosen."
|
description="Complete today's tasks and manage your habit list."
|
||||||
>
|
>
|
||||||
<HabitTracker mode="manage" />
|
<HabitTracker mode="all" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ export default function AppLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="min-h-svh bg-background" />}>
|
<Suspense fallback={<div className="bg-background pb-nav" />}>
|
||||||
<AppAuthGate>
|
<AppAuthGate>
|
||||||
<DailyReminderProvider />
|
<DailyReminderProvider />
|
||||||
<InAppReminderBanner />
|
<InAppReminderBanner />
|
||||||
{children}
|
<div className="pb-nav">{children}</div>
|
||||||
</AppAuthGate>
|
</AppAuthGate>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import { redirect } from "next/navigation";
|
|||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
export default function CharacterCreatorPage() {
|
export default function CharacterCreatorPage() {
|
||||||
redirect(`${routes.avatar}?tab=customize`);
|
redirect(routes.customize);
|
||||||
}
|
}
|
||||||
|
|||||||
229
app/globals.css
229
app/globals.css
@@ -4,57 +4,58 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--bottom-nav-height: 6rem;
|
||||||
--foreground: 0 0% 3.9%;
|
--background: 252 38% 10%;
|
||||||
--card: 0 0% 100%;
|
--foreground: 52 100% 88%;
|
||||||
--card-foreground: 0 0% 3.9%;
|
--card: 252 32% 14%;
|
||||||
--popover: 0 0% 100%;
|
--card-foreground: 52 100% 88%;
|
||||||
--popover-foreground: 0 0% 3.9%;
|
--popover: 252 32% 14%;
|
||||||
--primary: 0 0% 9%;
|
--popover-foreground: 52 100% 88%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary: 318 100% 62%;
|
||||||
--secondary: 0 0% 96.1%;
|
--primary-foreground: 252 38% 10%;
|
||||||
--secondary-foreground: 0 0% 9%;
|
--secondary: 198 100% 45%;
|
||||||
--muted: 0 0% 96.1%;
|
--secondary-foreground: 252 38% 10%;
|
||||||
--muted-foreground: 0 0% 45.1%;
|
--muted: 252 24% 20%;
|
||||||
--accent: 0 0% 96.1%;
|
--muted-foreground: 52 40% 68%;
|
||||||
--accent-foreground: 0 0% 9%;
|
--accent: 142 70% 45%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--accent-foreground: 252 38% 10%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive: 0 84% 58%;
|
||||||
--border: 0 0% 89.8%;
|
--destructive-foreground: 52 100% 96%;
|
||||||
--input: 0 0% 89.8%;
|
--border: 280 45% 38%;
|
||||||
--ring: 0 0% 3.9%;
|
--input: 280 35% 28%;
|
||||||
--chart-1: 12 76% 61%;
|
--ring: 318 100% 62%;
|
||||||
--chart-2: 173 58% 39%;
|
--chart-1: 318 100% 62%;
|
||||||
--chart-3: 197 37% 24%;
|
--chart-2: 198 100% 45%;
|
||||||
--chart-4: 43 74% 66%;
|
--chart-3: 142 70% 45%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-4: 48 100% 55%;
|
||||||
--radius: 0.5rem;
|
--chart-5: 280 70% 55%;
|
||||||
|
--radius: 0px;
|
||||||
|
--retro-shadow: 4px 4px 0 0 hsl(280 45% 28%);
|
||||||
|
--retro-shadow-sm: 2px 2px 0 0 hsl(280 45% 28%);
|
||||||
|
--lcd-bg: 142 35% 22%;
|
||||||
|
--lcd-border: 142 45% 35%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 3.9%;
|
--background: 252 38% 10%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 52 100% 88%;
|
||||||
--card: 0 0% 3.9%;
|
--card: 252 32% 14%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 52 100% 88%;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 252 32% 14%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 52 100% 88%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 318 100% 62%;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 252 38% 10%;
|
||||||
--secondary: 0 0% 14.9%;
|
--secondary: 198 100% 45%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 252 38% 10%;
|
||||||
--muted: 0 0% 14.9%;
|
--muted: 252 24% 20%;
|
||||||
--muted-foreground: 0 0% 63.9%;
|
--muted-foreground: 52 40% 68%;
|
||||||
--accent: 0 0% 14.9%;
|
--accent: 142 70% 45%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 252 38% 10%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 84% 58%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 52 100% 96%;
|
||||||
--border: 0 0% 14.9%;
|
--border: 280 45% 38%;
|
||||||
--input: 0 0% 14.9%;
|
--input: 280 35% 28%;
|
||||||
--ring: 0 0% 83.1%;
|
--ring: 318 100% 62%;
|
||||||
--chart-1: 220 70% 50%;
|
|
||||||
--chart-2: 160 60% 45%;
|
|
||||||
--chart-3: 30 80% 55%;
|
|
||||||
--chart-4: 280 65% 60%;
|
|
||||||
--chart-5: 340 75% 55%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +63,135 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply overflow-x-hidden bg-background text-foreground text-xs leading-relaxed;
|
||||||
|
font-family: var(--font-pixel), monospace;
|
||||||
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.pixel-border {
|
||||||
|
border: 2px solid hsl(var(--border));
|
||||||
|
box-shadow: var(--retro-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-border-sm {
|
||||||
|
border: 2px solid hsl(var(--border));
|
||||||
|
box-shadow: var(--retro-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-inset {
|
||||||
|
box-shadow: inset 0 0 0 2px hsl(var(--border) / 0.5),
|
||||||
|
inset 3px 3px 0 0 hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tamagotchi-shell {
|
||||||
|
@apply pixel-border bg-card;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--card)) 0%,
|
||||||
|
hsl(var(--muted)) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tamagotchi-lcd {
|
||||||
|
@apply pixel-inset relative overflow-hidden;
|
||||||
|
background: hsl(var(--lcd-bg));
|
||||||
|
border: 3px solid hsl(var(--lcd-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Matches pet-tab LCD: max-w-lg main minus px-5 and shell p-4 */
|
||||||
|
.tamagotchi-lcd-pet-match {
|
||||||
|
@apply aspect-[4/3] w-full shrink-0;
|
||||||
|
max-width: calc(32rem - 2.5rem - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customize-body {
|
||||||
|
min-height: 26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.customize-body {
|
||||||
|
min-height: 30rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.retro-btn {
|
||||||
|
@apply inline-flex items-center justify-center border-2 border-border bg-primary text-primary-foreground;
|
||||||
|
box-shadow: var(--retro-shadow-sm);
|
||||||
|
transition: transform 0.08s steps(2), box-shadow 0.08s steps(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retro-btn:hover:not(:disabled) {
|
||||||
|
transform: translate(-1px, -1px);
|
||||||
|
box-shadow: 3px 3px 0 0 hsl(280 45% 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retro-btn:active:not(:disabled) {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retro-card {
|
||||||
|
@apply border-2 border-border bg-card text-card-foreground;
|
||||||
|
box-shadow: var(--retro-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.pb-nav {
|
||||||
|
padding-bottom: var(--bottom-nav-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-pixelated {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pet-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes parallax-drift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate3d(6px, -4px, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes star-twinkle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pet-breathe {
|
||||||
|
animation: pet-breathe 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-parallax-drift {
|
||||||
|
animation: parallax-drift 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-star-twinkle {
|
||||||
|
animation: star-twinkle 3s steps(2, end) infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Press_Start_2P } 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";
|
||||||
@@ -16,10 +16,11 @@ export const metadata: Metadata = {
|
|||||||
description: "Build habits, care for your pixel pet, and track wellness every day.",
|
description: "Build habits, care for your pixel pet, and track wellness every day.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const geistSans = Geist({
|
const pixelFont = Press_Start_2P({
|
||||||
variable: "--font-geist-sans",
|
weight: "400",
|
||||||
display: "swap",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
variable: "--font-pixel",
|
||||||
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -29,7 +30,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.className} antialiased`}>
|
<body className={`${pixelFont.variable} font-sans`}>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
@@ -37,7 +38,7 @@ export default function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<div className="min-h-svh pb-24">{children}</div>
|
{children}
|
||||||
<GlobalBottomNav />
|
<GlobalBottomNav />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
27
components/avatar/avatar-customize-layout.tsx
Normal file
27
components/avatar/avatar-customize-layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { AppPageHeader } from "@/components/layout/app-page-header";
|
||||||
|
|
||||||
|
type AvatarCustomizeLayoutProps = {
|
||||||
|
eyebrow?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AvatarCustomizeLayout({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: AvatarCustomizeLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative bg-background bg-[radial-gradient(circle_at_20%_0%,hsl(var(--primary)/0.12)_0%,transparent_45%),radial-gradient(circle_at_80%_100%,hsl(var(--secondary)/0.1)_0%,transparent_40%)]">
|
||||||
|
<AppPageHeader
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-6xl px-5 py-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { CharacterCreator } from "@/components/character/character-creator";
|
import { PetHabitat } from "@/components/pet/pet-habitat";
|
||||||
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 { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
import {
|
import {
|
||||||
getAvatarCustomization,
|
getAvatarCustomization,
|
||||||
saveAvatarCustomization,
|
|
||||||
saveAvatarName,
|
|
||||||
type AvatarCustomization,
|
type AvatarCustomization,
|
||||||
} from "@/lib/avatar-customization-storage";
|
} from "@/lib/avatar-customization-storage";
|
||||||
import { getOwnedItemIds } from "@/lib/shop-storage";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type AvatarTab = "home" | "customize";
|
|
||||||
|
|
||||||
export function AvatarPageContent() {
|
export function AvatarPageContent() {
|
||||||
const { toast } = useToast();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [activeTab, setActiveTab] = useState<AvatarTab>("home");
|
|
||||||
const [customization, setCustomization] = useState<AvatarCustomization | null>(
|
const [customization, setCustomization] = useState<AvatarCustomization | null>(
|
||||||
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 [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadCustomization = useCallback(async () => {
|
const loadCustomization = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
const [next, owned] = await Promise.all([
|
setCustomization(await getAvatarCustomization());
|
||||||
getAvatarCustomization(),
|
|
||||||
getOwnedItemIds(),
|
|
||||||
]);
|
|
||||||
setCustomization(next);
|
|
||||||
setAvatarName(next.name);
|
|
||||||
setOwnedVariantIds(owned);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoadError(
|
setLoadError(
|
||||||
error instanceof Error ? error.message : "Could not load your avatar.",
|
error instanceof Error ? error.message : "Could not load your avatar.",
|
||||||
@@ -69,123 +35,15 @@ export function AvatarPageContent() {
|
|||||||
};
|
};
|
||||||
}, [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) {
|
if (loadError) {
|
||||||
return <p className="text-sm text-red-500">{loadError}</p>;
|
return <p className="text-xs text-red-500">{loadError}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!customization) {
|
if (!customization) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">Loading your pet...</p>
|
<p className="text-xs text-muted-foreground">Loading your pet...</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <PetHabitat customization={customization} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
97
components/avatar/customize-page-content.tsx
Normal file
97
components/avatar/customize-page-content.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { CharacterCreator } from "@/components/character/character-creator";
|
||||||
|
import { useToast } from "@/components/ui/toast-provider";
|
||||||
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||||
|
import {
|
||||||
|
getAvatarCustomization,
|
||||||
|
saveAvatarCustomization,
|
||||||
|
type AvatarCustomization,
|
||||||
|
} from "@/lib/avatar-customization-storage";
|
||||||
|
import { getOwnedItemIds } from "@/lib/shop-storage";
|
||||||
|
|
||||||
|
export function CustomizePageContent() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [customization, setCustomization] = useState<AvatarCustomization | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [avatarName, setAvatarName] = useState("");
|
||||||
|
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
|
||||||
|
const [ownedVariantIds, setOwnedVariantIds] = useState<string[]>([]);
|
||||||
|
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 handleSaveAvatar = async (nextCustomization: AvatarCustomization) => {
|
||||||
|
setIsSavingAvatar(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveAvatarCustomization({
|
||||||
|
...nextCustomization,
|
||||||
|
name: avatarName.trim() || nextCustomization.name,
|
||||||
|
equippedItems: customization?.equippedItems ?? [],
|
||||||
|
});
|
||||||
|
toast("Avatar updated.", "success");
|
||||||
|
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-xs text-red-500">{loadError}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customization) {
|
||||||
|
return <p className="text-xs text-muted-foreground">Loading customization...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CharacterCreator
|
||||||
|
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}`}
|
||||||
|
initialCustomization={{
|
||||||
|
...customization,
|
||||||
|
name: avatarName,
|
||||||
|
}}
|
||||||
|
name={avatarName}
|
||||||
|
onNameChange={setAvatarName}
|
||||||
|
ownedVariantIds={ownedVariantIds}
|
||||||
|
showNameField
|
||||||
|
saveLabel={isSavingAvatar ? "Saving..." : "Save changes"}
|
||||||
|
isSaving={isSavingAvatar}
|
||||||
|
onSave={handleSaveAvatar}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,24 +7,30 @@ import { CharacterColorSliders } from "@/components/character/character-color-sl
|
|||||||
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
|
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
|
||||||
import { CharacterLayerTabs } from "@/components/character/character-layer-tabs";
|
import { CharacterLayerTabs } from "@/components/character/character-layer-tabs";
|
||||||
import { CharacterPieceSelector } from "@/components/character/character-piece-selector";
|
import { CharacterPieceSelector } from "@/components/character/character-piece-selector";
|
||||||
|
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
|
||||||
|
import { RoomStyleSelector } from "@/components/pet/room-style-selector";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||||
import { clampHsl } from "@/lib/character/color-utils";
|
import { clampHsl } from "@/lib/character/color-utils";
|
||||||
import {
|
import {
|
||||||
CHARACTER_LAYERS,
|
|
||||||
COLOR_PRESETS,
|
COLOR_PRESETS,
|
||||||
DEFAULT_GRAY_COLOR,
|
DEFAULT_GRAY_COLOR,
|
||||||
LAYER_DEFAULT_COLORS,
|
LAYER_DEFAULT_COLORS,
|
||||||
NONE_VARIANT_ID,
|
NONE_VARIANT_ID,
|
||||||
buildDefaultVariants,
|
buildDefaultVariants,
|
||||||
getLayerById,
|
getLayerById,
|
||||||
|
type CharacterCreatorTabId,
|
||||||
type ColorPreset,
|
type ColorPreset,
|
||||||
} from "@/lib/character/presets";
|
} from "@/lib/character/presets";
|
||||||
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
||||||
import { isVariantUnlocked } from "@/lib/character/variant-access";
|
import { isVariantUnlocked } from "@/lib/character/variant-access";
|
||||||
|
import {
|
||||||
|
isRoomBackgroundUnlocked,
|
||||||
|
normalizeRoomBackgroundId,
|
||||||
|
type RoomBackgroundId,
|
||||||
|
} from "@/lib/room-backgrounds";
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -74,14 +80,20 @@ export function CharacterCreator({
|
|||||||
const [variants, setVariants] = useState<LayerVariantState>(
|
const [variants, setVariants] = useState<LayerVariantState>(
|
||||||
initialCustomization?.variants ?? buildDefaultVariants(),
|
initialCustomization?.variants ?? buildDefaultVariants(),
|
||||||
);
|
);
|
||||||
|
const [roomBackground, setRoomBackground] = useState<RoomBackgroundId>(
|
||||||
|
normalizeRoomBackgroundId(initialCustomization?.roomBackground),
|
||||||
|
);
|
||||||
const [activePresetId, setActivePresetId] = useState<string>(
|
const [activePresetId, setActivePresetId] = useState<string>(
|
||||||
DEFAULT_GRAY_COLOR.id,
|
DEFAULT_GRAY_COLOR.id,
|
||||||
);
|
);
|
||||||
const [activeLayerId, setActiveLayerId] = useState<
|
const [activeTabId, setActiveTabId] = useState<CharacterCreatorTabId>("skin");
|
||||||
(typeof CHARACTER_LAYERS)[number]["id"]
|
|
||||||
>("skin");
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isRoomTab = activeTabId === "room";
|
||||||
|
const isSkinTab = activeTabId === "skin";
|
||||||
|
const showStyleSection = !isSkinTab;
|
||||||
|
const showColorAdjust = !isRoomTab;
|
||||||
|
const activeLayerId = isRoomTab ? "skin" : activeTabId;
|
||||||
const activeLayer = getLayerById(activeLayerId);
|
const activeLayer = getLayerById(activeLayerId);
|
||||||
const activeColor = colors[activeLayerId];
|
const activeColor = colors[activeLayerId];
|
||||||
const ownedSet = new Set(ownedVariantIds);
|
const ownedSet = new Set(ownedVariantIds);
|
||||||
@@ -112,9 +124,14 @@ export function CharacterCreator({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => {
|
const handleTabChange = (tabId: CharacterCreatorTabId) => {
|
||||||
setActiveLayerId(layerId);
|
setActiveTabId(tabId);
|
||||||
const layerColor = colors[layerId];
|
|
||||||
|
if (tabId === "room") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerColor = colors[tabId];
|
||||||
const matchingPreset = COLOR_PRESETS.find(
|
const matchingPreset = COLOR_PRESETS.find(
|
||||||
(preset) =>
|
(preset) =>
|
||||||
preset.hsl.h === layerColor.h &&
|
preset.hsl.h === layerColor.h &&
|
||||||
@@ -135,12 +152,21 @@ export function CharacterCreator({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRoomChange = (roomId: RoomBackgroundId) => {
|
||||||
|
if (!isRoomBackgroundUnlocked(roomId, ownedVariantIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoomBackground(roomId);
|
||||||
|
};
|
||||||
|
|
||||||
const buildCustomization = (): AvatarCustomization => ({
|
const buildCustomization = (): AvatarCustomization => ({
|
||||||
name,
|
name,
|
||||||
colors,
|
colors,
|
||||||
variants,
|
variants,
|
||||||
customized: true,
|
customized: true,
|
||||||
equippedItems: initialCustomization?.equippedItems ?? [],
|
equippedItems: initialCustomization?.equippedItems ?? [],
|
||||||
|
roomBackground,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -160,9 +186,9 @@ export function CharacterCreator({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-5">
|
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
|
||||||
{showNameField ? (
|
{showNameField ? (
|
||||||
<div className="grid max-w-md gap-2">
|
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
|
||||||
<Label htmlFor="avatar-name">Pet name</Label>
|
<Label htmlFor="avatar-name">Pet name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="avatar-name"
|
id="avatar-name"
|
||||||
@@ -174,75 +200,89 @@ export function CharacterCreator({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<CharacterLayerTabs
|
<CharacterLayerTabs activeTabId={activeTabId} onTabChange={handleTabChange} />
|
||||||
activeLayerId={activeLayerId}
|
|
||||||
onLayerChange={handleLayerChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid items-stretch gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
|
<div className="customize-body mt-4 grid items-stretch gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
|
||||||
<Card className="flex flex-col overflow-hidden border-border/60 shadow-sm">
|
<div className="flex min-h-full min-w-0 flex-col">
|
||||||
<CardContent className="flex flex-1 items-center justify-center p-6">
|
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
||||||
<CharacterLayerPreview
|
<ParallaxRoomBackground roomId={roomBackground} interactive={false} />
|
||||||
colors={colors}
|
<div className="relative z-10 flex h-full items-center justify-center">
|
||||||
variants={variants}
|
<CharacterLayerPreview
|
||||||
scale={10}
|
colors={colors}
|
||||||
className="w-full"
|
variants={variants}
|
||||||
/>
|
scale={8}
|
||||||
</CardContent>
|
compact
|
||||||
</Card>
|
className="!h-auto max-h-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="flex flex-col border-border/60 shadow-sm">
|
<div className="flex min-h-full min-w-0 flex-col gap-5 border-t-2 border-border/60 pt-4 lg:border-l-2 lg:border-t-0 lg:pl-5 lg:pt-0">
|
||||||
<CardContent className="flex flex-1 flex-col gap-5 p-6">
|
{showStyleSection ? (
|
||||||
<section className="grid h-[5.75rem] shrink-0 gap-3">
|
<section className="grid shrink-0 gap-3">
|
||||||
<SectionLabel>Style</SectionLabel>
|
<SectionLabel>Style</SectionLabel>
|
||||||
<div className="min-h-14">
|
<div className="min-h-14">
|
||||||
<CharacterPieceSelector
|
{isRoomTab ? (
|
||||||
variants={pieceVariants}
|
<RoomStyleSelector
|
||||||
lockedVariantIds={lockedVariantIds}
|
activeId={roomBackground}
|
||||||
activeId={variants[activeLayerId]}
|
ownedItemIds={ownedVariantIds}
|
||||||
|
onSelect={handleRoomChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CharacterPieceSelector
|
||||||
|
variants={pieceVariants}
|
||||||
|
lockedVariantIds={lockedVariantIds}
|
||||||
|
activeId={variants[activeLayerId]}
|
||||||
|
color={activeColor}
|
||||||
|
skinColor={colors.skin}
|
||||||
|
onSelect={handleVariantChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showColorAdjust ? (
|
||||||
|
<>
|
||||||
|
<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}
|
color={activeColor}
|
||||||
skinColor={colors.skin}
|
onChange={updateActiveColor}
|
||||||
onSelect={handleVariantChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid shrink-0 gap-3">
|
{onSave ? (
|
||||||
<SectionLabel>Color</SectionLabel>
|
<div className="mt-auto grid gap-2 pt-1">
|
||||||
<div className="min-h-[4.75rem]">
|
<Button
|
||||||
<CharacterColorPresets
|
id="avatar-customization-save"
|
||||||
presets={COLOR_PRESETS}
|
type="button"
|
||||||
activeId={activePresetId}
|
disabled={isSaving}
|
||||||
onSelect={applyPreset}
|
onClick={() => void handleSave()}
|
||||||
/>
|
>
|
||||||
</div>
|
{isSaving ? "Saving..." : saveLabel}
|
||||||
</section>
|
</Button>
|
||||||
|
{saveError ? (
|
||||||
<section className="grid shrink-0 gap-4">
|
<p className="text-sm text-red-500">{saveError}</p>
|
||||||
<SectionLabel>Adjust</SectionLabel>
|
) : null}
|
||||||
<CharacterColorSliders
|
</div>
|
||||||
color={activeColor}
|
) : null}
|
||||||
onChange={updateActiveColor}
|
</div>
|
||||||
/>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,38 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CHARACTER_LAYERS, type CharacterLayerId } from "@/lib/character/presets";
|
import {
|
||||||
|
CHARACTER_CUSTOMIZATION_TABS,
|
||||||
|
type CharacterCreatorTabId,
|
||||||
|
} from "@/lib/character/presets";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type CharacterLayerTabsProps = {
|
type CharacterLayerTabsProps = {
|
||||||
activeLayerId: CharacterLayerId;
|
activeTabId: CharacterCreatorTabId;
|
||||||
onLayerChange: (layerId: CharacterLayerId) => void;
|
onTabChange: (tabId: CharacterCreatorTabId) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CharacterLayerTabs({
|
export function CharacterLayerTabs({
|
||||||
activeLayerId,
|
activeTabId,
|
||||||
onLayerChange,
|
onTabChange,
|
||||||
}: CharacterLayerTabsProps) {
|
}: CharacterLayerTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Character layers"
|
aria-label="Character customization"
|
||||||
className="flex gap-0.5 overflow-x-auto rounded-lg border bg-muted/30 p-1"
|
className="grid grid-cols-7 gap-0.5 border-2 border-border bg-muted/30 p-0.5"
|
||||||
>
|
>
|
||||||
{CHARACTER_LAYERS.map((layer) => (
|
{CHARACTER_CUSTOMIZATION_TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={layer.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeLayerId === layer.id}
|
aria-selected={activeTabId === tab.id}
|
||||||
onClick={() => onLayerChange(layer.id)}
|
onClick={() => onTabChange(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[4.5rem] flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
"min-w-0 truncate border-2 px-0.5 py-1.5 text-[7px] font-medium uppercase leading-tight tracking-wide transition-colors sm:px-1 sm:text-[8px]",
|
||||||
activeLayerId === layer.id
|
activeTabId === tab.id
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "border-secondary bg-background text-foreground shadow-[var(--retro-shadow-sm)]"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "border-transparent text-muted-foreground hover:border-border/60 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{layer.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { getProfilePreferences } from "@/lib/profile-preferences-storage";
|
|||||||
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
|
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
|
||||||
|
|
||||||
type HabitTrackerProps = {
|
type HabitTrackerProps = {
|
||||||
mode?: "daily" | "manage";
|
mode?: "daily" | "manage" | "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
const reasonLabels = {
|
const reasonLabels = {
|
||||||
@@ -409,6 +409,90 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === "all") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Today's tasks</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Personalized from your focus topics
|
||||||
|
{quizCompletedToday ? ", today's quiz," : ""} and custom habits.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||||
|
{coinMessage ? (
|
||||||
|
<p className="text-sm font-medium text-emerald-600">{coinMessage}</p>
|
||||||
|
) : null}
|
||||||
|
<DailyTaskList
|
||||||
|
tasks={dailyTasks}
|
||||||
|
getIsCompleted={getIsCompleted}
|
||||||
|
isPending={isPending}
|
||||||
|
getToggleError={getToggleError}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Custom habits</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add your own habits. Custom habits always appear in your daily
|
||||||
|
task list.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<CustomHabitList habits={customHabits} onRemove={handleRemoveHabit} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newHabitLabel}
|
||||||
|
placeholder="Add a custom habit"
|
||||||
|
disabled={isSaving}
|
||||||
|
onChange={(event) => setNewHabitLabel(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleAddHabit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving || newHabitLabel.trim().length === 0}
|
||||||
|
onClick={() => void handleAddHabit()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">How daily tasks are picked</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your tasks combine focus preferences, custom habits, and today's
|
||||||
|
quiz.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>Focus topics: {focusTopic ?? "None selected"}</p>
|
||||||
|
<p>
|
||||||
|
Daily quiz today:{" "}
|
||||||
|
{quizCompletedToday ? "Completed" : "Not completed yet"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Suggested habits can appear when your quiz shows low energy, high
|
||||||
|
stress, or poor sleep.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "manage") {
|
if (mode === "manage") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
ListPlus,
|
ListPlus,
|
||||||
|
Palette,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
UserRound,
|
UserRound,
|
||||||
@@ -19,10 +20,12 @@ const gridColsClass = {
|
|||||||
3: "grid-cols-3",
|
3: "grid-cols-3",
|
||||||
4: "grid-cols-4",
|
4: "grid-cols-4",
|
||||||
5: "grid-cols-5",
|
5: "grid-cols-5",
|
||||||
|
6: "grid-cols-6",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const navIcons = {
|
const navIcons = {
|
||||||
[routes.avatar]: Home,
|
[routes.avatar]: Home,
|
||||||
|
[routes.customize]: Palette,
|
||||||
[routes.habits]: ListPlus,
|
[routes.habits]: ListPlus,
|
||||||
[routes.dailyQuiz]: Sparkles,
|
[routes.dailyQuiz]: Sparkles,
|
||||||
[routes.shop]: ShoppingBag,
|
[routes.shop]: ShoppingBag,
|
||||||
@@ -37,14 +40,21 @@ export function AppNav({ items = appNavItems }: AppNavProps) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const columnCount = Math.min(
|
const columnCount = Math.min(
|
||||||
items.length,
|
items.length,
|
||||||
5,
|
6,
|
||||||
) as keyof typeof gridColsClass;
|
) as keyof typeof gridColsClass;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
<nav
|
||||||
|
className="fixed inset-x-0 bottom-0 z-50 border-t-2 border-border bg-background/95 shadow-[0_-4px_0_0_hsl(280_45%_28%/0.35)] backdrop-blur supports-[backdrop-filter]:bg-background/90"
|
||||||
|
style={{ height: "var(--bottom-nav-height)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-secondary/40"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto grid max-w-lg gap-1 px-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2",
|
"mx-auto grid h-full max-w-6xl gap-1 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2",
|
||||||
gridColsClass[columnCount],
|
gridColsClass[columnCount],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -59,14 +69,16 @@ export function AppNav({ items = appNavItems }: AppNavProps) {
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-1 rounded-lg px-1 py-2 text-[9px] font-medium leading-tight transition-colors",
|
"flex flex-col items-center justify-center gap-1.5 border-2 px-1 py-2 text-[9px] leading-none transition-all duration-75",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary text-primary-foreground"
|
? "border-secondary bg-primary text-primary-foreground shadow-[var(--retro-shadow-sm)] -translate-y-0.5"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
: "border-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground hover:-translate-y-0.5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-5 w-5 shrink-0" strokeWidth={2.5} />
|
||||||
<span>{item.shortLabel}</span>
|
<span className="max-w-full truncate uppercase tracking-wide">
|
||||||
|
{item.shortLabel}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
33
components/layout/app-page-header.tsx
Normal file
33
components/layout/app-page-header.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AppPageHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
wide?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppPageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
eyebrow,
|
||||||
|
wide = false,
|
||||||
|
}: AppPageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="border-b-2 border-border bg-card/60 shadow-[var(--retro-shadow-sm)]">
|
||||||
|
<div className={cn("mx-auto px-5 py-5", wide ? "max-w-6xl" : "max-w-lg")}>
|
||||||
|
{eyebrow ? (
|
||||||
|
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<h1 className="text-sm">{title}</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
|
import { AppPageHeader } from "@/components/layout/app-page-header";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
wide?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell({ children, title, description }: AppShellProps) {
|
export function AppShell({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
wide = false,
|
||||||
|
}: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh bg-background">
|
<div className="relative bg-background bg-[radial-gradient(circle_at_20%_0%,hsl(var(--primary)/0.12)_0%,transparent_45%),radial-gradient(circle_at_80%_100%,hsl(var(--secondary)/0.1)_0%,transparent_40%)]">
|
||||||
{(title || description) && (
|
{title ? (
|
||||||
<header className="border-b border-border/60 bg-card/40">
|
<AppPageHeader title={title} description={description} wide={wide} />
|
||||||
<div className="mx-auto max-w-lg px-5 py-5">
|
) : null}
|
||||||
{title ? (
|
<main
|
||||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
className={cn(
|
||||||
) : null}
|
"mx-auto px-5 py-6",
|
||||||
{description ? (
|
wide ? "max-w-6xl" : "max-w-lg",
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
)}
|
||||||
) : null}
|
>
|
||||||
</div>
|
{children}
|
||||||
</header>
|
</main>
|
||||||
)}
|
|
||||||
<main className="mx-auto max-w-lg px-5 py-6">{children}</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,9 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { AvatarCustomizeLayout } from "@/components/avatar/avatar-customize-layout";
|
||||||
import { CharacterCreator } from "@/components/character/character-creator";
|
import { CharacterCreator } from "@/components/character/character-creator";
|
||||||
import { useToast } from "@/components/ui/toast-provider";
|
import { useToast } from "@/components/ui/toast-provider";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
defaultAvatarCustomization,
|
defaultAvatarCustomization,
|
||||||
saveAvatarCustomization,
|
saveAvatarCustomization,
|
||||||
@@ -53,42 +47,20 @@ export function OnboardingCustomizeForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh bg-background">
|
<AvatarCustomizeLayout
|
||||||
<main className="mx-auto flex min-h-svh max-w-6xl flex-col justify-center px-4 py-10">
|
eyebrow="Step 2 of 2"
|
||||||
<div className="mb-8 space-y-2 text-center">
|
title="Create your Habit Pet"
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
|
description="Customize your pet's look, give them a name, and confirm before entering the app."
|
||||||
Step 2 of 2
|
>
|
||||||
</p>
|
<CharacterCreator
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">
|
initialCustomization={defaultAvatarCustomization}
|
||||||
Create your Habit Pet
|
name={avatarName}
|
||||||
</h1>
|
onNameChange={setAvatarName}
|
||||||
<p className="mx-auto max-w-2xl text-sm text-muted-foreground">
|
showNameField
|
||||||
Customize your pet's look, give them a name, and confirm before
|
saveLabel={isSubmitting ? "Saving..." : "Confirm avatar"}
|
||||||
entering the app.
|
isSaving={isSubmitting}
|
||||||
</p>
|
onSave={handleSave}
|
||||||
</div>
|
/>
|
||||||
|
</AvatarCustomizeLayout>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ export function OnboardingQuizForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh bg-background">
|
<main className="mx-auto max-w-lg px-5 py-10">
|
||||||
<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">
|
<div className="mb-8 text-center">
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
|
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
Step 1 of 2
|
Step 1 of 2
|
||||||
@@ -102,7 +101,6 @@ export function OnboardingQuizForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
107
components/pet/animated-pet-sprite.tsx
Normal file
107
components/pet/animated-pet-sprite.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
|
||||||
|
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AnimatedPetSpriteProps = {
|
||||||
|
customization: AvatarCustomization;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WanderPosition = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
facing: "left" | "right";
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_X = 24;
|
||||||
|
const MAX_X = 72;
|
||||||
|
const MIN_Y = 34;
|
||||||
|
const MAX_Y = 62;
|
||||||
|
|
||||||
|
function randomPosition(current?: WanderPosition): WanderPosition {
|
||||||
|
let next: WanderPosition = {
|
||||||
|
x: MIN_X + Math.random() * (MAX_X - MIN_X),
|
||||||
|
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
|
||||||
|
facing: Math.random() > 0.5 ? "right" : "left",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
let attempts = 0;
|
||||||
|
while (
|
||||||
|
attempts < 6 &&
|
||||||
|
Math.hypot(next.x - current.x, next.y - current.y) < 8
|
||||||
|
) {
|
||||||
|
next = {
|
||||||
|
x: MIN_X + Math.random() * (MAX_X - MIN_X),
|
||||||
|
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
|
||||||
|
facing: next.x >= current.x ? "right" : "left",
|
||||||
|
};
|
||||||
|
attempts += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedPetSprite({
|
||||||
|
customization,
|
||||||
|
className,
|
||||||
|
}: AnimatedPetSpriteProps) {
|
||||||
|
const [position, setPosition] = useState<WanderPosition>({
|
||||||
|
x: 48,
|
||||||
|
y: 48,
|
||||||
|
facing: "right",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
const scheduleWander = () => {
|
||||||
|
const delay = 1800 + Math.random() * 3200;
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
setPosition((current) => randomPosition(current));
|
||||||
|
scheduleWander();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleWander();
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("pointer-events-none absolute z-10", className)}
|
||||||
|
style={{
|
||||||
|
left: `${position.x}%`,
|
||||||
|
top: `${position.y}%`,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
transition: "left 1.8s steps(10), top 1.8s steps(10)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transform: position.facing === "left" ? "scaleX(-1)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="animate-pet-breathe origin-bottom">
|
||||||
|
<CharacterLayerPreview
|
||||||
|
colors={customization.colors}
|
||||||
|
variants={customization.variants}
|
||||||
|
equippedItems={customization.equippedItems}
|
||||||
|
scale={7}
|
||||||
|
compact
|
||||||
|
className="!h-auto max-h-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/pet/parallax-room-background.tsx
Normal file
68
components/pet/parallax-room-background.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getRoomBackground,
|
||||||
|
type RoomBackgroundId,
|
||||||
|
} from "@/lib/room-backgrounds";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ParallaxRoomBackgroundProps = {
|
||||||
|
roomId: RoomBackgroundId;
|
||||||
|
className?: string;
|
||||||
|
interactive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ParallaxRoomBackground({
|
||||||
|
roomId,
|
||||||
|
className,
|
||||||
|
interactive = true,
|
||||||
|
}: ParallaxRoomBackgroundProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const room = getRoomBackground(roomId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interactive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const x = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
|
||||||
|
const y = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
|
||||||
|
setOffset({ x, y });
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("pointermove", handlePointerMove);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
};
|
||||||
|
}, [interactive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("absolute inset-0 overflow-hidden", className)}>
|
||||||
|
{room.layers.map((layer, index) => (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-[-12%]",
|
||||||
|
layer.className,
|
||||||
|
index % 2 === 0 ? "animate-parallax-drift" : "animate-star-twinkle",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(${offset.x * layer.speed * 10}px, ${offset.y * layer.speed * 8}px, 0)`,
|
||||||
|
transition: "transform 0.15s steps(4)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
components/pet/pet-habitat.tsx
Normal file
77
components/pet/pet-habitat.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { AnimatedPetSprite } from "@/components/pet/animated-pet-sprite";
|
||||||
|
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
|
||||||
|
import { PixelGauge } from "@/components/pet/pixel-gauge";
|
||||||
|
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||||
|
import {
|
||||||
|
defaultAvatarCondition,
|
||||||
|
type AvatarCondition,
|
||||||
|
} from "@/lib/avatar-state";
|
||||||
|
import { getAvatarConditionForToday } from "@/lib/daily-quiz-storage";
|
||||||
|
import {
|
||||||
|
normalizeRoomBackgroundId,
|
||||||
|
type RoomBackgroundId,
|
||||||
|
} from "@/lib/room-backgrounds";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PetHabitatProps = {
|
||||||
|
customization: AvatarCustomization;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOOD_LABELS = {
|
||||||
|
happy: "HAPPY",
|
||||||
|
neutral: "OKAY",
|
||||||
|
tired: "SLEEPY",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function PetHabitat({ customization, className }: PetHabitatProps) {
|
||||||
|
const [condition, setCondition] = useState<AvatarCondition>(
|
||||||
|
defaultAvatarCondition,
|
||||||
|
);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const roomId = normalizeRoomBackgroundId(customization.roomBackground);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadStatus() {
|
||||||
|
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<div className={cn("tamagotchi-shell p-4", className)}>
|
||||||
|
<p className="text-center text-[10px] text-muted-foreground">
|
||||||
|
Waking up pet...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("tamagotchi-shell overflow-hidden p-3 sm:p-4", className)}>
|
||||||
|
<div className="mb-3 border-b-2 border-border/60 pb-3">
|
||||||
|
<p className="text-sm text-foreground">{customization.name}</p>
|
||||||
|
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
|
||||||
|
{MOOD_LABELS[condition.mood]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
||||||
|
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
|
||||||
|
<AnimatedPetSprite customization={customization} />
|
||||||
|
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5">
|
||||||
|
<PixelGauge type="heart" value={condition.health} variant="overlay" />
|
||||||
|
<PixelGauge type="energy" value={condition.energy} variant="overlay" />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
components/pet/pixel-gauge.tsx
Normal file
62
components/pet/pixel-gauge.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Heart, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
import { WELLNESS_SCALE_MAX } from "@/lib/avatar-state";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PixelGaugeProps = {
|
||||||
|
type: "heart" | "energy";
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
className?: string;
|
||||||
|
variant?: "default" | "overlay";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PixelGauge({
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
max = WELLNESS_SCALE_MAX,
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
}: PixelGaugeProps) {
|
||||||
|
const filled = Math.max(0, Math.min(max, Math.round(value)));
|
||||||
|
const Icon = type === "heart" ? Heart : Zap;
|
||||||
|
const label = type === "heart" ? "HP" : "NRG";
|
||||||
|
const activeClass =
|
||||||
|
type === "heart"
|
||||||
|
? "fill-rose-400 text-rose-400"
|
||||||
|
: "fill-amber-300 text-amber-300";
|
||||||
|
const emptyClass = "text-muted-foreground/35";
|
||||||
|
const isOverlay = variant === "overlay";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center",
|
||||||
|
isOverlay ? "gap-1" : "gap-1.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={`${label}: ${filled} of ${max}`}
|
||||||
|
>
|
||||||
|
{!isOverlay ? (
|
||||||
|
<span className="w-7 shrink-0 text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className={cn("flex items-center", isOverlay ? "gap-1" : "gap-0.5")}>
|
||||||
|
{Array.from({ length: max }, (_, index) => (
|
||||||
|
<Icon
|
||||||
|
key={`${type}-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 stroke-[2.5]",
|
||||||
|
isOverlay ? "size-6" : "size-4",
|
||||||
|
index < filled ? activeClass : emptyClass,
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
components/pet/room-background-picker.tsx
Normal file
83
components/pet/room-background-picker.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ROOM_BACKGROUNDS,
|
||||||
|
isRoomBackgroundUnlocked,
|
||||||
|
type RoomBackgroundId,
|
||||||
|
} from "@/lib/room-backgrounds";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type RoomBackgroundPickerProps = {
|
||||||
|
value: RoomBackgroundId;
|
||||||
|
ownedItemIds: string[];
|
||||||
|
onChange: (roomId: RoomBackgroundId) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomBackgroundPicker({
|
||||||
|
value,
|
||||||
|
ownedItemIds,
|
||||||
|
onChange,
|
||||||
|
compact = false,
|
||||||
|
}: RoomBackgroundPickerProps) {
|
||||||
|
return (
|
||||||
|
<section className="grid gap-3">
|
||||||
|
{!compact ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
Room background
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||||
|
Pick the scene behind your pet. Premium rooms unlock in the shop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
Premium rooms unlock in the shop.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{ROOM_BACKGROUNDS.map((room) => {
|
||||||
|
const unlocked = isRoomBackgroundUnlocked(room.id, ownedItemIds);
|
||||||
|
const selected = value === room.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={room.id}
|
||||||
|
type="button"
|
||||||
|
disabled={!unlocked}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={
|
||||||
|
unlocked
|
||||||
|
? room.name
|
||||||
|
: `${room.name} — unlock in the shop`
|
||||||
|
}
|
||||||
|
onClick={() => onChange(room.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-[4/3] overflow-hidden border-2 transition-transform",
|
||||||
|
selected
|
||||||
|
? "border-secondary shadow-[0_0_0_2px_hsl(var(--secondary)/0.35)]"
|
||||||
|
: "border-border/70",
|
||||||
|
unlocked
|
||||||
|
? "hover:-translate-y-0.5"
|
||||||
|
: "cursor-not-allowed opacity-80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("absolute inset-0", room.previewClassName)} />
|
||||||
|
<p className="absolute inset-x-0 bottom-0 bg-black/55 px-1.5 py-1 text-left text-[8px] uppercase tracking-wider text-foreground">
|
||||||
|
{room.name}
|
||||||
|
</p>
|
||||||
|
{!unlocked ? (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center bg-background/55">
|
||||||
|
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
components/pet/room-style-selector.tsx
Normal file
80
components/pet/room-style-selector.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
|
||||||
|
import { PIECE_SLOT_COUNT } from "@/lib/character/presets";
|
||||||
|
import {
|
||||||
|
ROOM_BACKGROUNDS,
|
||||||
|
isRoomBackgroundUnlocked,
|
||||||
|
type RoomBackgroundId,
|
||||||
|
} from "@/lib/room-backgrounds";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type RoomStyleSelectorProps = {
|
||||||
|
activeId: RoomBackgroundId;
|
||||||
|
ownedItemIds: readonly string[];
|
||||||
|
onSelect: (roomId: RoomBackgroundId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomStyleSelector({
|
||||||
|
activeId,
|
||||||
|
ownedItemIds,
|
||||||
|
onSelect,
|
||||||
|
}: RoomStyleSelectorProps) {
|
||||||
|
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
|
||||||
|
ROOM_BACKGROUNDS[index] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-8 gap-2">
|
||||||
|
{slots.map((room, index) => {
|
||||||
|
if (!room) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${index}`}
|
||||||
|
aria-hidden
|
||||||
|
className="size-14 rounded-lg border border-dashed border-border/50 bg-muted/15"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locked = !isRoomBackgroundUnlocked(room.id, ownedItemIds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={room.id}
|
||||||
|
type="button"
|
||||||
|
disabled={locked}
|
||||||
|
aria-disabled={locked}
|
||||||
|
title={
|
||||||
|
locked ? `${room.name} — unlock in the shop` : room.name
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
locked ? `${room.name}, locked — unlock in the shop` : room.name
|
||||||
|
}
|
||||||
|
aria-pressed={activeId === room.id}
|
||||||
|
onClick={() => onSelect(room.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative size-14 overflow-hidden rounded-lg border transition-colors",
|
||||||
|
locked
|
||||||
|
? "cursor-not-allowed border-border/40 opacity-80"
|
||||||
|
: activeId === room.id
|
||||||
|
? "border-foreground shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
|
||||||
|
: "border-border/60 hover:border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("absolute inset-0", room.previewClassName)} />
|
||||||
|
{locked ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-background/75"
|
||||||
|
>
|
||||||
|
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useToast } from "@/components/ui/toast-provider";
|
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 { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
|
||||||
|
import { getRoomBackground } from "@/lib/room-backgrounds";
|
||||||
import {
|
import {
|
||||||
equipShopItem,
|
equipShopItem,
|
||||||
getShopInventory,
|
getShopInventory,
|
||||||
@@ -35,11 +36,80 @@ const SHOP_LAYER_ORDER: ShopLayerId[] = [
|
|||||||
"eyes",
|
"eyes",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function ShopItemCard({
|
||||||
|
item,
|
||||||
|
owned,
|
||||||
|
equipped,
|
||||||
|
canAfford,
|
||||||
|
isPending,
|
||||||
|
onPurchase,
|
||||||
|
onEquipToggle,
|
||||||
|
}: {
|
||||||
|
item: ShopItemRecord;
|
||||||
|
owned: boolean;
|
||||||
|
equipped: boolean;
|
||||||
|
canAfford: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
onPurchase: () => void;
|
||||||
|
onEquipToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const room = item.type === "room" ? getRoomBackground(item.id) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-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 border-2 border-border bg-zinc-950/80 p-1.5">
|
||||||
|
{room ? (
|
||||||
|
<div className={`size-full ${room.previewClassName}`} />
|
||||||
|
) : (
|
||||||
|
<TintedSpriteIcon
|
||||||
|
src={item.image_path}
|
||||||
|
color={DEFAULT_GRAY_COLOR.hsl}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xs">{item.name}</CardTitle>
|
||||||
|
<CardDescription className="text-[9px]">{item.id}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge>{item.price} pts</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
{!owned ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canAfford || isPending}
|
||||||
|
onClick={onPurchase}
|
||||||
|
>
|
||||||
|
{isPending ? "Processing..." : canAfford ? "Buy" : "Not enough points"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant={equipped ? "default" : "outline"}
|
||||||
|
disabled={isPending || (item.type === "room" && equipped)}
|
||||||
|
onClick={onEquipToggle}
|
||||||
|
>
|
||||||
|
{isPending ? "Updating..." : equipped ? "Equipped" : "Equip"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ShopContent() {
|
export function ShopContent() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [items, setItems] = useState<ShopItemRecord[]>([]);
|
const [items, setItems] = useState<ShopItemRecord[]>([]);
|
||||||
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
|
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
|
||||||
const [equippedItems, setEquippedItems] = useState<string[]>([]);
|
const [equippedItems, setEquippedItems] = useState<string[]>([]);
|
||||||
|
const [equippedRoomBackground, setEquippedRoomBackground] = useState("room-day");
|
||||||
const [coins, setCoins] = useState<number | null>(null);
|
const [coins, setCoins] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
|
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
|
||||||
@@ -51,6 +121,7 @@ export function ShopContent() {
|
|||||||
setItems(inventory.items);
|
setItems(inventory.items);
|
||||||
setOwnedItemIds(inventory.ownedItemIds);
|
setOwnedItemIds(inventory.ownedItemIds);
|
||||||
setEquippedItems(inventory.equippedItems);
|
setEquippedItems(inventory.equippedItems);
|
||||||
|
setEquippedRoomBackground(inventory.equippedRoomBackground);
|
||||||
setCoins(inventory.coins);
|
setCoins(inventory.coins);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
setError(
|
setError(
|
||||||
@@ -94,12 +165,15 @@ export function ShopContent() {
|
|||||||
setPendingItemId(item.id);
|
setPendingItemId(item.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isEquipped = equippedItems.includes(item.id);
|
const isEquipped =
|
||||||
|
item.type === "room"
|
||||||
|
? equippedRoomBackground === item.id
|
||||||
|
: equippedItems.includes(item.id);
|
||||||
|
|
||||||
if (isEquipped) {
|
if (item.type !== "room" && isEquipped) {
|
||||||
await unequipShopItem(item.id);
|
await unequipShopItem(item.id);
|
||||||
toast(`Unequipped ${item.name}.`, "default");
|
toast(`Unequipped ${item.name}.`, "default");
|
||||||
} else {
|
} else if (!isEquipped) {
|
||||||
await equipShopItem(item.id);
|
await equipShopItem(item.id);
|
||||||
toast(`Equipped ${item.name}.`, "success");
|
toast(`Equipped ${item.name}.`, "success");
|
||||||
}
|
}
|
||||||
@@ -115,24 +189,28 @@ export function ShopContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupedItems = SHOP_LAYER_ORDER.map((layerId) => ({
|
const styleGroups = SHOP_LAYER_ORDER.map((layerId) => ({
|
||||||
layerId,
|
layerId,
|
||||||
label: getLayerLabel(layerId),
|
label: getLayerLabel(layerId),
|
||||||
items: items.filter((item) => item.type === layerId),
|
items: items.filter((item) => item.type === layerId),
|
||||||
})).filter((group) => group.items.length > 0);
|
})).filter((group) => group.items.length > 0);
|
||||||
|
|
||||||
|
const roomItems = items.filter((item) => item.type === "room");
|
||||||
|
|
||||||
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 border-2 border-border bg-muted/40 px-4 py-3 shadow-[var(--retro-shadow-sm)]">
|
||||||
<span className="text-sm text-muted-foreground">Your balance</span>
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
Your balance
|
||||||
|
</span>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{coins === null ? "..." : `${coins} points`}
|
{coins === null ? "..." : `${coins} points`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <p className="mb-4 text-sm text-red-500">{error}</p> : null}
|
{error ? <p className="mb-4 text-xs text-red-500">{error}</p> : null}
|
||||||
|
|
||||||
{!error && coins !== null && groupedItems.length === 0 ? (
|
{!error && coins !== null && items.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -144,70 +222,46 @@ export function ShopContent() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
{groupedItems.map((group) => (
|
{roomItems.length > 0 ? (
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Room backgrounds
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{roomItems.map((item) => (
|
||||||
|
<ShopItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
owned={ownedItemIds.includes(item.id)}
|
||||||
|
equipped={equippedRoomBackground === item.id}
|
||||||
|
canAfford={coins !== null && coins >= item.price}
|
||||||
|
isPending={pendingItemId === item.id}
|
||||||
|
onPurchase={() => void handlePurchase(item.id)}
|
||||||
|
onEquipToggle={() => void handleEquipToggle(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{styleGroups.map((group) => (
|
||||||
<section key={group.layerId} className="grid gap-3">
|
<section key={group.layerId} className="grid gap-3">
|
||||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
{group.label}
|
{group.label}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => (
|
||||||
const owned = ownedItemIds.includes(item.id);
|
<ShopItemCard
|
||||||
const equipped = equippedItems.includes(item.id);
|
key={item.id}
|
||||||
const canAfford = coins !== null && coins >= item.price;
|
item={item}
|
||||||
const isPending = pendingItemId === item.id;
|
owned={ownedItemIds.includes(item.id)}
|
||||||
|
equipped={equippedItems.includes(item.id)}
|
||||||
return (
|
canAfford={coins !== null && coins >= item.price}
|
||||||
<Card key={item.id}>
|
isPending={pendingItemId === item.id}
|
||||||
<CardHeader className="pb-3">
|
onPurchase={() => void handlePurchase(item.id)}
|
||||||
<div className="flex items-start justify-between gap-3">
|
onEquipToggle={() => void handleEquipToggle(item)}
|
||||||
<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>
|
|
||||||
<CardTitle className="text-base">{item.name}</CardTitle>
|
|
||||||
<CardDescription>{item.id}</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge>{item.price} pts</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-2">
|
|
||||||
{!owned ? (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant="outline"
|
|
||||||
disabled={coins === null || !canAfford || isPending}
|
|
||||||
onClick={() => void handlePurchase(item.id)}
|
|
||||||
>
|
|
||||||
{isPending
|
|
||||||
? "Processing..."
|
|
||||||
: canAfford
|
|
||||||
? "Buy"
|
|
||||||
: "Not enough points"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant={equipped ? "default" : "outline"}
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => void handleEquipToggle(item)}
|
|
||||||
>
|
|
||||||
{isPending
|
|
||||||
? "Updating..."
|
|
||||||
: equipped
|
|
||||||
? "Equipped"
|
|
||||||
: "Equip"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-none border-2 border-border px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-[var(--retro-shadow-sm)]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -5,26 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap border-2 border-border text-xs font-normal transition-[transform,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow-[var(--retro-shadow-sm)] hover:-translate-x-px hover:-translate-y-px hover:shadow-[3px_3px_0_0_hsl(280_45%_28%)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-[var(--retro-shadow-sm)]",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border-border bg-background shadow-[var(--retro-shadow-sm)] hover:bg-muted hover:text-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-[var(--retro-shadow-sm)]",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "border-transparent shadow-none hover:bg-muted hover:text-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "border-transparent text-secondary underline-offset-4 shadow-none hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
sm: "h-8 px-3 text-[10px]",
|
||||||
lg: "h-10 rounded-md px-8",
|
lg: "h-11 px-8",
|
||||||
icon: "h-9 w-9",
|
icon: "h-10 w-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"border-2 border-border bg-card text-card-foreground shadow-[var(--retro-shadow)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex h-9 w-full rounded-none border-2 border-input bg-muted/30 px-3 py-1 text-xs shadow-[var(--retro-shadow-sm)] transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
<div
|
<div
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="pointer-events-none fixed bottom-24 right-4 z-50 flex w-[min(100vw-2rem,20rem)] flex-col gap-2"
|
className="pointer-events-none fixed right-4 z-50 flex w-[min(100vw-2rem,20rem)] flex-col gap-2"
|
||||||
|
style={{ bottom: "calc(var(--bottom-nav-height) + 0.75rem)" }}
|
||||||
>
|
>
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur-sm",
|
"rounded-lg border px-4 py-3 text-xs shadow-lg backdrop-blur-sm",
|
||||||
message.variant === "success" &&
|
message.variant === "success" &&
|
||||||
"border-emerald-500/30 bg-emerald-500/15 text-emerald-100",
|
"border-emerald-500/30 bg-emerald-500/15 text-emerald-100",
|
||||||
message.variant === "error" &&
|
message.variant === "error" &&
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
NONE_VARIANT_ID,
|
NONE_VARIANT_ID,
|
||||||
} from "@/lib/character/presets";
|
} from "@/lib/character/presets";
|
||||||
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
||||||
|
import {
|
||||||
|
DEFAULT_ROOM_BACKGROUND,
|
||||||
|
normalizeRoomBackgroundId,
|
||||||
|
type RoomBackgroundId,
|
||||||
|
} from "@/lib/room-backgrounds";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
export type AvatarCustomization = {
|
export type AvatarCustomization = {
|
||||||
@@ -24,6 +29,7 @@ export type AvatarCustomization = {
|
|||||||
variants: LayerVariantState;
|
variants: LayerVariantState;
|
||||||
customized: boolean;
|
customized: boolean;
|
||||||
equippedItems: string[];
|
equippedItems: string[];
|
||||||
|
roomBackground: RoomBackgroundId;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AvatarStateRow = {
|
type AvatarStateRow = {
|
||||||
@@ -41,6 +47,7 @@ type AvatarStateRow = {
|
|||||||
head_color: string | null;
|
head_color: string | null;
|
||||||
avatar_customized: boolean | null;
|
avatar_customized: boolean | null;
|
||||||
equipped_items: string[] | null;
|
equipped_items: string[] | null;
|
||||||
|
room_background: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_AVATAR_NAME = "Pixel Me";
|
export const DEFAULT_AVATAR_NAME = "Pixel Me";
|
||||||
@@ -53,11 +60,19 @@ export const defaultAvatarCustomization: AvatarCustomization = {
|
|||||||
variants: buildDefaultVariants(),
|
variants: buildDefaultVariants(),
|
||||||
customized: false,
|
customized: false,
|
||||||
equippedItems: [],
|
equippedItems: [],
|
||||||
|
roomBackground: DEFAULT_ROOM_BACKGROUND,
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatarSelectFields =
|
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, room_background";
|
||||||
|
|
||||||
|
const avatarSelectFieldsLegacy =
|
||||||
"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";
|
"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 isMissingRoomBackgroundColumn(message: string) {
|
||||||
|
return /room_background/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
function colorFromHex(hex: string | null | undefined, fallback: HSL): HSL {
|
function colorFromHex(hex: string | null | undefined, fallback: HSL): HSL {
|
||||||
if (!hex?.trim() || !HEX_COLOR_PATTERN.test(hex)) {
|
if (!hex?.trim() || !HEX_COLOR_PATTERN.test(hex)) {
|
||||||
return { ...fallback };
|
return { ...fallback };
|
||||||
@@ -109,6 +124,7 @@ export function mapRowToAvatarCustomization(
|
|||||||
},
|
},
|
||||||
customized: row.avatar_customized === true,
|
customized: row.avatar_customized === true,
|
||||||
equippedItems: row.equipped_items ?? [],
|
equippedItems: row.equipped_items ?? [],
|
||||||
|
roomBackground: normalizeRoomBackgroundId(row.room_background),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +148,7 @@ export function mapAvatarCustomizationToRow(customization: AvatarCustomization)
|
|||||||
eye_color: normalizeHexColor(colors.eyes, defaults.eyes),
|
eye_color: normalizeHexColor(colors.eyes, defaults.eyes),
|
||||||
head_style: normalizedVariants.head,
|
head_style: normalizedVariants.head,
|
||||||
head_color: normalizeHexColor(colors.head, defaults.head),
|
head_color: normalizeHexColor(colors.head, defaults.head),
|
||||||
|
room_background: normalizeRoomBackgroundId(customization.roomBackground),
|
||||||
avatar_customized: true,
|
avatar_customized: true,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -159,13 +176,28 @@ export async function getAvatarCustomization(): Promise<AvatarCustomization> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase
|
let { data, error } = await supabase
|
||||||
.from("avatar_state")
|
.from("avatar_state")
|
||||||
.select(avatarSelectFields)
|
.select(avatarSelectFields)
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error && isMissingRoomBackgroundColumn(error.message)) {
|
||||||
|
const legacy = await supabase
|
||||||
|
.from("avatar_state")
|
||||||
|
.select(avatarSelectFieldsLegacy)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (legacy.error) {
|
||||||
|
throw new Error(legacy.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
data = legacy.data
|
||||||
|
? ({ ...legacy.data, room_background: null } as AvatarStateRow)
|
||||||
|
: null;
|
||||||
|
error = null;
|
||||||
|
} else if (error) {
|
||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +241,22 @@ export async function saveAvatarCustomization(
|
|||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const payload = mapAvatarCustomizationToRow(customization);
|
const payload = mapAvatarCustomizationToRow(customization);
|
||||||
|
|
||||||
const { error } = await supabase
|
let { error } = await supabase
|
||||||
.from("avatar_state")
|
.from("avatar_state")
|
||||||
.update(payload)
|
.update(payload)
|
||||||
.eq("user_id", userId);
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error && isMissingRoomBackgroundColumn(error.message)) {
|
||||||
|
const { room_background, ...legacyPayload } = payload;
|
||||||
|
void room_background;
|
||||||
|
const retry = await supabase
|
||||||
|
.from("avatar_state")
|
||||||
|
.update(legacyPayload)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
error = retry.error;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,16 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type CharacterCreatorTabId = CharacterLayerId | "room";
|
||||||
|
|
||||||
|
export const CHARACTER_CUSTOMIZATION_TABS: {
|
||||||
|
id: CharacterCreatorTabId;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
...CHARACTER_LAYERS.map((layer) => ({ id: layer.id, label: layer.label })),
|
||||||
|
{ id: "room", label: "Room" },
|
||||||
|
];
|
||||||
|
|
||||||
export const LAYER_DEFAULT_COLORS: Record<CharacterLayerId, HSL> = {
|
export const LAYER_DEFAULT_COLORS: Record<CharacterLayerId, HSL> = {
|
||||||
skin: DEFAULT_GRAY_COLOR.hsl,
|
skin: DEFAULT_GRAY_COLOR.hsl,
|
||||||
pants: DEFAULT_GRAY_COLOR.hsl,
|
pants: DEFAULT_GRAY_COLOR.hsl,
|
||||||
|
|||||||
245
lib/room-backgrounds.ts
Normal file
245
lib/room-backgrounds.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
export type RoomBackgroundId =
|
||||||
|
|
||||||
|
| "room-day"
|
||||||
|
|
||||||
|
| "room-dusk"
|
||||||
|
|
||||||
|
| "room-night"
|
||||||
|
|
||||||
|
| "room-forest";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type RoomBackgroundLayer = {
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
className: string;
|
||||||
|
|
||||||
|
speed: number;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type RoomBackground = {
|
||||||
|
|
||||||
|
id: RoomBackgroundId;
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
free: boolean;
|
||||||
|
|
||||||
|
previewClassName: string;
|
||||||
|
|
||||||
|
layers: RoomBackgroundLayer[];
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const DEFAULT_ROOM_BACKGROUND: RoomBackgroundId = "room-day";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const ROOM_BACKGROUNDS: RoomBackground[] = [
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
id: "room-day",
|
||||||
|
|
||||||
|
name: "Sunny Field",
|
||||||
|
|
||||||
|
free: true,
|
||||||
|
|
||||||
|
previewClassName: "bg-gradient-to-b from-sky-400 via-sky-300 to-lime-400",
|
||||||
|
|
||||||
|
layers: [
|
||||||
|
|
||||||
|
{ id: "sky", className: "bg-gradient-to-b from-sky-500 via-sky-300 to-sky-200", speed: 0.15 },
|
||||||
|
|
||||||
|
{ id: "clouds", className: "bg-[radial-gradient(circle_at_20%_30%,rgba(255,255,255,0.85)_0%,transparent_35%),radial-gradient(circle_at_70%_20%,rgba(255,255,255,0.7)_0%,transparent_30%)]", speed: 0.35 },
|
||||||
|
|
||||||
|
{ id: "hills", className: "bg-[linear-gradient(to_top,#3d8b37_0%,#3d8b37_40%,transparent_40%),radial-gradient(ellipse_120%_80%_at_50%_100%,#2f6f2c_0%,transparent_70%)]", speed: 0.55 },
|
||||||
|
|
||||||
|
{ id: "grass", className: "bg-[repeating-linear-gradient(90deg,rgba(0,0,0,0.06)_0_2px,transparent_2px_6px)] opacity-40", speed: 0.75 },
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
id: "room-dusk",
|
||||||
|
|
||||||
|
name: "Golden Dusk",
|
||||||
|
|
||||||
|
free: true,
|
||||||
|
|
||||||
|
previewClassName: "bg-gradient-to-b from-orange-400 via-pink-400 to-indigo-700",
|
||||||
|
|
||||||
|
layers: [
|
||||||
|
|
||||||
|
{ id: "sky", className: "bg-gradient-to-b from-orange-500 via-rose-400 to-indigo-800", speed: 0.12 },
|
||||||
|
|
||||||
|
{ id: "sun", className: "bg-[radial-gradient(circle_at_50%_85%,rgba(255,220,120,0.95)_0%,rgba(255,160,80,0.35)_18%,transparent_45%)]", speed: 0.25 },
|
||||||
|
|
||||||
|
{ id: "hills", className: "bg-[radial-gradient(ellipse_130%_70%_at_50%_100%,#1f2937_0%,transparent_72%)]", speed: 0.5 },
|
||||||
|
|
||||||
|
{ id: "stars", className: "bg-[radial-gradient(circle,#fff_1px,transparent_1px)] bg-[length:18px_18px] opacity-20", speed: 0.65 },
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
id: "room-night",
|
||||||
|
|
||||||
|
name: "Starlit Night",
|
||||||
|
|
||||||
|
free: false,
|
||||||
|
|
||||||
|
previewClassName: "bg-gradient-to-b from-indigo-950 via-violet-900 to-slate-900",
|
||||||
|
|
||||||
|
layers: [
|
||||||
|
|
||||||
|
{ id: "sky", className: "bg-gradient-to-b from-indigo-950 via-violet-900 to-slate-900", speed: 0.1 },
|
||||||
|
|
||||||
|
{ id: "stars", className: "bg-[radial-gradient(circle,#fff_1.5px,transparent_1.5px)] bg-[length:22px_22px] opacity-80", speed: 0.3 },
|
||||||
|
|
||||||
|
{ id: "moon", className: "bg-[radial-gradient(circle_at_78%_18%,rgba(255,255,220,0.95)_0%,rgba(255,255,220,0.15)_8%,transparent_20%)]", speed: 0.2 },
|
||||||
|
|
||||||
|
{ id: "ground", className: "bg-[linear-gradient(to_top,#111827_0%,#111827_35%,transparent_35%)]", speed: 0.6 },
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
id: "room-forest",
|
||||||
|
|
||||||
|
name: "Mystic Forest",
|
||||||
|
|
||||||
|
free: false,
|
||||||
|
|
||||||
|
previewClassName: "bg-gradient-to-b from-emerald-900 via-green-900 to-lime-950",
|
||||||
|
|
||||||
|
layers: [
|
||||||
|
|
||||||
|
{ id: "sky", className: "bg-gradient-to-b from-emerald-950 via-green-900 to-lime-950", speed: 0.14 },
|
||||||
|
|
||||||
|
{ id: "mist", className: "bg-[radial-gradient(circle_at_30%_60%,rgba(180,255,200,0.25)_0%,transparent_45%),radial-gradient(circle_at_70%_40%,rgba(180,255,200,0.18)_0%,transparent_40%)]", speed: 0.4 },
|
||||||
|
|
||||||
|
{ id: "trees", className: "bg-[repeating-linear-gradient(90deg,rgba(0,0,0,0.35)_0_14px,transparent_14px_28px)] opacity-50", speed: 0.55 },
|
||||||
|
|
||||||
|
{ id: "floor", className: "bg-[linear-gradient(to_top,#14532d_0%,#14532d_30%,transparent_30%)]", speed: 0.7 },
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const ROOM_MAP = new Map(ROOM_BACKGROUNDS.map((room) => [room.id, room]));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const LEGACY_ROOM_IDS = new Set(["room-space", "room-neon"]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function getRoomBackground(id: string | null | undefined): RoomBackground {
|
||||||
|
|
||||||
|
if (id && LEGACY_ROOM_IDS.has(id)) {
|
||||||
|
|
||||||
|
return ROOM_MAP.get(DEFAULT_ROOM_BACKGROUND)!;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return ROOM_MAP.get((id as RoomBackgroundId) ?? DEFAULT_ROOM_BACKGROUND) ?? ROOM_MAP.get(DEFAULT_ROOM_BACKGROUND)!;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function isValidRoomBackgroundId(id: string): id is RoomBackgroundId {
|
||||||
|
|
||||||
|
return ROOM_MAP.has(id as RoomBackgroundId);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function normalizeRoomBackgroundId(id: string | null | undefined): RoomBackgroundId {
|
||||||
|
|
||||||
|
if (id && LEGACY_ROOM_IDS.has(id)) {
|
||||||
|
|
||||||
|
return DEFAULT_ROOM_BACKGROUND;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (id && isValidRoomBackgroundId(id)) {
|
||||||
|
|
||||||
|
return id;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return DEFAULT_ROOM_BACKGROUND;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function isRoomBackgroundUnlocked(
|
||||||
|
|
||||||
|
roomId: RoomBackgroundId,
|
||||||
|
|
||||||
|
ownedItemIds: readonly string[],
|
||||||
|
|
||||||
|
): boolean {
|
||||||
|
|
||||||
|
const room = getRoomBackground(roomId);
|
||||||
|
|
||||||
|
if (room.free) {
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return ownedItemIds.includes(roomId);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function getShopRoomBackgrounds() {
|
||||||
|
|
||||||
|
return ROOM_BACKGROUNDS.filter((room) => !room.free).map((room) => ({
|
||||||
|
|
||||||
|
id: room.id,
|
||||||
|
|
||||||
|
name: room.name,
|
||||||
|
|
||||||
|
type: "room" as const,
|
||||||
|
|
||||||
|
price: room.id === "room-night" ? 40 : 50,
|
||||||
|
|
||||||
|
image_path: "",
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ export const routes = {
|
|||||||
onboardingQuiz: "/onboarding/quiz",
|
onboardingQuiz: "/onboarding/quiz",
|
||||||
onboardingCustomize: "/onboarding/customize",
|
onboardingCustomize: "/onboarding/customize",
|
||||||
avatar: "/avatar",
|
avatar: "/avatar",
|
||||||
|
customize: "/customize",
|
||||||
habits: "/habits",
|
habits: "/habits",
|
||||||
dailyQuiz: "/daily/quiz",
|
dailyQuiz: "/daily/quiz",
|
||||||
shop: "/shop",
|
shop: "/shop",
|
||||||
@@ -16,6 +17,7 @@ export type AppRoute = (typeof routes)[keyof typeof routes];
|
|||||||
|
|
||||||
export const appNavItems = [
|
export const appNavItems = [
|
||||||
{ href: routes.avatar, label: "Pet", shortLabel: "Pet" },
|
{ href: routes.avatar, label: "Pet", shortLabel: "Pet" },
|
||||||
|
{ href: routes.customize, label: "Customize", shortLabel: "Style" },
|
||||||
{ href: routes.habits, label: "Habits", shortLabel: "Habits" },
|
{ href: routes.habits, label: "Habits", shortLabel: "Habits" },
|
||||||
{ href: routes.dailyQuiz, label: "Daily Quiz", shortLabel: "Quiz" },
|
{ href: routes.dailyQuiz, label: "Daily Quiz", shortLabel: "Quiz" },
|
||||||
{ href: routes.shop, label: "Shop", shortLabel: "Shop" },
|
{ href: routes.shop, label: "Shop", shortLabel: "Shop" },
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
|
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
|
||||||
import type { CharacterLayerId } from "@/lib/character/presets";
|
import type { CharacterLayerId } from "@/lib/character/presets";
|
||||||
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets";
|
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets";
|
||||||
|
import { getShopRoomBackgrounds } from "@/lib/room-backgrounds";
|
||||||
|
|
||||||
export type ShopLayerId = Exclude<CharacterLayerId, "skin">;
|
export type ShopLayerId = Exclude<CharacterLayerId, "skin">;
|
||||||
|
|
||||||
|
export type ShopItemType = ShopLayerId | "room";
|
||||||
|
|
||||||
export type ShopItemRecord = {
|
export type ShopItemRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: ShopLayerId;
|
type: ShopItemType;
|
||||||
price: number;
|
price: number;
|
||||||
image_path: string;
|
image_path: string;
|
||||||
};
|
};
|
||||||
@@ -52,7 +55,7 @@ export function parseShopStyleItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getShopCatalogFromPresets(): ShopItemRecord[] {
|
export function getShopCatalogFromPresets(): ShopItemRecord[] {
|
||||||
return CHARACTER_LAYERS.flatMap((layer) => {
|
const styleItems = CHARACTER_LAYERS.flatMap((layer) => {
|
||||||
if (layer.id === "skin") {
|
if (layer.id === "skin") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,8 @@ export function getShopCatalogFromPresets(): ShopItemRecord[] {
|
|||||||
image_path: variant.src,
|
image_path: variant.src,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return [...styleItems, ...getShopRoomBackgrounds()];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) {
|
function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) {
|
||||||
|
|||||||
@@ -128,6 +128,31 @@ export async function purchaseShopItem(itemId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getEquippedRoomBackground(): Promise<string> {
|
||||||
|
const userId = await getAuthenticatedUserId();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return "room-day";
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("avatar_state")
|
||||||
|
.select("room_background")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (/room_background/i.test(error.message)) {
|
||||||
|
return "room-day";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data?.room_background as string | null) ?? "room-day";
|
||||||
|
}
|
||||||
|
|
||||||
export async function equipShopItem(itemId: string) {
|
export async function equipShopItem(itemId: string) {
|
||||||
const userId = await getAuthenticatedUserId();
|
const userId = await getAuthenticatedUserId();
|
||||||
|
|
||||||
@@ -152,6 +177,28 @@ export async function equipShopItem(itemId: string) {
|
|||||||
throw new Error("Could not find that shop item.");
|
throw new Error("Could not find that shop item.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.type === "room") {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("avatar_state")
|
||||||
|
.update({
|
||||||
|
room_background: itemId,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (/room_background/i.test(error.message)) {
|
||||||
|
notifyHabitPetDataUpdated();
|
||||||
|
return [itemId];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyHabitPetDataUpdated();
|
||||||
|
return [itemId];
|
||||||
|
}
|
||||||
|
|
||||||
const current = await getEquippedItems();
|
const current = await getEquippedItems();
|
||||||
const nextStyle = parseVariantId(itemId);
|
const nextStyle = parseVariantId(itemId);
|
||||||
|
|
||||||
@@ -216,12 +263,14 @@ export async function unequipShopItem(itemId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getShopInventory() {
|
export async function getShopInventory() {
|
||||||
const [items, ownedItemIds, equippedItems, coins] = await Promise.all([
|
const [items, ownedItemIds, equippedItems, equippedRoomBackground, coins] =
|
||||||
getShopItems(),
|
await Promise.all([
|
||||||
getOwnedItemIds(),
|
getShopItems(),
|
||||||
getEquippedItems(),
|
getOwnedItemIds(),
|
||||||
getCoins(),
|
getEquippedItems(),
|
||||||
]);
|
getEquippedRoomBackground(),
|
||||||
|
getCoins(),
|
||||||
|
]);
|
||||||
|
|
||||||
return { items, ownedItemIds, equippedItems, coins };
|
return { items, ownedItemIds, equippedItems, equippedRoomBackground, coins };
|
||||||
}
|
}
|
||||||
|
|||||||
18
supabase/migrations/20260524200000_room_background.sql
Normal file
18
supabase/migrations/20260524200000_room_background.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
alter table public.avatar_state
|
||||||
|
add column if not exists room_background text not null default 'room-day';
|
||||||
|
|
||||||
|
alter table public.avatar_state
|
||||||
|
drop constraint if exists avatar_state_room_background_check;
|
||||||
|
|
||||||
|
alter table public.avatar_state
|
||||||
|
add constraint avatar_state_room_background_check
|
||||||
|
check (
|
||||||
|
room_background in (
|
||||||
|
'room-day',
|
||||||
|
'room-dusk',
|
||||||
|
'room-night',
|
||||||
|
'room-forest',
|
||||||
|
'room-space',
|
||||||
|
'room-neon'
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
alter table public.avatar_state
|
||||||
|
drop constraint if exists avatar_state_room_background_check;
|
||||||
|
|
||||||
|
alter table public.avatar_state
|
||||||
|
add constraint avatar_state_room_background_check
|
||||||
|
check (
|
||||||
|
room_background in (
|
||||||
|
'room-day',
|
||||||
|
'room-dusk',
|
||||||
|
'room-night',
|
||||||
|
'room-forest'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
update public.avatar_state
|
||||||
|
set room_background = 'room-day'
|
||||||
|
where room_background in ('room-space', 'room-neon');
|
||||||
|
|
||||||
|
delete from public.user_items
|
||||||
|
where item_id in ('room-space', 'room-neon');
|
||||||
|
|
||||||
|
delete from public.shop_items
|
||||||
|
where id in ('room-space', 'room-neon');
|
||||||
@@ -30,7 +30,9 @@ values
|
|||||||
('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'),
|
('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'),
|
||||||
('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'),
|
('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'),
|
||||||
('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'),
|
('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'),
|
||||||
('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png')
|
('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'),
|
||||||
|
('room-night', 'Starlit Night', 'room', 40, ''),
|
||||||
|
('room-forest', 'Mystic Forest', 'room', 50, '')
|
||||||
on conflict (id) do update
|
on conflict (id) do update
|
||||||
set
|
set
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ export default {
|
|||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 4px)",
|
||||||
},
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-pixel)", "monospace"],
|
||||||
|
pixel: ["var(--font-pixel)", "monospace"],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
nav: "var(--bottom-nav-height)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [tailwindcssAnimate],
|
plugins: [tailwindcssAnimate],
|
||||||
|
|||||||
Reference in New Issue
Block a user