Polish quiz completion UX, room PNG backgrounds, and habits list order

Polish quiz completion UX, rooms, and app layout.
This commit is contained in:
pricewowen
2026-05-24 07:09:40 -06:00
parent 33ba52def1
commit 5ebcf33bca
25 changed files with 219 additions and 476 deletions

View File

@@ -222,6 +222,28 @@
.image-pixelated {
image-rendering: pixelated;
}
.field-locked {
@apply cursor-not-allowed border-border/60 bg-muted/70 text-muted-foreground opacity-75;
}
.form-section-locked {
@apply border-border/50 bg-muted/35;
}
.form-section-locked .field-locked,
.form-section-locked input:disabled,
.form-section-locked textarea:disabled {
@apply cursor-not-allowed opacity-75;
}
.form-section-locked input[type="range"]:disabled {
@apply opacity-50 grayscale;
}
.form-section-locked label {
@apply text-muted-foreground;
}
}
@keyframes pet-breathe {

View File

@@ -15,7 +15,7 @@ export function AvatarCustomizeLayout({
children,
}: AvatarCustomizeLayoutProps) {
return (
<div className="relative min-h-dvh 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%)] pt-topbar">
<div className="relative min-h-dvh bg-background pt-topbar">
<AppTopBar />
<main className="mx-auto max-w-lg px-4 py-6 lg:max-w-6xl lg:px-5">

View File

@@ -11,6 +11,7 @@ type PixelAvatarProps = {
mood?: AvatarMood;
size?: "sm" | "md" | "lg";
className?: string;
showMoodLabel?: boolean;
};
const sizeStyles = {
@@ -23,6 +24,7 @@ export function PixelAvatar({
mood = "neutral",
size = "lg",
className,
showMoodLabel = true,
}: PixelAvatarProps) {
return (
<div
@@ -43,9 +45,11 @@ export function PixelAvatar({
</div>
<div className="absolute bottom-[24%] left-1/2 h-1 w-4 -translate-x-1/2 rounded-[1px] bg-foreground/80" />
</div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{mood}
</p>
{showMoodLabel ? (
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{mood}
</p>
) : null}
</div>
);
}

View File

@@ -371,10 +371,7 @@ export function CharacterCreator({
<div className="customize-body mt-4 grid items-stretch gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
<div className="flex min-h-full min-w-0 flex-col">
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
<ParallaxRoomBackground
roomId={displayCustomization.roomBackground}
interactive={false}
/>
<ParallaxRoomBackground roomId={displayCustomization.roomBackground} />
<div className="relative z-10 flex h-full items-center justify-center">
<CharacterLayerPreview
colors={displayCustomization.colors}

View File

@@ -1,9 +1,9 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -21,20 +21,16 @@ import {
SLEEP_HOURS_MAX,
SLEEP_HOURS_MIN,
SLEEP_HOURS_STEP,
WELLNESS_SCALE_MAX,
type DailyQuizAnswers,
type DailyQuizSubmission,
} from "@/lib/avatar-state";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
getCachedQuizTabData,
prefetchQuizTabData,
} from "@/lib/app-tab-data-cache";
import {
getAvatarConditionForToday,
saveDailyEntry,
} from "@/lib/daily-quiz-storage";
import { saveDailyEntry } from "@/lib/daily-quiz-storage";
import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety";
import { routes } from "@/lib/routes";
export function DailyQuizForm() {
const cachedQuiz = getCachedQuizTabData();
@@ -48,9 +44,6 @@ export function DailyQuizForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(cachedQuiz !== null);
const [liveCondition, setLiveCondition] = useState(
cachedQuiz?.liveCondition ?? cachedQuiz?.submission?.condition ?? null,
);
const applyQuizData = useCallback(
(data: NonNullable<ReturnType<typeof getCachedQuizTabData>>) => {
@@ -58,19 +51,13 @@ export function DailyQuizForm() {
setSubmission(data.submission);
setAnswers(data.submission.answers);
setJournal(data.submission.journal);
setLiveCondition(data.submission.condition);
} else {
setSubmission(null);
setLiveCondition(data.liveCondition);
}
},
[],
);
const refreshLiveCondition = useCallback(async () => {
setLiveCondition(await getAvatarConditionForToday());
}, []);
useEffect(() => {
let cancelled = false;
@@ -107,18 +94,6 @@ export function DailyQuizForm() {
};
}, [applyQuizData]);
useEffect(() => {
const handleDataUpdated = () => {
void refreshLiveCondition();
};
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
};
}, [refreshLiveCondition]);
const isCompleted = submission !== null;
const canSubmit = !isCompleted && !isSubmitting;
@@ -143,7 +118,6 @@ export function DailyQuizForm() {
try {
const savedEntry = await saveDailyEntry(answers, journal);
setSubmission(savedEntry);
setLiveCondition(savedEntry.condition);
} catch (submitError) {
setError(
submitError instanceof Error
@@ -164,65 +138,32 @@ export function DailyQuizForm() {
return (
<div className="flex flex-col gap-6">
{isCompleted && submission ? (
<>
<Card className="border-primary/40 bg-primary/10">
<CardContent className="flex items-start gap-4 pt-6">
<div className="rounded-full bg-primary/15 p-2.5">
<CheckCircle2
aria-hidden="true"
className="h-7 w-7 text-primary"
/>
</div>
<div className="space-y-1">
<p className="text-lg font-semibold tracking-tight">
Today&apos;s quiz is complete
</p>
<p className="text-sm text-muted-foreground">
Your wellness check-in and journal are saved for today. You
can review your answers below, and come back tomorrow for
your next quiz.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Your pet today</CardTitle>
<CardDescription>
Based on your wellness check-in and completed habits today.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<PixelAvatar
mood={(liveCondition ?? submission.condition).mood}
size="md"
<Card className="border-primary/40 bg-primary/10">
<CardContent className="flex items-start gap-4 pt-6">
<div className="rounded-full bg-primary/15 p-2.5">
<CheckCircle2
aria-hidden="true"
className="h-7 w-7 text-primary"
/>
<div className="grid w-full grid-cols-3 gap-2 text-center text-sm">
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Mood</p>
<p className="font-medium capitalize">
{(liveCondition ?? submission.condition).mood}
</p>
</div>
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Energy</p>
<p className="font-medium">
{(liveCondition ?? submission.condition).energy}/
{WELLNESS_SCALE_MAX}
</p>
</div>
<div className="rounded-lg border bg-background px-3 py-2">
<p className="text-muted-foreground">Health</p>
<p className="font-medium">
{(liveCondition ?? submission.condition).health}/
{WELLNESS_SCALE_MAX}
</p>
</div>
</div>
</CardContent>
</Card>
</>
</div>
<div className="space-y-1">
<p className="text-lg font-semibold tracking-tight">
Today&apos;s quiz is complete
</p>
<p className="text-sm text-muted-foreground">
Your answers are saved below. Come back tomorrow for your next
check-in. How you did today affects your pet. Spend time with{" "}
<Link
href={routes.avatar}
className="font-medium text-primary underline-offset-2 hover:underline"
>
your pet
</Link>{" "}
whenever you want to see how they are doing.
</p>
</div>
</CardContent>
</Card>
) : (
<div className="flex items-center justify-between">
<Badge>Today&apos;s check-in</Badge>
@@ -230,14 +171,14 @@ export function DailyQuizForm() {
</div>
)}
<Card className={isCompleted ? "opacity-90" : undefined}>
<Card className={isCompleted ? "form-section-locked" : undefined}>
<CardHeader>
<CardTitle className="text-base">
{isCompleted ? "Your answers today" : "Wellness"}
</CardTitle>
<CardDescription>
{isCompleted
? "These fields are locked until tomorrow's quiz."
? "What you answered today."
: "Rate your wellness from 1 to 5. Sleep length uses hours."}
</CardDescription>
</CardHeader>
@@ -284,12 +225,12 @@ export function DailyQuizForm() {
</CardContent>
</Card>
<Card className={isCompleted ? "opacity-90" : undefined}>
<Card className={isCompleted ? "form-section-locked" : undefined}>
<CardHeader>
<CardTitle className="text-base">Daily journal</CardTitle>
<CardDescription>
{isCompleted
? "Your journal entry for today."
? "What you wrote today."
: "Write about your day."}
</CardDescription>
</CardHeader>
@@ -303,7 +244,7 @@ export function DailyQuizForm() {
disabled={isCompleted}
maxLength={JOURNAL_MAX_LENGTH}
onChange={(event) => setJournal(event.target.value)}
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring field-locked disabled:opacity-75"
placeholder="Write about your day."
/>
{!isCompleted ? (

View File

@@ -30,10 +30,14 @@ export function WellnessSlider({
const displayValue = formatValue ? formatValue(value) : `${value}${unit ?? ""}`;
return (
<div className="space-y-2">
<div className={disabled ? "space-y-2 opacity-90" : "space-y-2"}>
<div className="flex items-center justify-between text-sm">
<Label htmlFor={id}>{label}</Label>
<span className="text-muted-foreground">{displayValue}</span>
<Label htmlFor={id} className={disabled ? "text-muted-foreground" : undefined}>
{label}
</Label>
<span className={disabled ? "text-muted-foreground/80" : "text-muted-foreground"}>
{displayValue}
</span>
</div>
<input
id={id}
@@ -44,7 +48,11 @@ export function WellnessSlider({
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="w-full accent-primary disabled:opacity-60"
className={
disabled
? "w-full cursor-not-allowed accent-muted-foreground opacity-50 grayscale"
: "w-full accent-primary"
}
/>
</div>
);

View File

@@ -63,20 +63,9 @@ function DailyTaskList({
);
}
const sortedTasks = [...tasks].sort((a, b) => {
const aCompleted = getIsCompleted(a);
const bCompleted = getIsCompleted(b);
if (aCompleted === bCompleted) {
return 0;
}
return aCompleted ? 1 : -1;
});
return (
<>
{sortedTasks.map((habit) => {
{tasks.map((habit) => {
const isCompleted = getIsCompleted(habit);
const pending = isPending(habit.id);
const toggleError = getToggleError(habit.id);

View File

@@ -10,7 +10,7 @@ export function AppPageHeader({
eyebrow,
}: AppPageHeaderProps) {
return (
<header className="mb-6 border-b-2 border-border bg-card/60 px-1 pb-5 shadow-[var(--retro-shadow-sm)]">
<header className="mb-6 border-b-2 border-border pb-5">
{eyebrow ? (
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{eyebrow}

View File

@@ -9,7 +9,7 @@ type AppShellProps = {
wide?: boolean;
/** Use remaining viewport height between top bar and bottom nav. */
fillViewport?: boolean;
/** Center page content horizontally (pairs well with fillViewport). */
/** Center page body horizontally (header stays left-aligned in the content column). */
centered?: boolean;
};
@@ -27,7 +27,7 @@ export function AppShell({
centered = false,
}: AppShellProps) {
return (
<div className="relative flex min-h-dvh flex-col 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%)] pt-topbar">
<div className="relative flex min-h-dvh flex-col bg-background pt-topbar">
<AppTopBar />
<main
className={cn(
@@ -35,9 +35,7 @@ export function AppShell({
fillViewport
? "min-h-[calc(100dvh-var(--app-topbar-height)-var(--app-nav-offset)-env(safe-area-inset-top,0px))]"
: null,
centered ? "items-center" : null,
wide ? contentWidthClass.wide : contentWidthClass.default,
fillViewport && "max-w-none lg:max-w-none",
)}
>
{title ? (
@@ -46,6 +44,7 @@ export function AppShell({
<div
className={cn(
fillViewport ? "pet-viewport-stage flex w-full flex-1 flex-col" : "contents",
centered ? "items-center" : null,
)}
>
{children}

View File

@@ -1,7 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
getRoomBackground,
type RoomBackgroundId,
@@ -11,83 +9,25 @@ 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);
const usesSceneImage = Boolean(room.sceneImage);
useEffect(() => {
if (!interactive || usesSceneImage) {
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, usesSceneImage]);
if (room.sceneImage) {
return (
<div
ref={containerRef}
className={cn(
"absolute inset-0 z-0 overflow-hidden",
room.previewClassName,
className,
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={room.sceneImage}
alt=""
aria-hidden
className="absolute inset-0 size-full object-cover image-pixelated"
/>
</div>
);
}
return (
<div
ref={containerRef}
className={cn("absolute inset-0 z-0 overflow-hidden", className)}
className={cn("absolute inset-0 z-0 overflow-hidden bg-lcd-bg", 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)",
}}
/>
))}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={room.sceneImage}
alt=""
aria-hidden
className="absolute inset-0 size-full object-cover image-pixelated"
/>
</div>
);
}

View File

@@ -71,16 +71,12 @@ export function RoomBackgroundPicker({
"hover:-translate-y-0.5",
)}
>
{room.sceneImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={room.sceneImage}
alt=""
className="absolute inset-0 size-full object-cover image-pixelated"
/>
) : (
<div className={cn("absolute inset-0", room.previewClassName)} />
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={room.sceneImage}
alt=""
className="absolute inset-0 size-full object-cover image-pixelated"
/>
<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>

View File

@@ -70,16 +70,12 @@ export function RoomStyleSelector({
: "border-border/60 hover:border-border",
)}
>
{room.sceneImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={room.sceneImage}
alt=""
className="absolute inset-0 size-full object-cover image-pixelated"
/>
) : (
<div className={cn("absolute inset-0", room.previewClassName)} />
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={room.sceneImage}
alt=""
className="absolute inset-0 size-full object-cover image-pixelated"
/>
{locked ? (
<span
aria-hidden

View File

@@ -227,7 +227,13 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={email ?? "Guest user"} readOnly />
<Input
id="email"
value={email ?? "Guest user"}
readOnly
disabled
className="field-locked"
/>
</div>
</CardContent>
</Card>

View File

@@ -79,16 +79,12 @@ function ShopItemCard({
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden border-2 border-border bg-zinc-950/80 p-1">
{room ? (
item.image_path ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.image_path}
alt=""
className="size-full object-cover image-pixelated"
/>
) : (
<div className={cn("size-full", room.previewClassName)} />
)
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.image_path || room.sceneImage}
alt=""
className="size-full object-cover image-pixelated"
/>
) : (
<TintedSpriteIcon
src={item.image_path}
@@ -141,7 +137,7 @@ export function ShopContent() {
const [items, setItems] = useState<ShopItemRecord[]>([]);
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
const [equippedItems, setEquippedItems] = useState<string[]>([]);
const [equippedRoomBackground, setEquippedRoomBackground] = useState("room-day");
const [equippedRoomBackground, setEquippedRoomBackground] = useState("room-1");
const [coins, setCoins] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
@@ -153,7 +149,7 @@ export function ShopContent() {
setError(null);
let nextEquippedItems: string[] = [];
let nextEquippedRoomBackground = "room-day";
let nextEquippedRoomBackground = "room-1";
try {
const inventory = await getShopInventory();

View File

@@ -105,7 +105,7 @@ export function ShopItemPreviewModal({
<div className="p-4">
<div className="tamagotchi-lcd relative aspect-[4/3] w-full overflow-hidden">
<ParallaxRoomBackground roomId={roomId} interactive={false} />
<ParallaxRoomBackground roomId={roomId} />
<div className="relative z-10 flex h-full items-center justify-center">
<CharacterLayerPreview
colors={previewCustomization.colors}

View File

@@ -1,232 +1,102 @@
export type RoomBackgroundId =
| "room-day"
| "room-dusk"
| "room-night"
| "room-forest";
export type RoomBackgroundLayer = {
id: string;
className: string;
speed: number;
};
| "room-1"
| "room-2"
| "room-3"
| "room-4";
export type RoomBackground = {
id: RoomBackgroundId;
name: string;
free: boolean;
previewClassName: string;
/** Full-scene SVG used for shop thumbnail and in-app preview parity. */
sceneImage?: string;
layers: RoomBackgroundLayer[];
sceneImage: string;
};
export const DEFAULT_ROOM_BACKGROUND: RoomBackgroundId = "room-1";
export const DEFAULT_ROOM_BACKGROUND: RoomBackgroundId = "room-day";
export const ROOM_SCENE_IMAGE_PATH = (id: RoomBackgroundId) =>
`/room/${id}.png`;
export const ROOM_BACKGROUNDS: RoomBackground[] = [
{
id: "room-day",
id: "room-1",
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 },
],
sceneImage: ROOM_SCENE_IMAGE_PATH("room-1"),
},
{
id: "room-dusk",
id: "room-2",
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_at_12%_18%,rgba(255,255,255,0.8)_1px,transparent_1px),radial-gradient(circle_at_25%_42%,rgba(255,255,255,0.7)_1px,transparent_1px),radial-gradient(circle_at_38%_12%,rgba(255,255,255,0.9)_1px,transparent_1px),radial-gradient(circle_at_52%_35%,rgba(255,255,255,0.65)_1px,transparent_1px),radial-gradient(circle_at_68%_22%,rgba(255,255,255,0.75)_1px,transparent_1px),radial-gradient(circle_at_82%_48%,rgba(255,255,255,0.7)_1px,transparent_1px),radial-gradient(circle_at_18%_65%,rgba(255,255,255,0.85)_1px,transparent_1px),radial-gradient(circle_at_45%_58%,rgba(255,255,255,0.6)_1px,transparent_1px),radial-gradient(circle_at_72%_72%,rgba(255,255,255,0.7)_1px,transparent_1px),radial-gradient(circle_at_90%_15%,rgba(255,255,255,0.8)_1px,transparent_1px),radial-gradient(circle_at_8%_82%,rgba(255,255,255,0.65)_1px,transparent_1px),radial-gradient(circle_at_58%_88%,rgba(255,255,255,0.75)_1px,transparent_1px)] opacity-20", speed: 0.65 },
],
sceneImage: ROOM_SCENE_IMAGE_PATH("room-2"),
},
{
id: "room-night",
id: "room-3",
name: "Starlit Night",
free: false,
previewClassName: "bg-gradient-to-b from-[#1e1b4b] via-[#4c1d95] to-[#0f172a]",
sceneImage: "/shop/room-night.svg",
layers: [],
sceneImage: ROOM_SCENE_IMAGE_PATH("room-3"),
},
{
id: "room-forest",
id: "room-4",
name: "Mystic Forest",
free: false,
previewClassName: "bg-gradient-to-b from-[#064e3b] via-[#14532d] to-[#052e16]",
sceneImage: "/shop/room-forest.svg",
layers: [],
sceneImage: ROOM_SCENE_IMAGE_PATH("room-4"),
},
];
const ROOM_MAP = new Map(ROOM_BACKGROUNDS.map((room) => [room.id, room]));
const LEGACY_ROOM_IDS = new Set(["room-space", "room-neon"]);
const LEGACY_ROOM_ID_MAP: Record<string, RoomBackgroundId> = {
"room-day": "room-1",
"room-dusk": "room-2",
"room-night": "room-3",
"room-forest": "room-4",
"room-space": "room-1",
"room-neon": "room-1",
};
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)!;
const normalized = normalizeRoomBackgroundId(id);
return ROOM_MAP.get(normalized) ?? 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)) {
export function normalizeRoomBackgroundId(
id: string | null | undefined,
): RoomBackgroundId {
if (!id) {
return DEFAULT_ROOM_BACKGROUND;
}
if (id && isValidRoomBackgroundId(id)) {
if (isValidRoomBackgroundId(id)) {
return id;
}
return DEFAULT_ROOM_BACKGROUND;
return LEGACY_ROOM_ID_MAP[id] ?? 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);
return ownedItemIds.some(
(ownedId) => normalizeRoomBackgroundId(ownedId) === 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: room.sceneImage ?? "",
price: room.id === "room-3" ? 40 : 50,
image_path: room.sceneImage,
}));
}

View File

@@ -1,5 +1,6 @@
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { getCoins } from "@/lib/avatar-progression-storage";
import { normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
import { type ShopItemRecord } from "@/lib/shop-catalog";
import { createClient } from "@/lib/supabase/client";
@@ -103,7 +104,7 @@ export async function getEquippedRoomBackground(): Promise<string> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return "room-day";
return "room-1";
}
const supabase = createClient();
@@ -115,13 +116,13 @@ export async function getEquippedRoomBackground(): Promise<string> {
if (error) {
if (/room_background/i.test(error.message)) {
return "room-day";
return "room-1";
}
throw new Error(error.message);
}
return (data?.room_background as string | null) ?? "room-day";
return normalizeRoomBackgroundId(data?.room_background as string | null);
}
export async function equipShopItem(itemId: string) {

BIN
public/room/room-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/room/room-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/room/room-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/room/room-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,45 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 96" width="128" height="96">
<defs>
<linearGradient id="forest-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#064e3b"/>
<stop offset="50%" stop-color="#14532d"/>
<stop offset="100%" stop-color="#052e16"/>
</linearGradient>
</defs>
<rect width="128" height="96" fill="url(#forest-sky)"/>
<!-- mist -->
<rect x="0" y="48" width="128" height="8" fill="#86efac" opacity="0.12"/>
<rect x="0" y="56" width="128" height="6" fill="#bbf7d0" opacity="0.08"/>
<!-- distant trees -->
<rect x="4" y="58" width="3" height="14" fill="#14532d"/>
<rect x="1" y="52" width="9" height="4" fill="#166534"/>
<rect x="2" y="48" width="7" height="4" fill="#15803d"/>
<rect x="22" y="54" width="3" height="18" fill="#14532d"/>
<rect x="18" y="48" width="11" height="4" fill="#166534"/>
<rect x="20" y="44" width="7" height="4" fill="#15803d"/>
<rect x="108" y="56" width="3" height="16" fill="#14532d"/>
<rect x="104" y="50" width="11" height="4" fill="#166534"/>
<rect x="106" y="46" width="7" height="4" fill="#15803d"/>
<!-- mid trees -->
<rect x="36" y="50" width="4" height="22" fill="#14532d"/>
<rect x="30" y="42" width="16" height="5" fill="#166534"/>
<rect x="32" y="36" width="12" height="5" fill="#15803d"/>
<rect x="34" y="30" width="8" height="5" fill="#22c55e" opacity="0.85"/>
<rect x="68" y="46" width="5" height="26" fill="#14532d"/>
<rect x="62" y="38" width="17" height="5" fill="#166534"/>
<rect x="64" y="32" width="13" height="5" fill="#15803d"/>
<rect x="66" y="26" width="9" height="5" fill="#22c55e" opacity="0.85"/>
<rect x="66" y="20" width="5" height="5" fill="#4ade80" opacity="0.7"/>
<!-- foreground tree -->
<rect x="88" y="52" width="5" height="20" fill="#14532d"/>
<rect x="82" y="44" width="17" height="5" fill="#166534"/>
<rect x="84" y="38" width="13" height="5" fill="#15803d"/>
<rect x="86" y="32" width="9" height="5" fill="#22c55e"/>
<rect x="88" y="26" width="5" height="5" fill="#4ade80"/>
<!-- ground -->
<rect x="0" y="72" width="128" height="24" fill="#166534"/>
<rect x="0" y="72" width="128" height="3" fill="#22c55e" opacity="0.35"/>
<rect x="8" y="78" width="6" height="2" fill="#14532d" opacity="0.5"/>
<rect x="42" y="80" width="8" height="2" fill="#14532d" opacity="0.5"/>
<rect x="96" y="77" width="5" height="2" fill="#14532d" opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,36 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 96" width="128" height="96">
<defs>
<linearGradient id="night-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="55%" stop-color="#4c1d95"/>
<stop offset="100%" stop-color="#0f172a"/>
</linearGradient>
</defs>
<rect width="128" height="96" fill="url(#night-sky)"/>
<!-- stars -->
<rect x="12" y="10" width="2" height="2" fill="#fef9c3"/>
<rect x="28" y="6" width="2" height="2" fill="#fef9c3"/>
<rect x="44" y="14" width="2" height="2" fill="#fef9c3"/>
<rect x="58" y="8" width="2" height="2" fill="#fef9c3"/>
<rect x="72" y="18" width="2" height="2" fill="#fef9c3"/>
<rect x="86" y="12" width="2" height="2" fill="#fef9c3"/>
<rect x="102" y="6" width="2" height="2" fill="#fef9c3"/>
<rect x="18" y="24" width="2" height="2" fill="#fef08a"/>
<rect x="52" y="22" width="2" height="2" fill="#fef9c3"/>
<rect x="94" y="26" width="2" height="2" fill="#fef9c3"/>
<rect x="110" y="20" width="2" height="2" fill="#fef08a"/>
<rect x="36" y="32" width="2" height="2" fill="#fef9c3" opacity="0.8"/>
<rect x="68" y="30" width="2" height="2" fill="#fef9c3" opacity="0.8"/>
<!-- moon glow -->
<circle cx="98" cy="22" r="14" fill="#fef9c3" opacity="0.15"/>
<circle cx="98" cy="22" r="10" fill="#fef9c3" opacity="0.25"/>
<circle cx="98" cy="22" r="7" fill="#fef9c3" opacity="0.95"/>
<circle cx="95" cy="20" r="5" fill="#fef08a" opacity="0.5"/>
<!-- distant hills -->
<rect x="0" y="68" width="128" height="28" fill="#0f172a" opacity="0.55"/>
<rect x="0" y="72" width="40" height="24" fill="#1e1b4b" opacity="0.7"/>
<rect x="36" y="76" width="48" height="20" fill="#312e81" opacity="0.5"/>
<rect x="80" y="70" width="48" height="26" fill="#1e1b4b" opacity="0.65"/>
<!-- ground -->
<rect x="0" y="84" width="128" height="12" fill="#0f172a"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,59 @@
-- Room backgrounds: PNG assets in /public/room with ids room-1 .. room-4.
alter table public.avatar_state
alter column room_background set default 'room-1';
update public.avatar_state
set room_background = case room_background
when 'room-day' then 'room-1'
when 'room-dusk' then 'room-2'
when 'room-night' then 'room-3'
when 'room-forest' then 'room-4'
when 'room-space' then 'room-1'
when 'room-neon' then 'room-1'
else room_background
end
where room_background in (
'room-day',
'room-dusk',
'room-night',
'room-forest',
'room-space',
'room-neon'
);
update public.user_items
set item_id = case item_id
when 'room-night' then 'room-3'
when 'room-forest' then 'room-4'
else item_id
end
where item_id in ('room-night', 'room-forest');
delete from public.shop_items
where id in ('room-night', 'room-forest');
insert into public.shop_items (id, name, type, price, image_path)
values
('room-3', 'Starlit Night', 'room', 40, '/room/room-3.png'),
('room-4', 'Mystic Forest', 'room', 50, '/room/room-4.png')
on conflict (id) do update
set
name = excluded.name,
type = excluded.type,
price = excluded.price,
image_path = excluded.image_path;
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-1',
'room-2',
'room-3',
'room-4'
)
);

View File

@@ -33,8 +33,8 @@ values
('head-5', 'Shoulder-Length Hair', 'head', 50, '/character/head/head-5.png'),
('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'),
('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'),
('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg')
('room-3', 'Starlit Night', 'room', 40, '/room/room-3.png'),
('room-4', 'Mystic Forest', 'room', 50, '/room/room-4.png')
on conflict (id) do update
set
name = excluded.name,