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-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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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'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'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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
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-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,
|
||||
|
||||
Reference in New Issue
Block a user