Polish quiz completion UX, room PNG backgrounds, and habits list order
Polish quiz completion UX, rooms, and app layout.
This commit is contained in:
@@ -222,6 +222,28 @@
|
|||||||
.image-pixelated {
|
.image-pixelated {
|
||||||
image-rendering: 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 {
|
@keyframes pet-breathe {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function AvatarCustomizeLayout({
|
|||||||
children,
|
children,
|
||||||
}: AvatarCustomizeLayoutProps) {
|
}: AvatarCustomizeLayoutProps) {
|
||||||
return (
|
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 />
|
<AppTopBar />
|
||||||
|
|
||||||
<main className="mx-auto max-w-lg px-4 py-6 lg:max-w-6xl lg:px-5">
|
<main className="mx-auto max-w-lg px-4 py-6 lg:max-w-6xl lg:px-5">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type PixelAvatarProps = {
|
|||||||
mood?: AvatarMood;
|
mood?: AvatarMood;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
showMoodLabel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
@@ -23,6 +24,7 @@ export function PixelAvatar({
|
|||||||
mood = "neutral",
|
mood = "neutral",
|
||||||
size = "lg",
|
size = "lg",
|
||||||
className,
|
className,
|
||||||
|
showMoodLabel = true,
|
||||||
}: PixelAvatarProps) {
|
}: PixelAvatarProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -43,9 +45,11 @@ export function PixelAvatar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-[24%] left-1/2 h-1 w-4 -translate-x-1/2 rounded-[1px] bg-foreground/80" />
|
<div className="absolute bottom-[24%] left-1/2 h-1 w-4 -translate-x-1/2 rounded-[1px] bg-foreground/80" />
|
||||||
</div>
|
</div>
|
||||||
|
{showMoodLabel ? (
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
{mood}
|
{mood}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="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="flex min-h-full min-w-0 flex-col">
|
||||||
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
||||||
<ParallaxRoomBackground
|
<ParallaxRoomBackground roomId={displayCustomization.roomBackground} />
|
||||||
roomId={displayCustomization.roomBackground}
|
|
||||||
interactive={false}
|
|
||||||
/>
|
|
||||||
<div className="relative z-10 flex h-full items-center justify-center">
|
<div className="relative z-10 flex h-full items-center justify-center">
|
||||||
<CharacterLayerPreview
|
<CharacterLayerPreview
|
||||||
colors={displayCustomization.colors}
|
colors={displayCustomization.colors}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
|
|
||||||
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
|
import { WellnessSlider } from "@/components/daily-quiz/wellness-slider";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -21,20 +21,16 @@ import {
|
|||||||
SLEEP_HOURS_MAX,
|
SLEEP_HOURS_MAX,
|
||||||
SLEEP_HOURS_MIN,
|
SLEEP_HOURS_MIN,
|
||||||
SLEEP_HOURS_STEP,
|
SLEEP_HOURS_STEP,
|
||||||
WELLNESS_SCALE_MAX,
|
|
||||||
type DailyQuizAnswers,
|
type DailyQuizAnswers,
|
||||||
type DailyQuizSubmission,
|
type DailyQuizSubmission,
|
||||||
} from "@/lib/avatar-state";
|
} from "@/lib/avatar-state";
|
||||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
|
||||||
import {
|
import {
|
||||||
getCachedQuizTabData,
|
getCachedQuizTabData,
|
||||||
prefetchQuizTabData,
|
prefetchQuizTabData,
|
||||||
} from "@/lib/app-tab-data-cache";
|
} from "@/lib/app-tab-data-cache";
|
||||||
import {
|
import { saveDailyEntry } from "@/lib/daily-quiz-storage";
|
||||||
getAvatarConditionForToday,
|
|
||||||
saveDailyEntry,
|
|
||||||
} from "@/lib/daily-quiz-storage";
|
|
||||||
import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety";
|
import { JOURNAL_MAX_LENGTH } from "@/lib/journal-safety";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
export function DailyQuizForm() {
|
export function DailyQuizForm() {
|
||||||
const cachedQuiz = getCachedQuizTabData();
|
const cachedQuiz = getCachedQuizTabData();
|
||||||
@@ -48,9 +44,6 @@ export function DailyQuizForm() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isReady, setIsReady] = useState(cachedQuiz !== null);
|
const [isReady, setIsReady] = useState(cachedQuiz !== null);
|
||||||
const [liveCondition, setLiveCondition] = useState(
|
|
||||||
cachedQuiz?.liveCondition ?? cachedQuiz?.submission?.condition ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyQuizData = useCallback(
|
const applyQuizData = useCallback(
|
||||||
(data: NonNullable<ReturnType<typeof getCachedQuizTabData>>) => {
|
(data: NonNullable<ReturnType<typeof getCachedQuizTabData>>) => {
|
||||||
@@ -58,19 +51,13 @@ export function DailyQuizForm() {
|
|||||||
setSubmission(data.submission);
|
setSubmission(data.submission);
|
||||||
setAnswers(data.submission.answers);
|
setAnswers(data.submission.answers);
|
||||||
setJournal(data.submission.journal);
|
setJournal(data.submission.journal);
|
||||||
setLiveCondition(data.submission.condition);
|
|
||||||
} else {
|
} else {
|
||||||
setSubmission(null);
|
setSubmission(null);
|
||||||
setLiveCondition(data.liveCondition);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshLiveCondition = useCallback(async () => {
|
|
||||||
setLiveCondition(await getAvatarConditionForToday());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -107,18 +94,6 @@ export function DailyQuizForm() {
|
|||||||
};
|
};
|
||||||
}, [applyQuizData]);
|
}, [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 isCompleted = submission !== null;
|
||||||
const canSubmit = !isCompleted && !isSubmitting;
|
const canSubmit = !isCompleted && !isSubmitting;
|
||||||
|
|
||||||
@@ -143,7 +118,6 @@ export function DailyQuizForm() {
|
|||||||
try {
|
try {
|
||||||
const savedEntry = await saveDailyEntry(answers, journal);
|
const savedEntry = await saveDailyEntry(answers, journal);
|
||||||
setSubmission(savedEntry);
|
setSubmission(savedEntry);
|
||||||
setLiveCondition(savedEntry.condition);
|
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(
|
setError(
|
||||||
submitError instanceof Error
|
submitError instanceof Error
|
||||||
@@ -164,7 +138,6 @@ export function DailyQuizForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{isCompleted && submission ? (
|
{isCompleted && submission ? (
|
||||||
<>
|
|
||||||
<Card className="border-primary/40 bg-primary/10">
|
<Card className="border-primary/40 bg-primary/10">
|
||||||
<CardContent className="flex items-start gap-4 pt-6">
|
<CardContent className="flex items-start gap-4 pt-6">
|
||||||
<div className="rounded-full bg-primary/15 p-2.5">
|
<div className="rounded-full bg-primary/15 p-2.5">
|
||||||
@@ -178,51 +151,19 @@ export function DailyQuizForm() {
|
|||||||
Today's quiz is complete
|
Today's quiz is complete
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Your wellness check-in and journal are saved for today. You
|
Your answers are saved below. Come back tomorrow for your next
|
||||||
can review your answers below, and come back tomorrow for
|
check-in. How you did today affects your pet. Spend time with{" "}
|
||||||
your next quiz.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<div className="flex items-center justify-between">
|
||||||
<Badge>Today's check-in</Badge>
|
<Badge>Today's check-in</Badge>
|
||||||
@@ -230,14 +171,14 @@ export function DailyQuizForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className={isCompleted ? "opacity-90" : undefined}>
|
<Card className={isCompleted ? "form-section-locked" : undefined}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
{isCompleted ? "Your answers today" : "Wellness"}
|
{isCompleted ? "Your answers today" : "Wellness"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{isCompleted
|
{isCompleted
|
||||||
? "These fields are locked until tomorrow's quiz."
|
? "What you answered today."
|
||||||
: "Rate your wellness from 1 to 5. Sleep length uses hours."}
|
: "Rate your wellness from 1 to 5. Sleep length uses hours."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -284,12 +225,12 @@ export function DailyQuizForm() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className={isCompleted ? "opacity-90" : undefined}>
|
<Card className={isCompleted ? "form-section-locked" : undefined}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Daily journal</CardTitle>
|
<CardTitle className="text-base">Daily journal</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{isCompleted
|
{isCompleted
|
||||||
? "Your journal entry for today."
|
? "What you wrote today."
|
||||||
: "Write about your day."}
|
: "Write about your day."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -303,7 +244,7 @@ export function DailyQuizForm() {
|
|||||||
disabled={isCompleted}
|
disabled={isCompleted}
|
||||||
maxLength={JOURNAL_MAX_LENGTH}
|
maxLength={JOURNAL_MAX_LENGTH}
|
||||||
onChange={(event) => setJournal(event.target.value)}
|
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."
|
placeholder="Write about your day."
|
||||||
/>
|
/>
|
||||||
{!isCompleted ? (
|
{!isCompleted ? (
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ export function WellnessSlider({
|
|||||||
const displayValue = formatValue ? formatValue(value) : `${value}${unit ?? ""}`;
|
const displayValue = formatValue ? formatValue(value) : `${value}${unit ?? ""}`;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<Label htmlFor={id} className={disabled ? "text-muted-foreground" : undefined}>
|
||||||
<span className="text-muted-foreground">{displayValue}</span>
|
{label}
|
||||||
|
</Label>
|
||||||
|
<span className={disabled ? "text-muted-foreground/80" : "text-muted-foreground"}>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -44,7 +48,11 @@ export function WellnessSlider({
|
|||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedTasks.map((habit) => {
|
{tasks.map((habit) => {
|
||||||
const isCompleted = getIsCompleted(habit);
|
const isCompleted = getIsCompleted(habit);
|
||||||
const pending = isPending(habit.id);
|
const pending = isPending(habit.id);
|
||||||
const toggleError = getToggleError(habit.id);
|
const toggleError = getToggleError(habit.id);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function AppPageHeader({
|
|||||||
eyebrow,
|
eyebrow,
|
||||||
}: AppPageHeaderProps) {
|
}: AppPageHeaderProps) {
|
||||||
return (
|
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 ? (
|
{eyebrow ? (
|
||||||
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type AppShellProps = {
|
|||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
/** Use remaining viewport height between top bar and bottom nav. */
|
/** Use remaining viewport height between top bar and bottom nav. */
|
||||||
fillViewport?: boolean;
|
fillViewport?: boolean;
|
||||||
/** Center page content horizontally (pairs well with fillViewport). */
|
/** Center page body horizontally (header stays left-aligned in the content column). */
|
||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export function AppShell({
|
|||||||
centered = false,
|
centered = false,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
return (
|
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 />
|
<AppTopBar />
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -35,9 +35,7 @@ export function AppShell({
|
|||||||
fillViewport
|
fillViewport
|
||||||
? "min-h-[calc(100dvh-var(--app-topbar-height)-var(--app-nav-offset)-env(safe-area-inset-top,0px))]"
|
? "min-h-[calc(100dvh-var(--app-topbar-height)-var(--app-nav-offset)-env(safe-area-inset-top,0px))]"
|
||||||
: null,
|
: null,
|
||||||
centered ? "items-center" : null,
|
|
||||||
wide ? contentWidthClass.wide : contentWidthClass.default,
|
wide ? contentWidthClass.wide : contentWidthClass.default,
|
||||||
fillViewport && "max-w-none lg:max-w-none",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title ? (
|
{title ? (
|
||||||
@@ -46,6 +44,7 @@ export function AppShell({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
fillViewport ? "pet-viewport-stage flex w-full flex-1 flex-col" : "contents",
|
fillViewport ? "pet-viewport-stage flex w-full flex-1 flex-col" : "contents",
|
||||||
|
centered ? "items-center" : null,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getRoomBackground,
|
getRoomBackground,
|
||||||
type RoomBackgroundId,
|
type RoomBackgroundId,
|
||||||
@@ -11,51 +9,17 @@ import { cn } from "@/lib/utils";
|
|||||||
type ParallaxRoomBackgroundProps = {
|
type ParallaxRoomBackgroundProps = {
|
||||||
roomId: RoomBackgroundId;
|
roomId: RoomBackgroundId;
|
||||||
className?: string;
|
className?: string;
|
||||||
interactive?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ParallaxRoomBackground({
|
export function ParallaxRoomBackground({
|
||||||
roomId,
|
roomId,
|
||||||
className,
|
className,
|
||||||
interactive = true,
|
|
||||||
}: ParallaxRoomBackgroundProps) {
|
}: ParallaxRoomBackgroundProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
||||||
const room = getRoomBackground(roomId);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
className={cn("absolute inset-0 z-0 overflow-hidden bg-lcd-bg", className)}
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 z-0 overflow-hidden",
|
|
||||||
room.previewClassName,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
@@ -66,28 +30,4 @@ export function ParallaxRoomBackground({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,16 +71,12 @@ export function RoomBackgroundPicker({
|
|||||||
"hover:-translate-y-0.5",
|
"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
|
<img
|
||||||
src={room.sceneImage}
|
src={room.sceneImage}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 size-full object-cover image-pixelated"
|
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">
|
<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}
|
{room.name}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -70,16 +70,12 @@ export function RoomStyleSelector({
|
|||||||
: "border-border/60 hover:border-border",
|
: "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
|
<img
|
||||||
src={room.sceneImage}
|
src={room.sceneImage}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 size-full object-cover image-pixelated"
|
className="absolute inset-0 size-full object-cover image-pixelated"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className={cn("absolute inset-0", room.previewClassName)} />
|
|
||||||
)}
|
|
||||||
{locked ? (
|
{locked ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -227,7 +227,13 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -79,16 +79,12 @@ function ShopItemCard({
|
|||||||
<div className="flex min-w-0 items-start gap-3">
|
<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">
|
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden border-2 border-border bg-zinc-950/80 p-1">
|
||||||
{room ? (
|
{room ? (
|
||||||
item.image_path ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={item.image_path}
|
src={item.image_path || room.sceneImage}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-full object-cover image-pixelated"
|
className="size-full object-cover image-pixelated"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className={cn("size-full", room.previewClassName)} />
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<TintedSpriteIcon
|
<TintedSpriteIcon
|
||||||
src={item.image_path}
|
src={item.image_path}
|
||||||
@@ -141,7 +137,7 @@ export function ShopContent() {
|
|||||||
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 [equippedRoomBackground, setEquippedRoomBackground] = useState("room-1");
|
||||||
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);
|
||||||
@@ -153,7 +149,7 @@ export function ShopContent() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
let nextEquippedItems: string[] = [];
|
let nextEquippedItems: string[] = [];
|
||||||
let nextEquippedRoomBackground = "room-day";
|
let nextEquippedRoomBackground = "room-1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inventory = await getShopInventory();
|
const inventory = await getShopInventory();
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function ShopItemPreviewModal({
|
|||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="tamagotchi-lcd relative aspect-[4/3] w-full overflow-hidden">
|
<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">
|
<div className="relative z-10 flex h-full items-center justify-center">
|
||||||
<CharacterLayerPreview
|
<CharacterLayerPreview
|
||||||
colors={previewCustomization.colors}
|
colors={previewCustomization.colors}
|
||||||
|
|||||||
@@ -1,232 +1,102 @@
|
|||||||
export type RoomBackgroundId =
|
export type RoomBackgroundId =
|
||||||
|
| "room-1"
|
||||||
| "room-day"
|
| "room-2"
|
||||||
|
| "room-3"
|
||||||
| "room-dusk"
|
| "room-4";
|
||||||
|
|
||||||
| "room-night"
|
|
||||||
|
|
||||||
| "room-forest";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type RoomBackgroundLayer = {
|
|
||||||
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
className: string;
|
|
||||||
|
|
||||||
speed: number;
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type RoomBackground = {
|
export type RoomBackground = {
|
||||||
|
|
||||||
id: RoomBackgroundId;
|
id: RoomBackgroundId;
|
||||||
|
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
free: boolean;
|
free: boolean;
|
||||||
|
sceneImage: string;
|
||||||
previewClassName: string;
|
|
||||||
|
|
||||||
/** Full-scene SVG used for shop thumbnail and in-app preview parity. */
|
|
||||||
sceneImage?: string;
|
|
||||||
|
|
||||||
layers: RoomBackgroundLayer[];
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_ROOM_BACKGROUND: RoomBackgroundId = "room-1";
|
||||||
|
|
||||||
|
export const ROOM_SCENE_IMAGE_PATH = (id: RoomBackgroundId) =>
|
||||||
export const DEFAULT_ROOM_BACKGROUND: RoomBackgroundId = "room-day";
|
`/room/${id}.png`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const ROOM_BACKGROUNDS: RoomBackground[] = [
|
export const ROOM_BACKGROUNDS: RoomBackground[] = [
|
||||||
|
|
||||||
{
|
{
|
||||||
|
id: "room-1",
|
||||||
id: "room-day",
|
|
||||||
|
|
||||||
name: "Sunny Field",
|
name: "Sunny Field",
|
||||||
|
|
||||||
free: true,
|
free: true,
|
||||||
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-1"),
|
||||||
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-2",
|
||||||
id: "room-dusk",
|
|
||||||
|
|
||||||
name: "Golden Dusk",
|
name: "Golden Dusk",
|
||||||
|
|
||||||
free: true,
|
free: true,
|
||||||
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-2"),
|
||||||
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 },
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
id: "room-3",
|
||||||
id: "room-night",
|
|
||||||
|
|
||||||
name: "Starlit Night",
|
name: "Starlit Night",
|
||||||
|
|
||||||
free: false,
|
free: false,
|
||||||
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-3"),
|
||||||
previewClassName: "bg-gradient-to-b from-[#1e1b4b] via-[#4c1d95] to-[#0f172a]",
|
|
||||||
|
|
||||||
sceneImage: "/shop/room-night.svg",
|
|
||||||
|
|
||||||
layers: [],
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
id: "room-4",
|
||||||
id: "room-forest",
|
|
||||||
|
|
||||||
name: "Mystic Forest",
|
name: "Mystic Forest",
|
||||||
|
|
||||||
free: false,
|
free: false,
|
||||||
|
sceneImage: ROOM_SCENE_IMAGE_PATH("room-4"),
|
||||||
previewClassName: "bg-gradient-to-b from-[#064e3b] via-[#14532d] to-[#052e16]",
|
|
||||||
|
|
||||||
sceneImage: "/shop/room-forest.svg",
|
|
||||||
|
|
||||||
layers: [],
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const ROOM_MAP = new Map(ROOM_BACKGROUNDS.map((room) => [room.id, room]));
|
const ROOM_MAP = new Map(ROOM_BACKGROUNDS.map((room) => [room.id, room]));
|
||||||
|
|
||||||
|
const LEGACY_ROOM_ID_MAP: Record<string, RoomBackgroundId> = {
|
||||||
|
"room-day": "room-1",
|
||||||
const LEGACY_ROOM_IDS = new Set(["room-space", "room-neon"]);
|
"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 {
|
export function getRoomBackground(id: string | null | undefined): RoomBackground {
|
||||||
|
const normalized = normalizeRoomBackgroundId(id);
|
||||||
if (id && LEGACY_ROOM_IDS.has(id)) {
|
return ROOM_MAP.get(normalized) ?? ROOM_MAP.get(DEFAULT_ROOM_BACKGROUND)!;
|
||||||
|
|
||||||
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 {
|
export function isValidRoomBackgroundId(id: string): id is RoomBackgroundId {
|
||||||
|
|
||||||
return ROOM_MAP.has(id as RoomBackgroundId);
|
return ROOM_MAP.has(id as RoomBackgroundId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeRoomBackgroundId(
|
||||||
|
id: string | null | undefined,
|
||||||
export function normalizeRoomBackgroundId(id: string | null | undefined): RoomBackgroundId {
|
): RoomBackgroundId {
|
||||||
|
if (!id) {
|
||||||
if (id && LEGACY_ROOM_IDS.has(id)) {
|
|
||||||
|
|
||||||
return DEFAULT_ROOM_BACKGROUND;
|
return DEFAULT_ROOM_BACKGROUND;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isValidRoomBackgroundId(id)) {
|
||||||
|
|
||||||
if (id && isValidRoomBackgroundId(id)) {
|
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return LEGACY_ROOM_ID_MAP[id] ?? DEFAULT_ROOM_BACKGROUND;
|
||||||
|
|
||||||
return DEFAULT_ROOM_BACKGROUND;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function isRoomBackgroundUnlocked(
|
export function isRoomBackgroundUnlocked(
|
||||||
|
|
||||||
roomId: RoomBackgroundId,
|
roomId: RoomBackgroundId,
|
||||||
|
|
||||||
ownedItemIds: readonly string[],
|
ownedItemIds: readonly string[],
|
||||||
|
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
||||||
const room = getRoomBackground(roomId);
|
const room = getRoomBackground(roomId);
|
||||||
|
|
||||||
if (room.free) {
|
if (room.free) {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ownedItemIds.some(
|
||||||
|
(ownedId) => normalizeRoomBackgroundId(ownedId) === roomId,
|
||||||
return ownedItemIds.includes(roomId);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function getShopRoomBackgrounds() {
|
export function getShopRoomBackgrounds() {
|
||||||
|
|
||||||
return ROOM_BACKGROUNDS.filter((room) => !room.free).map((room) => ({
|
return ROOM_BACKGROUNDS.filter((room) => !room.free).map((room) => ({
|
||||||
|
|
||||||
id: room.id,
|
id: room.id,
|
||||||
|
|
||||||
name: room.name,
|
name: room.name,
|
||||||
|
|
||||||
type: "room" as const,
|
type: "room" as const,
|
||||||
|
price: room.id === "room-3" ? 40 : 50,
|
||||||
price: room.id === "room-night" ? 40 : 50,
|
image_path: room.sceneImage,
|
||||||
|
|
||||||
image_path: room.sceneImage ?? "",
|
|
||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
||||||
import { getCoins } from "@/lib/avatar-progression-storage";
|
import { getCoins } from "@/lib/avatar-progression-storage";
|
||||||
|
import { normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
|
||||||
import { type ShopItemRecord } from "@/lib/shop-catalog";
|
import { type ShopItemRecord } from "@/lib/shop-catalog";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export async function getEquippedRoomBackground(): Promise<string> {
|
|||||||
const userId = await getAuthenticatedUserId();
|
const userId = await getAuthenticatedUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return "room-day";
|
return "room-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -115,13 +116,13 @@ export async function getEquippedRoomBackground(): Promise<string> {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (/room_background/i.test(error.message)) {
|
if (/room_background/i.test(error.message)) {
|
||||||
return "room-day";
|
return "room-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(error.message);
|
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) {
|
export async function equipShopItem(itemId: string) {
|
||||||
|
|||||||
BIN
public/room/room-1.png
Normal file
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
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
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
BIN
public/room/room-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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 |
@@ -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 |
59
supabase/migrations/20260525200000_room_png_ids.sql
Normal file
59
supabase/migrations/20260525200000_room_png_ids.sql
Normal 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'
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -33,8 +33,8 @@ values
|
|||||||
('head-5', 'Shoulder-Length Hair', 'head', 50, '/character/head/head-5.png'),
|
('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-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'),
|
||||||
('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
|
('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
|
||||||
('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'),
|
('room-3', 'Starlit Night', 'room', 40, '/room/room-3.png'),
|
||||||
('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg')
|
('room-4', 'Mystic Forest', 'room', 50, '/room/room-4.png')
|
||||||
on conflict (id) do update
|
on conflict (id) do update
|
||||||
set
|
set
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
|
|||||||
Reference in New Issue
Block a user