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>
{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,7 +138,6 @@ 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">
@@ -178,51 +151,19 @@ export function DailyQuizForm() {
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.
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>
<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"
/>
<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 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,51 +9,17 @@ 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,
)}
className={cn("absolute inset-0 z-0 overflow-hidden bg-lcd-bg", className)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -66,28 +30,4 @@ export function ParallaxRoomBackground({
/>
</div>
);
}
return (
<div
ref={containerRef}
className={cn("absolute inset-0 z-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>
);
}

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
{/* 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)} />
)}
<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
{/* 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)} />
)}
{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}
src={item.image_path || room.sceneImage}
alt=""
className="size-full object-cover image-pixelated"
/>
) : (
<div className={cn("size-full", room.previewClassName)} />
)
) : (
<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,