diff --git a/app/globals.css b/app/globals.css index 423b662..d402579 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 { diff --git a/components/avatar/avatar-customize-layout.tsx b/components/avatar/avatar-customize-layout.tsx index 6de9399..fde45aa 100644 --- a/components/avatar/avatar-customize-layout.tsx +++ b/components/avatar/avatar-customize-layout.tsx @@ -15,7 +15,7 @@ export function AvatarCustomizeLayout({ children, }: AvatarCustomizeLayoutProps) { return ( -
+
diff --git a/components/avatar/pixel-avatar.tsx b/components/avatar/pixel-avatar.tsx index 86aa9ba..66718c3 100644 --- a/components/avatar/pixel-avatar.tsx +++ b/components/avatar/pixel-avatar.tsx @@ -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 (
-

- {mood} -

+ {showMoodLabel ? ( +

+ {mood} +

+ ) : null}
); } diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx index fa60dac..c5da3a3 100644 --- a/components/character/character-creator.tsx +++ b/components/character/character-creator.tsx @@ -371,10 +371,7 @@ export function CharacterCreator({
- +
(null); const [isReady, setIsReady] = useState(cachedQuiz !== null); - const [liveCondition, setLiveCondition] = useState( - cachedQuiz?.liveCondition ?? cachedQuiz?.submission?.condition ?? null, - ); const applyQuizData = useCallback( (data: NonNullable>) => { @@ -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 (
{isCompleted && submission ? ( - <> - - -
-
-
-

- Today's quiz is complete -

-

- 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 pet today - - Based on your wellness check-in and completed habits today. - - - - + +
+
+
+

+ Today's quiz is complete +

+

+ Your answers are saved below. Come back tomorrow for your next + check-in. How you did today affects your pet. Spend time with{" "} + + your pet + {" "} + whenever you want to see how they are doing. +

+
+
+
) : (
Today's check-in @@ -230,14 +171,14 @@ export function DailyQuizForm() {
)} - + {isCompleted ? "Your answers today" : "Wellness"} {isCompleted - ? "These fields are locked until tomorrow's quiz." + ? "What you answered today." : "Rate your wellness from 1 to 5. Sleep length uses hours."} @@ -284,12 +225,12 @@ export function DailyQuizForm() { - + Daily journal {isCompleted - ? "Your journal entry for today." + ? "What you wrote today." : "Write about your day."} @@ -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 ? ( diff --git a/components/daily-quiz/wellness-slider.tsx b/components/daily-quiz/wellness-slider.tsx index c074ceb..21da67a 100644 --- a/components/daily-quiz/wellness-slider.tsx +++ b/components/daily-quiz/wellness-slider.tsx @@ -30,10 +30,14 @@ export function WellnessSlider({ const displayValue = formatValue ? formatValue(value) : `${value}${unit ?? ""}`; return ( -
+
- - {displayValue} + + + {displayValue} +
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" + } />
); diff --git a/components/habits/habit-tracker.tsx b/components/habits/habit-tracker.tsx index ce452bf..46e92dd 100644 --- a/components/habits/habit-tracker.tsx +++ b/components/habits/habit-tracker.tsx @@ -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); diff --git a/components/layout/app-page-header.tsx b/components/layout/app-page-header.tsx index 37ed7f1..261b267 100644 --- a/components/layout/app-page-header.tsx +++ b/components/layout/app-page-header.tsx @@ -10,7 +10,7 @@ export function AppPageHeader({ eyebrow, }: AppPageHeaderProps) { return ( -
+
{eyebrow ? (

{eyebrow} diff --git a/components/layout/app-shell.tsx b/components/layout/app-shell.tsx index 5046105..cf5e4e1 100644 --- a/components/layout/app-shell.tsx +++ b/components/layout/app-shell.tsx @@ -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 ( -

+
{title ? ( @@ -46,6 +44,7 @@ export function AppShell({
{children} diff --git a/components/pet/parallax-room-background.tsx b/components/pet/parallax-room-background.tsx index e1e83ba..0e6bf67 100644 --- a/components/pet/parallax-room-background.tsx +++ b/components/pet/parallax-room-background.tsx @@ -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(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 ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - -
- ); - } return (
- {room.layers.map((layer, index) => ( -
- ))} + {/* eslint-disable-next-line @next/next/no-img-element */} +
); } diff --git a/components/pet/room-background-picker.tsx b/components/pet/room-background-picker.tsx index 176016a..5a9be92 100644 --- a/components/pet/room-background-picker.tsx +++ b/components/pet/room-background-picker.tsx @@ -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 */} +

{room.name}

diff --git a/components/pet/room-style-selector.tsx b/components/pet/room-style-selector.tsx index 2e512d0..4c13e1b 100644 --- a/components/pet/room-style-selector.tsx +++ b/components/pet/room-style-selector.tsx @@ -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 */} + {locked ? (
- +
diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index 9423a4b..135f53a 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -79,16 +79,12 @@ function ShopItemCard({
{room ? ( - item.image_path ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
- ) + // eslint-disable-next-line @next/next/no-img-element + ) : ( ([]); const [ownedItemIds, setOwnedItemIds] = useState([]); const [equippedItems, setEquippedItems] = useState([]); - const [equippedRoomBackground, setEquippedRoomBackground] = useState("room-day"); + const [equippedRoomBackground, setEquippedRoomBackground] = useState("room-1"); const [coins, setCoins] = useState(null); const [error, setError] = useState(null); const [pendingItemId, setPendingItemId] = useState(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(); diff --git a/components/shop/shop-item-preview-modal.tsx b/components/shop/shop-item-preview-modal.tsx index 7ac03a2..7ad1248 100644 --- a/components/shop/shop-item-preview-modal.tsx +++ b/components/shop/shop-item-preview-modal.tsx @@ -105,7 +105,7 @@ export function ShopItemPreviewModal({
- +
+ `/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 = { + "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, })); - } - - diff --git a/lib/shop-storage.ts b/lib/shop-storage.ts index df105da..06ed7aa 100644 --- a/lib/shop-storage.ts +++ b/lib/shop-storage.ts @@ -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 { const userId = await getAuthenticatedUserId(); if (!userId) { - return "room-day"; + return "room-1"; } const supabase = createClient(); @@ -115,13 +116,13 @@ export async function getEquippedRoomBackground(): Promise { 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) { diff --git a/public/room/room-1.png b/public/room/room-1.png new file mode 100644 index 0000000..805250d Binary files /dev/null and b/public/room/room-1.png differ diff --git a/public/room/room-2.png b/public/room/room-2.png new file mode 100644 index 0000000..de72077 Binary files /dev/null and b/public/room/room-2.png differ diff --git a/public/room/room-3.png b/public/room/room-3.png new file mode 100644 index 0000000..9705a9e Binary files /dev/null and b/public/room/room-3.png differ diff --git a/public/room/room-4.png b/public/room/room-4.png new file mode 100644 index 0000000..8817955 Binary files /dev/null and b/public/room/room-4.png differ diff --git a/public/shop/room-forest.svg b/public/shop/room-forest.svg deleted file mode 100644 index af6b333..0000000 --- a/public/shop/room-forest.svg +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/shop/room-night.svg b/public/shop/room-night.svg deleted file mode 100644 index 8f209e4..0000000 --- a/public/shop/room-night.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/supabase/migrations/20260525200000_room_png_ids.sql b/supabase/migrations/20260525200000_room_png_ids.sql new file mode 100644 index 0000000..9022523 --- /dev/null +++ b/supabase/migrations/20260525200000_room_png_ids.sql @@ -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' + ) + ); diff --git a/supabase/seed.sql b/supabase/seed.sql index 1b41564..98bf955 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -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,