Stable version with additional styling

This commit is contained in:
pricewowen
2026-05-24 01:43:59 -06:00
parent cd5feb3a98
commit 9fdec200b5
40 changed files with 1682 additions and 467 deletions

View File

@@ -0,0 +1,27 @@
import { AppPageHeader } from "@/components/layout/app-page-header";
type AvatarCustomizeLayoutProps = {
eyebrow?: string;
title: string;
description: string;
children: React.ReactNode;
};
export function AvatarCustomizeLayout({
eyebrow,
title,
description,
children,
}: AvatarCustomizeLayoutProps) {
return (
<div className="relative 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%)]">
<AppPageHeader
eyebrow={eyebrow}
title={title}
description={description}
/>
<main className="mx-auto max-w-6xl px-5 py-6">{children}</main>
</div>
);
}

View File

@@ -1,58 +1,24 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { CharacterCreator } from "@/components/character/character-creator";
import { AvatarStatusCard } from "@/components/avatar/avatar-status-card";
import { DailyQuizLinkButton } from "@/components/daily-quiz/daily-quiz-link-button";
import { HabitTracker } from "@/components/habits/habit-tracker";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/toast-provider";
import { PetHabitat } from "@/components/pet/pet-habitat";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
getAvatarCustomization,
saveAvatarCustomization,
saveAvatarName,
type AvatarCustomization,
} from "@/lib/avatar-customization-storage";
import { getOwnedItemIds } from "@/lib/shop-storage";
import { cn } from "@/lib/utils";
type AvatarTab = "home" | "customize";
export function AvatarPageContent() {
const { toast } = useToast();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<AvatarTab>("home");
const [customization, setCustomization] = useState<AvatarCustomization | null>(
null,
);
const [avatarName, setAvatarName] = useState("");
const [isSavingName, setIsSavingName] = useState(false);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const [ownedVariantIds, setOwnedVariantIds] = useState<string[]>([]);
useEffect(() => {
if (searchParams.get("tab") === "customize") {
setActiveTab("customize");
}
}, [searchParams]);
const [loadError, setLoadError] = useState<string | null>(null);
const loadCustomization = useCallback(async () => {
try {
setLoadError(null);
const [next, owned] = await Promise.all([
getAvatarCustomization(),
getOwnedItemIds(),
]);
setCustomization(next);
setAvatarName(next.name);
setOwnedVariantIds(owned);
setCustomization(await getAvatarCustomization());
} catch (error) {
setLoadError(
error instanceof Error ? error.message : "Could not load your avatar.",
@@ -69,123 +35,15 @@ export function AvatarPageContent() {
};
}, [loadCustomization]);
const handleSaveName = async () => {
setIsSavingName(true);
try {
const savedName = await saveAvatarName(avatarName);
setAvatarName(savedName);
toast("Pet name saved.", "success");
} catch (error) {
toast(
error instanceof Error ? error.message : "Could not save name.",
"error",
);
} finally {
setIsSavingName(false);
}
};
const handleSaveAvatar = async (nextCustomization: AvatarCustomization) => {
setIsSavingAvatar(true);
try {
await saveAvatarCustomization({
...nextCustomization,
name: avatarName.trim() || nextCustomization.name,
equippedItems: customization?.equippedItems ?? [],
});
toast("Avatar updated.", "success");
setActiveTab("home");
await loadCustomization();
} catch (error) {
toast(
error instanceof Error ? error.message : "Could not save avatar.",
"error",
);
throw error;
} finally {
setIsSavingAvatar(false);
}
};
if (loadError) {
return <p className="text-sm text-red-500">{loadError}</p>;
return <p className="text-xs text-red-500">{loadError}</p>;
}
if (!customization) {
return (
<p className="text-sm text-muted-foreground">Loading your pet...</p>
<p className="text-xs text-muted-foreground">Loading your pet...</p>
);
}
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/30 p-1">
{(
[
{ id: "home", label: "Overview" },
{ id: "customize", label: "Customize" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
))}
</div>
<div className="grid gap-3 rounded-lg border bg-card p-4">
<Label htmlFor="avatar-name-shared">Pet name</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="avatar-name-shared"
value={avatarName}
maxLength={32}
onChange={(event) => setAvatarName(event.target.value)}
/>
<Button
type="button"
variant="outline"
disabled={isSavingName}
onClick={() => void handleSaveName()}
>
{isSavingName ? "Saving..." : "Save name"}
</Button>
</div>
</div>
{activeTab === "home" ? (
<>
<AvatarStatusCard customization={customization} />
<HabitTracker />
<DailyQuizLinkButton />
</>
) : (
<CharacterCreator
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${avatarName}`}
initialCustomization={{
...customization,
name: avatarName,
}}
name={avatarName}
onNameChange={setAvatarName}
ownedVariantIds={ownedVariantIds}
showNameField
saveLabel={isSavingAvatar ? "Saving..." : "Save changes"}
isSaving={isSavingAvatar}
onSave={handleSaveAvatar}
/>
)}
</div>
);
return <PetHabitat customization={customization} />;
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { CharacterCreator } from "@/components/character/character-creator";
import { useToast } from "@/components/ui/toast-provider";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import {
getAvatarCustomization,
saveAvatarCustomization,
type AvatarCustomization,
} from "@/lib/avatar-customization-storage";
import { getOwnedItemIds } from "@/lib/shop-storage";
export function CustomizePageContent() {
const { toast } = useToast();
const [customization, setCustomization] = useState<AvatarCustomization | null>(
null,
);
const [avatarName, setAvatarName] = useState("");
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const [ownedVariantIds, setOwnedVariantIds] = useState<string[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const loadCustomization = useCallback(async () => {
try {
setLoadError(null);
const [next, owned] = await Promise.all([
getAvatarCustomization(),
getOwnedItemIds(),
]);
setCustomization(next);
setAvatarName(next.name);
setOwnedVariantIds(owned);
} catch (error) {
setLoadError(
error instanceof Error ? error.message : "Could not load your avatar.",
);
}
}, []);
useEffect(() => {
void loadCustomization();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, loadCustomization);
return () => {
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, loadCustomization);
};
}, [loadCustomization]);
const handleSaveAvatar = async (nextCustomization: AvatarCustomization) => {
setIsSavingAvatar(true);
try {
await saveAvatarCustomization({
...nextCustomization,
name: avatarName.trim() || nextCustomization.name,
equippedItems: customization?.equippedItems ?? [],
});
toast("Avatar updated.", "success");
await loadCustomization();
} catch (error) {
toast(
error instanceof Error ? error.message : "Could not save avatar.",
"error",
);
throw error;
} finally {
setIsSavingAvatar(false);
}
};
if (loadError) {
return <p className="text-xs text-red-500">{loadError}</p>;
}
if (!customization) {
return <p className="text-xs text-muted-foreground">Loading customization...</p>;
}
return (
<CharacterCreator
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}`}
initialCustomization={{
...customization,
name: avatarName,
}}
name={avatarName}
onNameChange={setAvatarName}
ownedVariantIds={ownedVariantIds}
showNameField
saveLabel={isSavingAvatar ? "Saving..." : "Save changes"}
isSaving={isSavingAvatar}
onSave={handleSaveAvatar}
/>
);
}

View File

@@ -7,24 +7,30 @@ import { CharacterColorSliders } from "@/components/character/character-color-sl
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
import { CharacterLayerTabs } from "@/components/character/character-layer-tabs";
import { CharacterPieceSelector } from "@/components/character/character-piece-selector";
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
import { RoomStyleSelector } from "@/components/pet/room-style-selector";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import { clampHsl } from "@/lib/character/color-utils";
import {
CHARACTER_LAYERS,
COLOR_PRESETS,
DEFAULT_GRAY_COLOR,
LAYER_DEFAULT_COLORS,
NONE_VARIANT_ID,
buildDefaultVariants,
getLayerById,
type CharacterCreatorTabId,
type ColorPreset,
} from "@/lib/character/presets";
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
import { isVariantUnlocked } from "@/lib/character/variant-access";
import {
isRoomBackgroundUnlocked,
normalizeRoomBackgroundId,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
@@ -74,14 +80,20 @@ export function CharacterCreator({
const [variants, setVariants] = useState<LayerVariantState>(
initialCustomization?.variants ?? buildDefaultVariants(),
);
const [roomBackground, setRoomBackground] = useState<RoomBackgroundId>(
normalizeRoomBackgroundId(initialCustomization?.roomBackground),
);
const [activePresetId, setActivePresetId] = useState<string>(
DEFAULT_GRAY_COLOR.id,
);
const [activeLayerId, setActiveLayerId] = useState<
(typeof CHARACTER_LAYERS)[number]["id"]
>("skin");
const [activeTabId, setActiveTabId] = useState<CharacterCreatorTabId>("skin");
const [saveError, setSaveError] = useState<string | null>(null);
const isRoomTab = activeTabId === "room";
const isSkinTab = activeTabId === "skin";
const showStyleSection = !isSkinTab;
const showColorAdjust = !isRoomTab;
const activeLayerId = isRoomTab ? "skin" : activeTabId;
const activeLayer = getLayerById(activeLayerId);
const activeColor = colors[activeLayerId];
const ownedSet = new Set(ownedVariantIds);
@@ -112,9 +124,14 @@ export function CharacterCreator({
}));
};
const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => {
setActiveLayerId(layerId);
const layerColor = colors[layerId];
const handleTabChange = (tabId: CharacterCreatorTabId) => {
setActiveTabId(tabId);
if (tabId === "room") {
return;
}
const layerColor = colors[tabId];
const matchingPreset = COLOR_PRESETS.find(
(preset) =>
preset.hsl.h === layerColor.h &&
@@ -135,12 +152,21 @@ export function CharacterCreator({
}));
};
const handleRoomChange = (roomId: RoomBackgroundId) => {
if (!isRoomBackgroundUnlocked(roomId, ownedVariantIds)) {
return;
}
setRoomBackground(roomId);
};
const buildCustomization = (): AvatarCustomization => ({
name,
colors,
variants,
customized: true,
equippedItems: initialCustomization?.equippedItems ?? [],
roomBackground,
});
const handleSave = async () => {
@@ -160,9 +186,9 @@ export function CharacterCreator({
};
return (
<div className="grid gap-5">
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
{showNameField ? (
<div className="grid max-w-md gap-2">
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
<Label htmlFor="avatar-name">Pet name</Label>
<Input
id="avatar-name"
@@ -174,75 +200,89 @@ export function CharacterCreator({
</div>
) : null}
<CharacterLayerTabs
activeLayerId={activeLayerId}
onLayerChange={handleLayerChange}
/>
<CharacterLayerTabs activeTabId={activeTabId} onTabChange={handleTabChange} />
<div className="grid items-stretch gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<Card className="flex flex-col overflow-hidden border-border/60 shadow-sm">
<CardContent className="flex flex-1 items-center justify-center p-6">
<CharacterLayerPreview
colors={colors}
variants={variants}
scale={10}
className="w-full"
/>
</CardContent>
</Card>
<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={roomBackground} interactive={false} />
<div className="relative z-10 flex h-full items-center justify-center">
<CharacterLayerPreview
colors={colors}
variants={variants}
scale={8}
compact
className="!h-auto max-h-none"
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
</div>
</div>
<Card className="flex flex-col border-border/60 shadow-sm">
<CardContent className="flex flex-1 flex-col gap-5 p-6">
<section className="grid h-[5.75rem] shrink-0 gap-3">
<div className="flex min-h-full min-w-0 flex-col gap-5 border-t-2 border-border/60 pt-4 lg:border-l-2 lg:border-t-0 lg:pl-5 lg:pt-0">
{showStyleSection ? (
<section className="grid shrink-0 gap-3">
<SectionLabel>Style</SectionLabel>
<div className="min-h-14">
<CharacterPieceSelector
variants={pieceVariants}
lockedVariantIds={lockedVariantIds}
activeId={variants[activeLayerId]}
{isRoomTab ? (
<RoomStyleSelector
activeId={roomBackground}
ownedItemIds={ownedVariantIds}
onSelect={handleRoomChange}
/>
) : (
<CharacterPieceSelector
variants={pieceVariants}
lockedVariantIds={lockedVariantIds}
activeId={variants[activeLayerId]}
color={activeColor}
skinColor={colors.skin}
onSelect={handleVariantChange}
/>
)}
</div>
</section>
) : null}
{showColorAdjust ? (
<>
<section className="grid shrink-0 gap-3">
<SectionLabel>Color</SectionLabel>
<div className="min-h-[4.75rem]">
<CharacterColorPresets
presets={COLOR_PRESETS}
activeId={activePresetId}
onSelect={applyPreset}
/>
</div>
</section>
<section className="grid shrink-0 gap-4">
<SectionLabel>Adjust</SectionLabel>
<CharacterColorSliders
color={activeColor}
skinColor={colors.skin}
onSelect={handleVariantChange}
onChange={updateActiveColor}
/>
</div>
</section>
</section>
</>
) : null}
<section className="grid shrink-0 gap-3">
<SectionLabel>Color</SectionLabel>
<div className="min-h-[4.75rem]">
<CharacterColorPresets
presets={COLOR_PRESETS}
activeId={activePresetId}
onSelect={applyPreset}
/>
</div>
</section>
<section className="grid shrink-0 gap-4">
<SectionLabel>Adjust</SectionLabel>
<CharacterColorSliders
color={activeColor}
onChange={updateActiveColor}
/>
</section>
{onSave ? (
<div className="grid gap-2 pt-1">
<Button
id="avatar-customization-save"
type="button"
disabled={isSaving}
onClick={() => void handleSave()}
>
{isSaving ? "Saving..." : saveLabel}
</Button>
{saveError ? (
<p className="text-sm text-red-500">{saveError}</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
{onSave ? (
<div className="mt-auto grid gap-2 pt-1">
<Button
id="avatar-customization-save"
type="button"
disabled={isSaving}
onClick={() => void handleSave()}
>
{isSaving ? "Saving..." : saveLabel}
</Button>
{saveError ? (
<p className="text-sm text-red-500">{saveError}</p>
) : null}
</div>
) : null}
</div>
</div>
</div>
);

View File

@@ -1,38 +1,41 @@
"use client";
import { CHARACTER_LAYERS, type CharacterLayerId } from "@/lib/character/presets";
import {
CHARACTER_CUSTOMIZATION_TABS,
type CharacterCreatorTabId,
} from "@/lib/character/presets";
import { cn } from "@/lib/utils";
type CharacterLayerTabsProps = {
activeLayerId: CharacterLayerId;
onLayerChange: (layerId: CharacterLayerId) => void;
activeTabId: CharacterCreatorTabId;
onTabChange: (tabId: CharacterCreatorTabId) => void;
};
export function CharacterLayerTabs({
activeLayerId,
onLayerChange,
activeTabId,
onTabChange,
}: CharacterLayerTabsProps) {
return (
<div
role="tablist"
aria-label="Character layers"
className="flex gap-0.5 overflow-x-auto rounded-lg border bg-muted/30 p-1"
aria-label="Character customization"
className="grid grid-cols-7 gap-0.5 border-2 border-border bg-muted/30 p-0.5"
>
{CHARACTER_LAYERS.map((layer) => (
{CHARACTER_CUSTOMIZATION_TABS.map((tab) => (
<button
key={layer.id}
key={tab.id}
type="button"
role="tab"
aria-selected={activeLayerId === layer.id}
onClick={() => onLayerChange(layer.id)}
aria-selected={activeTabId === tab.id}
onClick={() => onTabChange(tab.id)}
className={cn(
"min-w-[4.5rem] flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors",
activeLayerId === layer.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
"min-w-0 truncate border-2 px-0.5 py-1.5 text-[7px] font-medium uppercase leading-tight tracking-wide transition-colors sm:px-1 sm:text-[8px]",
activeTabId === tab.id
? "border-secondary bg-background text-foreground shadow-[var(--retro-shadow-sm)]"
: "border-transparent text-muted-foreground hover:border-border/60 hover:text-foreground",
)}
>
{layer.label}
{tab.label}
</button>
))}
</div>

View File

@@ -35,7 +35,7 @@ import { getProfilePreferences } from "@/lib/profile-preferences-storage";
import { hasCompletedDailyQuizToday } from "@/lib/daily-quiz-storage";
type HabitTrackerProps = {
mode?: "daily" | "manage";
mode?: "daily" | "manage" | "all";
};
const reasonLabels = {
@@ -409,6 +409,90 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
);
}
if (mode === "all") {
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Today&apos;s tasks</CardTitle>
<CardDescription>
Personalized from your focus topics
{quizCompletedToday ? ", today's quiz," : ""} and custom habits.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{coinMessage ? (
<p className="text-sm font-medium text-emerald-600">{coinMessage}</p>
) : null}
<DailyTaskList
tasks={dailyTasks}
getIsCompleted={getIsCompleted}
isPending={isPending}
getToggleError={getToggleError}
onToggle={handleToggle}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Custom habits</CardTitle>
<CardDescription>
Add your own habits. Custom habits always appear in your daily
task list.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CustomHabitList habits={customHabits} onRemove={handleRemoveHabit} />
<div className="flex gap-2">
<Input
value={newHabitLabel}
placeholder="Add a custom habit"
disabled={isSaving}
onChange={(event) => setNewHabitLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleAddHabit();
}
}}
/>
<Button
type="button"
disabled={isSaving || newHabitLabel.trim().length === 0}
onClick={() => void handleAddHabit()}
>
Add
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">How daily tasks are picked</CardTitle>
<CardDescription>
Your tasks combine focus preferences, custom habits, and today&apos;s
quiz.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>Focus topics: {focusTopic ?? "None selected"}</p>
<p>
Daily quiz today:{" "}
{quizCompletedToday ? "Completed" : "Not completed yet"}
</p>
<p>
Suggested habits can appear when your quiz shows low energy, high
stress, or poor sleep.
</p>
</CardContent>
</Card>
</div>
);
}
if (mode === "manage") {
return (
<div className="flex flex-col gap-6">

View File

@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import {
Home,
ListPlus,
Palette,
ShoppingBag,
Sparkles,
UserRound,
@@ -19,10 +20,12 @@ const gridColsClass = {
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
} as const;
const navIcons = {
[routes.avatar]: Home,
[routes.customize]: Palette,
[routes.habits]: ListPlus,
[routes.dailyQuiz]: Sparkles,
[routes.shop]: ShoppingBag,
@@ -37,14 +40,21 @@ export function AppNav({ items = appNavItems }: AppNavProps) {
const pathname = usePathname();
const columnCount = Math.min(
items.length,
5,
6,
) as keyof typeof gridColsClass;
return (
<nav className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<nav
className="fixed inset-x-0 bottom-0 z-50 border-t-2 border-border bg-background/95 shadow-[0_-4px_0_0_hsl(280_45%_28%/0.35)] backdrop-blur supports-[backdrop-filter]:bg-background/90"
style={{ height: "var(--bottom-nav-height)" }}
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-secondary/40"
/>
<div
className={cn(
"mx-auto grid max-w-lg gap-1 px-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2",
"mx-auto grid h-full max-w-6xl gap-1 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2",
gridColsClass[columnCount],
)}
>
@@ -59,18 +69,20 @@ export function AppNav({ items = appNavItems }: AppNavProps) {
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center gap-1 rounded-lg px-1 py-2 text-[9px] font-medium leading-tight transition-colors",
"flex flex-col items-center justify-center gap-1.5 border-2 px-1 py-2 text-[9px] leading-none transition-all duration-75",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
? "border-secondary bg-primary text-primary-foreground shadow-[var(--retro-shadow-sm)] -translate-y-0.5"
: "border-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground hover:-translate-y-0.5",
)}
>
<Icon className="h-4 w-4" />
<span>{item.shortLabel}</span>
<Icon className="h-5 w-5 shrink-0" strokeWidth={2.5} />
<span className="max-w-full truncate uppercase tracking-wide">
{item.shortLabel}
</span>
</Link>
);
})}
</div>
</nav>
);
}
}

View File

@@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
type AppPageHeaderProps = {
title: string;
description?: string;
eyebrow?: string;
wide?: boolean;
};
export function AppPageHeader({
title,
description,
eyebrow,
wide = false,
}: AppPageHeaderProps) {
return (
<header className="border-b-2 border-border bg-card/60 shadow-[var(--retro-shadow-sm)]">
<div className={cn("mx-auto px-5 py-5", wide ? "max-w-6xl" : "max-w-lg")}>
{eyebrow ? (
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{eyebrow}
</p>
) : null}
<h1 className="text-sm">{title}</h1>
{description ? (
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
</div>
</header>
);
}

View File

@@ -1,25 +1,32 @@
import { AppPageHeader } from "@/components/layout/app-page-header";
import { cn } from "@/lib/utils";
type AppShellProps = {
children: React.ReactNode;
title?: string;
description?: string;
wide?: boolean;
};
export function AppShell({ children, title, description }: AppShellProps) {
export function AppShell({
children,
title,
description,
wide = false,
}: AppShellProps) {
return (
<div className="min-h-svh bg-background">
{(title || description) && (
<header className="border-b border-border/60 bg-card/40">
<div className="mx-auto max-w-lg px-5 py-5">
{title ? (
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
) : null}
{description ? (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
) : null}
</div>
</header>
)}
<main className="mx-auto max-w-lg px-5 py-6">{children}</main>
<div className="relative 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%)]">
{title ? (
<AppPageHeader title={title} description={description} wide={wide} />
) : null}
<main
className={cn(
"mx-auto px-5 py-6",
wide ? "max-w-6xl" : "max-w-lg",
)}
>
{children}
</main>
</div>
);
}

View File

@@ -3,15 +3,9 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AvatarCustomizeLayout } from "@/components/avatar/avatar-customize-layout";
import { CharacterCreator } from "@/components/character/character-creator";
import { useToast } from "@/components/ui/toast-provider";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
defaultAvatarCustomization,
saveAvatarCustomization,
@@ -53,42 +47,20 @@ export function OnboardingCustomizeForm() {
};
return (
<div className="min-h-svh bg-background">
<main className="mx-auto flex min-h-svh max-w-6xl flex-col justify-center px-4 py-10">
<div className="mb-8 space-y-2 text-center">
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
Step 2 of 2
</p>
<h1 className="text-3xl font-semibold tracking-tight">
Create your Habit Pet
</h1>
<p className="mx-auto max-w-2xl text-sm text-muted-foreground">
Customize your pet&apos;s look, give them a name, and confirm before
entering the app.
</p>
</div>
<Card className="border-border/60 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Avatar customization</CardTitle>
<CardDescription>
Pick styles and colors for each layer. You can change these later
from the Pet tab.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CharacterCreator
initialCustomization={defaultAvatarCustomization}
name={avatarName}
onNameChange={setAvatarName}
showNameField
saveLabel={isSubmitting ? "Saving..." : "Confirm avatar"}
isSaving={isSubmitting}
onSave={handleSave}
/>
</CardContent>
</Card>
</main>
</div>
<AvatarCustomizeLayout
eyebrow="Step 2 of 2"
title="Create your Habit Pet"
description="Customize your pet's look, give them a name, and confirm before entering the app."
>
<CharacterCreator
initialCustomization={defaultAvatarCustomization}
name={avatarName}
onNameChange={setAvatarName}
showNameField
saveLabel={isSubmitting ? "Saving..." : "Confirm avatar"}
isSaving={isSubmitting}
onSave={handleSave}
/>
</AvatarCustomizeLayout>
);
}

View File

@@ -48,8 +48,7 @@ export function OnboardingQuizForm() {
};
return (
<div className="min-h-svh bg-background">
<main className="mx-auto flex min-h-svh max-w-lg flex-col justify-center px-5 py-10">
<main className="mx-auto max-w-lg px-5 py-10">
<div className="mb-8 text-center">
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
Step 1 of 2
@@ -102,7 +101,6 @@ export function OnboardingQuizForm() {
</Button>
</CardContent>
</Card>
</main>
</div>
</main>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import { cn } from "@/lib/utils";
type AnimatedPetSpriteProps = {
customization: AvatarCustomization;
className?: string;
};
type WanderPosition = {
x: number;
y: number;
facing: "left" | "right";
};
const MIN_X = 24;
const MAX_X = 72;
const MIN_Y = 34;
const MAX_Y = 62;
function randomPosition(current?: WanderPosition): WanderPosition {
let next: WanderPosition = {
x: MIN_X + Math.random() * (MAX_X - MIN_X),
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
facing: Math.random() > 0.5 ? "right" : "left",
};
if (current) {
let attempts = 0;
while (
attempts < 6 &&
Math.hypot(next.x - current.x, next.y - current.y) < 8
) {
next = {
x: MIN_X + Math.random() * (MAX_X - MIN_X),
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
facing: next.x >= current.x ? "right" : "left",
};
attempts += 1;
}
}
return next;
}
export function AnimatedPetSprite({
customization,
className,
}: AnimatedPetSpriteProps) {
const [position, setPosition] = useState<WanderPosition>({
x: 48,
y: 48,
facing: "right",
});
useEffect(() => {
let timeoutId: number | undefined;
const scheduleWander = () => {
const delay = 1800 + Math.random() * 3200;
timeoutId = window.setTimeout(() => {
setPosition((current) => randomPosition(current));
scheduleWander();
}, delay);
};
scheduleWander();
return () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
};
}, []);
return (
<div
className={cn("pointer-events-none absolute z-10", className)}
style={{
left: `${position.x}%`,
top: `${position.y}%`,
transform: "translate(-50%, -50%)",
transition: "left 1.8s steps(10), top 1.8s steps(10)",
}}
>
<div
style={{
transform: position.facing === "left" ? "scaleX(-1)" : undefined,
}}
>
<div className="animate-pet-breathe origin-bottom">
<CharacterLayerPreview
colors={customization.colors}
variants={customization.variants}
equippedItems={customization.equippedItems}
scale={7}
compact
className="!h-auto max-h-none"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
getRoomBackground,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
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);
useEffect(() => {
if (!interactive) {
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]);
return (
<div ref={containerRef} className={cn("absolute inset-0 overflow-hidden", className)}>
{room.layers.map((layer, index) => (
<div
key={layer.id}
aria-hidden
className={cn(
"absolute inset-[-12%]",
layer.className,
index % 2 === 0 ? "animate-parallax-drift" : "animate-star-twinkle",
)}
style={{
transform: `translate3d(${offset.x * layer.speed * 10}px, ${offset.y * layer.speed * 8}px, 0)`,
transition: "transform 0.15s steps(4)",
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatedPetSprite } from "@/components/pet/animated-pet-sprite";
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
import { PixelGauge } from "@/components/pet/pixel-gauge";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import {
defaultAvatarCondition,
type AvatarCondition,
} from "@/lib/avatar-state";
import { getAvatarConditionForToday } from "@/lib/daily-quiz-storage";
import {
normalizeRoomBackgroundId,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
import { cn } from "@/lib/utils";
type PetHabitatProps = {
customization: AvatarCustomization;
className?: string;
};
const MOOD_LABELS = {
happy: "HAPPY",
neutral: "OKAY",
tired: "SLEEPY",
} as const;
export function PetHabitat({ customization, className }: PetHabitatProps) {
const [condition, setCondition] = useState<AvatarCondition>(
defaultAvatarCondition,
);
const [isReady, setIsReady] = useState(false);
const roomId = normalizeRoomBackgroundId(customization.roomBackground);
useEffect(() => {
async function loadStatus() {
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
setIsReady(true);
}
void loadStatus();
}, []);
if (!isReady) {
return (
<div className={cn("tamagotchi-shell p-4", className)}>
<p className="text-center text-[10px] text-muted-foreground">
Waking up pet...
</p>
</div>
);
}
return (
<div className={cn("tamagotchi-shell overflow-hidden p-3 sm:p-4", className)}>
<div className="mb-3 border-b-2 border-border/60 pb-3">
<p className="text-sm text-foreground">{customization.name}</p>
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
{MOOD_LABELS[condition.mood]}
</p>
</div>
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
<AnimatedPetSprite customization={customization} />
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5">
<PixelGauge type="heart" value={condition.health} variant="overlay" />
<PixelGauge type="energy" value={condition.energy} variant="overlay" />
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { Heart, Zap } from "lucide-react";
import { WELLNESS_SCALE_MAX } from "@/lib/avatar-state";
import { cn } from "@/lib/utils";
type PixelGaugeProps = {
type: "heart" | "energy";
value: number;
max?: number;
className?: string;
variant?: "default" | "overlay";
};
export function PixelGauge({
type,
value,
max = WELLNESS_SCALE_MAX,
className,
variant = "default",
}: PixelGaugeProps) {
const filled = Math.max(0, Math.min(max, Math.round(value)));
const Icon = type === "heart" ? Heart : Zap;
const label = type === "heart" ? "HP" : "NRG";
const activeClass =
type === "heart"
? "fill-rose-400 text-rose-400"
: "fill-amber-300 text-amber-300";
const emptyClass = "text-muted-foreground/35";
const isOverlay = variant === "overlay";
return (
<div
className={cn(
"flex items-center",
isOverlay ? "gap-1" : "gap-1.5",
className,
)}
aria-label={`${label}: ${filled} of ${max}`}
>
{!isOverlay ? (
<span className="w-7 shrink-0 text-[9px] uppercase tracking-wider text-muted-foreground">
{label}
</span>
) : null}
<div className={cn("flex items-center", isOverlay ? "gap-1" : "gap-0.5")}>
{Array.from({ length: max }, (_, index) => (
<Icon
key={`${type}-${index}`}
className={cn(
"shrink-0 stroke-[2.5]",
isOverlay ? "size-6" : "size-4",
index < filled ? activeClass : emptyClass,
)}
aria-hidden
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { Lock } from "lucide-react";
import {
ROOM_BACKGROUNDS,
isRoomBackgroundUnlocked,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
import { cn } from "@/lib/utils";
type RoomBackgroundPickerProps = {
value: RoomBackgroundId;
ownedItemIds: string[];
onChange: (roomId: RoomBackgroundId) => void;
compact?: boolean;
};
export function RoomBackgroundPicker({
value,
ownedItemIds,
onChange,
compact = false,
}: RoomBackgroundPickerProps) {
return (
<section className="grid gap-3">
{!compact ? (
<div>
<h3 className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
Room background
</h3>
<p className="mt-1 text-[9px] text-muted-foreground">
Pick the scene behind your pet. Premium rooms unlock in the shop.
</p>
</div>
) : (
<p className="text-[9px] text-muted-foreground">
Premium rooms unlock in the shop.
</p>
)}
<div className="grid grid-cols-2 gap-2">
{ROOM_BACKGROUNDS.map((room) => {
const unlocked = isRoomBackgroundUnlocked(room.id, ownedItemIds);
const selected = value === room.id;
return (
<button
key={room.id}
type="button"
disabled={!unlocked}
aria-pressed={selected}
title={
unlocked
? room.name
: `${room.name} — unlock in the shop`
}
onClick={() => onChange(room.id)}
className={cn(
"relative aspect-[4/3] overflow-hidden border-2 transition-transform",
selected
? "border-secondary shadow-[0_0_0_2px_hsl(var(--secondary)/0.35)]"
: "border-border/70",
unlocked
? "hover:-translate-y-0.5"
: "cursor-not-allowed opacity-80",
)}
>
<div className={cn("absolute inset-0", room.previewClassName)} />
<p className="absolute inset-x-0 bottom-0 bg-black/55 px-1.5 py-1 text-left text-[8px] uppercase tracking-wider text-foreground">
{room.name}
</p>
{!unlocked ? (
<span className="absolute inset-0 flex items-center justify-center bg-background/55">
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
</span>
) : null}
</button>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { Lock } from "lucide-react";
import { PIECE_SLOT_COUNT } from "@/lib/character/presets";
import {
ROOM_BACKGROUNDS,
isRoomBackgroundUnlocked,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
import { cn } from "@/lib/utils";
type RoomStyleSelectorProps = {
activeId: RoomBackgroundId;
ownedItemIds: readonly string[];
onSelect: (roomId: RoomBackgroundId) => void;
};
export function RoomStyleSelector({
activeId,
ownedItemIds,
onSelect,
}: RoomStyleSelectorProps) {
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
ROOM_BACKGROUNDS[index] ?? null,
);
return (
<div className="grid grid-cols-8 gap-2">
{slots.map((room, index) => {
if (!room) {
return (
<div
key={`empty-${index}`}
aria-hidden
className="size-14 rounded-lg border border-dashed border-border/50 bg-muted/15"
/>
);
}
const locked = !isRoomBackgroundUnlocked(room.id, ownedItemIds);
return (
<button
key={room.id}
type="button"
disabled={locked}
aria-disabled={locked}
title={
locked ? `${room.name} — unlock in the shop` : room.name
}
aria-label={
locked ? `${room.name}, locked — unlock in the shop` : room.name
}
aria-pressed={activeId === room.id}
onClick={() => onSelect(room.id)}
className={cn(
"relative size-14 overflow-hidden rounded-lg border transition-colors",
locked
? "cursor-not-allowed border-border/40 opacity-80"
: activeId === room.id
? "border-foreground shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
: "border-border/60 hover:border-border",
)}
>
<div className={cn("absolute inset-0", room.previewClassName)} />
{locked ? (
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center bg-background/75"
>
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
</span>
) : null}
</button>
);
})}
</div>
);
}

View File

@@ -15,6 +15,7 @@ import {
import { useToast } from "@/components/ui/toast-provider";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
import { getRoomBackground } from "@/lib/room-backgrounds";
import {
equipShopItem,
getShopInventory,
@@ -35,11 +36,80 @@ const SHOP_LAYER_ORDER: ShopLayerId[] = [
"eyes",
];
function ShopItemCard({
item,
owned,
equipped,
canAfford,
isPending,
onPurchase,
onEquipToggle,
}: {
item: ShopItemRecord;
owned: boolean;
equipped: boolean;
canAfford: boolean;
isPending: boolean;
onPurchase: () => void;
onEquipToggle: () => void;
}) {
const room = item.type === "room" ? getRoomBackground(item.id) : null;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center border-2 border-border bg-zinc-950/80 p-1.5">
{room ? (
<div className={`size-full ${room.previewClassName}`} />
) : (
<TintedSpriteIcon
src={item.image_path}
color={DEFAULT_GRAY_COLOR.hsl}
size={36}
/>
)}
</div>
<div>
<CardTitle className="text-xs">{item.name}</CardTitle>
<CardDescription className="text-[9px]">{item.id}</CardDescription>
</div>
</div>
<Badge>{item.price} pts</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2">
{!owned ? (
<Button
className="w-full"
variant="outline"
disabled={!canAfford || isPending}
onClick={onPurchase}
>
{isPending ? "Processing..." : canAfford ? "Buy" : "Not enough points"}
</Button>
) : (
<Button
className="w-full"
variant={equipped ? "default" : "outline"}
disabled={isPending || (item.type === "room" && equipped)}
onClick={onEquipToggle}
>
{isPending ? "Updating..." : equipped ? "Equipped" : "Equip"}
</Button>
)}
</CardContent>
</Card>
);
}
export function ShopContent() {
const { toast } = useToast();
const [items, setItems] = useState<ShopItemRecord[]>([]);
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
const [equippedItems, setEquippedItems] = useState<string[]>([]);
const [equippedRoomBackground, setEquippedRoomBackground] = useState("room-day");
const [coins, setCoins] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
@@ -51,6 +121,7 @@ export function ShopContent() {
setItems(inventory.items);
setOwnedItemIds(inventory.ownedItemIds);
setEquippedItems(inventory.equippedItems);
setEquippedRoomBackground(inventory.equippedRoomBackground);
setCoins(inventory.coins);
} catch (refreshError) {
setError(
@@ -94,12 +165,15 @@ export function ShopContent() {
setPendingItemId(item.id);
try {
const isEquipped = equippedItems.includes(item.id);
const isEquipped =
item.type === "room"
? equippedRoomBackground === item.id
: equippedItems.includes(item.id);
if (isEquipped) {
if (item.type !== "room" && isEquipped) {
await unequipShopItem(item.id);
toast(`Unequipped ${item.name}.`, "default");
} else {
} else if (!isEquipped) {
await equipShopItem(item.id);
toast(`Equipped ${item.name}.`, "success");
}
@@ -115,24 +189,28 @@ export function ShopContent() {
}
};
const groupedItems = SHOP_LAYER_ORDER.map((layerId) => ({
const styleGroups = SHOP_LAYER_ORDER.map((layerId) => ({
layerId,
label: getLayerLabel(layerId),
items: items.filter((item) => item.type === layerId),
})).filter((group) => group.items.length > 0);
const roomItems = items.filter((item) => item.type === "room");
return (
<>
<div className="mb-4 flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-3">
<span className="text-sm text-muted-foreground">Your balance</span>
<div className="mb-4 flex items-center justify-between border-2 border-border bg-muted/40 px-4 py-3 shadow-[var(--retro-shadow-sm)]">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
Your balance
</span>
<Badge variant="secondary">
{coins === null ? "..." : `${coins} points`}
</Badge>
</div>
{error ? <p className="mb-4 text-sm text-red-500">{error}</p> : null}
{error ? <p className="mb-4 text-xs text-red-500">{error}</p> : null}
{!error && coins !== null && groupedItems.length === 0 ? (
{!error && coins !== null && items.length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">
@@ -144,70 +222,46 @@ export function ShopContent() {
) : null}
<div className="grid gap-6">
{groupedItems.map((group) => (
{roomItems.length > 0 ? (
<section className="grid gap-3">
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Room backgrounds
</h3>
<div className="grid gap-4 sm:grid-cols-2">
{roomItems.map((item) => (
<ShopItemCard
key={item.id}
item={item}
owned={ownedItemIds.includes(item.id)}
equipped={equippedRoomBackground === item.id}
canAfford={coins !== null && coins >= item.price}
isPending={pendingItemId === item.id}
onPurchase={() => void handlePurchase(item.id)}
onEquipToggle={() => void handleEquipToggle(item)}
/>
))}
</div>
</section>
) : null}
{styleGroups.map((group) => (
<section key={group.layerId} className="grid gap-3">
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{group.label}
</h3>
<div className="grid gap-4 sm:grid-cols-2">
{group.items.map((item) => {
const owned = ownedItemIds.includes(item.id);
const equipped = equippedItems.includes(item.id);
const canAfford = coins !== null && coins >= item.price;
const isPending = pendingItemId === item.id;
return (
<Card key={item.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-lg border bg-zinc-950/80 p-1.5">
<TintedSpriteIcon
src={item.image_path}
color={DEFAULT_GRAY_COLOR.hsl}
size={36}
/>
</div>
<div>
<CardTitle className="text-base">{item.name}</CardTitle>
<CardDescription>{item.id}</CardDescription>
</div>
</div>
<Badge>{item.price} pts</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2">
{!owned ? (
<Button
className="w-full"
variant="outline"
disabled={coins === null || !canAfford || isPending}
onClick={() => void handlePurchase(item.id)}
>
{isPending
? "Processing..."
: canAfford
? "Buy"
: "Not enough points"}
</Button>
) : (
<Button
className="w-full"
variant={equipped ? "default" : "outline"}
disabled={isPending}
onClick={() => void handleEquipToggle(item)}
>
{isPending
? "Updating..."
: equipped
? "Equipped"
: "Equip"}
</Button>
)}
</CardContent>
</Card>
);
})}
{group.items.map((item) => (
<ShopItemCard
key={item.id}
item={item}
owned={ownedItemIds.includes(item.id)}
equipped={equippedItems.includes(item.id)}
canAfford={coins !== null && coins >= item.price}
isPending={pendingItemId === item.id}
onPurchase={() => void handlePurchase(item.id)}
onEquipToggle={() => void handleEquipToggle(item)}
/>
))}
</div>
</section>
))}

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-none border-2 border-border px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-[var(--retro-shadow-sm)]",
{
variants: {
variant: {

View File

@@ -5,26 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap border-2 border-border text-xs font-normal transition-[transform,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-[var(--retro-shadow-sm)] hover:-translate-x-px hover:-translate-y-px hover:shadow-[3px_3px_0_0_hsl(280_45%_28%)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive text-destructive-foreground shadow-[var(--retro-shadow-sm)]",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"border-border bg-background shadow-[var(--retro-shadow-sm)] hover:bg-muted hover:text-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground shadow-[var(--retro-shadow-sm)]",
ghost: "border-transparent shadow-none hover:bg-muted hover:text-foreground",
link: "border-transparent text-secondary underline-offset-4 shadow-none hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
default: "h-10 px-4 py-2",
sm: "h-8 px-3 text-[10px]",
lg: "h-11 px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
"border-2 border-border bg-card text-card-foreground shadow-[var(--retro-shadow)]",
className,
)}
{...props}

View File

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-none border-2 border-input bg-muted/30 px-3 py-1 text-xs shadow-[var(--retro-shadow-sm)] transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@@ -45,13 +45,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children}
<div
aria-live="polite"
className="pointer-events-none fixed bottom-24 right-4 z-50 flex w-[min(100vw-2rem,20rem)] flex-col gap-2"
className="pointer-events-none fixed right-4 z-50 flex w-[min(100vw-2rem,20rem)] flex-col gap-2"
style={{ bottom: "calc(var(--bottom-nav-height) + 0.75rem)" }}
>
{messages.map((message) => (
<div
key={message.id}
className={cn(
"rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur-sm",
"rounded-lg border px-4 py-3 text-xs shadow-lg backdrop-blur-sm",
message.variant === "success" &&
"border-emerald-500/30 bg-emerald-500/15 text-emerald-100",
message.variant === "error" &&