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}
/>
);
}