Merge character customization branch and resolve avatar page conflict

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masongga
2026-05-24 00:17:14 -06:00
60 changed files with 3023 additions and 405 deletions

View File

@@ -0,0 +1,191 @@
"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 { 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);
} 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 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>;
}
if (!customization) {
return (
<p className="text-sm 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>
);
}

View File

@@ -3,13 +3,11 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { PixelAvatar } from "@/components/avatar/pixel-avatar";
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import {
defaultAvatarCondition,
WELLNESS_SCALE_MAX,
@@ -23,7 +21,11 @@ import {
import { getCompletedHabitLabelsForToday } from "@/lib/habits-storage";
import { routes } from "@/lib/routes";
export function AvatarStatusCard() {
type AvatarStatusCardProps = {
customization: AvatarCustomization;
};
export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
const [condition, setCondition] = useState<AvatarCondition>(
defaultAvatarCondition,
);
@@ -69,7 +71,21 @@ export function AvatarStatusCard() {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
<PixelAvatar mood={condition.mood} />
<div className="text-center">
<p className="text-lg font-semibold tracking-tight">
{customization.name}
</p>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{condition.mood}
</p>
</div>
<CharacterLayerPreview
colors={customization.colors}
variants={customization.variants}
equippedItems={customization.equippedItems}
scale={8}
className="w-full max-w-xs"
/>
<div className="flex flex-wrap justify-center gap-2">
<Badge variant="secondary">
Energy {condition.energy}/{WELLNESS_SCALE_MAX}

View File

@@ -0,0 +1,38 @@
"use client";
import { cn } from "@/lib/utils";
import type { ColorPreset } from "@/lib/character/presets";
type CharacterColorPresetsProps = {
presets: ColorPreset[];
activeId?: string;
onSelect: (preset: ColorPreset) => void;
};
export function CharacterColorPresets({
presets,
activeId,
onSelect,
}: CharacterColorPresetsProps) {
return (
<div className="grid grid-cols-9 gap-2">
{presets.map((preset) => (
<button
key={preset.id}
type="button"
title={preset.name}
aria-label={preset.name}
aria-pressed={activeId === preset.id}
onClick={() => onSelect(preset)}
className={cn(
"aspect-square size-full max-h-9 max-w-9 rounded-md border transition-colors",
activeId === preset.id
? "border-foreground ring-1 ring-foreground/40 ring-offset-1 ring-offset-background"
: "border-border/60 hover:border-border",
)}
style={{ backgroundColor: preset.hex }}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Label } from "@/components/ui/label";
import {
LIGHTNESS_MIN,
clampHsl,
displayToLightness,
lightnessToDisplay,
type HSL,
} from "@/lib/character/color-utils";
import { cn } from "@/lib/utils";
type ColorSliderProps = {
label: string;
value: number;
displayValue?: number;
min: number;
max: number;
gradient?: string;
onChange: (value: number) => void;
};
export function ColorSlider({
label,
value,
displayValue,
min,
max,
gradient,
onChange,
}: ColorSliderProps) {
const shownValue = displayValue ?? value;
return (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>{label}</Label>
<span className="text-xs tabular-nums text-muted-foreground">
{shownValue}
</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className={cn(
"h-2.5 w-full cursor-pointer appearance-none rounded-full border border-border/40",
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:shadow-sm",
"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-foreground [&::-moz-range-thumb]:shadow-sm",
)}
style={
gradient
? {
background: gradient,
}
: undefined
}
/>
</div>
);
}
type CharacterColorSlidersProps = {
color: HSL;
onChange: (color: HSL) => void;
};
export function CharacterColorSliders({
color,
onChange,
}: CharacterColorSlidersProps) {
const hueGradient =
"linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))";
const saturationGradient = `linear-gradient(to right, hsl(${color.h}, 0%, ${color.l}%), hsl(${color.h}, 100%, ${color.l}%))`;
const lightnessGradient = `linear-gradient(to right, hsl(${color.h}, ${color.s}%, ${LIGHTNESS_MIN}%), hsl(${color.h}, ${color.s}%, 50%), hsl(${color.h}, ${color.s}%, 100%))`;
const brightnessDisplay = lightnessToDisplay(color.l);
return (
<div className="grid gap-5">
<ColorSlider
label="Hue"
value={color.h}
min={0}
max={360}
gradient={hueGradient}
onChange={(h) => onChange({ ...color, h })}
/>
<ColorSlider
label="Saturation"
value={color.s}
min={0}
max={100}
gradient={saturationGradient}
onChange={(s) => onChange({ ...color, s })}
/>
<ColorSlider
label="Brightness"
value={brightnessDisplay}
displayValue={brightnessDisplay}
min={0}
max={100}
gradient={lightnessGradient}
onChange={(display) =>
onChange(clampHsl({ ...color, l: displayToLightness(display) }))
}
/>
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { CharacterColorPresets } from "@/components/character/character-color-presets";
import { CharacterColorSliders } from "@/components/character/character-color-sliders";
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 { 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 ColorPreset,
} from "@/lib/character/presets";
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
import { isVariantUnlocked } from "@/lib/character/variant-access";
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{children}
</h3>
);
}
type CharacterCreatorProps = {
initialCustomization?: AvatarCustomization;
name?: string;
onNameChange?: (name: string) => void;
showNameField?: boolean;
saveLabel?: string;
isSaving?: boolean;
ownedVariantIds?: string[];
onSave?: (customization: AvatarCustomization) => Promise<void>;
};
export function CharacterCreator({
initialCustomization,
name: controlledName,
onNameChange,
ownedVariantIds = [],
showNameField = false,
saveLabel = "Save avatar",
isSaving = false,
onSave,
}: CharacterCreatorProps) {
const [internalName, setInternalName] = useState(
initialCustomization?.name ?? "Pixel Me",
);
const name = controlledName ?? internalName;
const setName = (nextName: string) => {
if (onNameChange) {
onNameChange(nextName);
return;
}
setInternalName(nextName);
};
const [colors, setColors] = useState<LayerColorState>(
initialCustomization?.colors ?? { ...LAYER_DEFAULT_COLORS },
);
const [variants, setVariants] = useState<LayerVariantState>(
initialCustomization?.variants ?? buildDefaultVariants(),
);
const [activePresetId, setActivePresetId] = useState<string>(
DEFAULT_GRAY_COLOR.id,
);
const [activeLayerId, setActiveLayerId] = useState<
(typeof CHARACTER_LAYERS)[number]["id"]
>("skin");
const [saveError, setSaveError] = useState<string | null>(null);
const activeLayer = getLayerById(activeLayerId);
const activeColor = colors[activeLayerId];
const ownedSet = new Set(ownedVariantIds);
const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : [];
const lockedVariantIds = new Set(
pieceVariants
.filter(
(variant) =>
variant.id !== NONE_VARIANT_ID &&
!isVariantUnlocked(variant.id, ownedSet),
)
.map((variant) => variant.id),
);
const applyPreset = (preset: ColorPreset) => {
setActivePresetId(preset.id);
setColors((current) => ({
...current,
[activeLayerId]: { ...preset.hsl },
}));
};
const updateActiveColor = (nextColor: typeof activeColor) => {
setActivePresetId("");
setColors((current) => ({
...current,
[activeLayerId]: clampHsl(nextColor),
}));
};
const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => {
setActiveLayerId(layerId);
const layerColor = colors[layerId];
const matchingPreset = COLOR_PRESETS.find(
(preset) =>
preset.hsl.h === layerColor.h &&
preset.hsl.s === layerColor.s &&
preset.hsl.l === layerColor.l,
);
setActivePresetId(matchingPreset?.id ?? "");
};
const handleVariantChange = (variantId: string) => {
if (!isVariantUnlocked(variantId, ownedSet)) {
return;
}
setVariants((current) => ({
...current,
[activeLayerId]: variantId,
}));
};
const buildCustomization = (): AvatarCustomization => ({
name,
colors,
variants,
customized: true,
equippedItems: initialCustomization?.equippedItems ?? [],
});
const handleSave = async () => {
if (!onSave) {
return;
}
setSaveError(null);
try {
await onSave(buildCustomization());
} catch (error) {
setSaveError(
error instanceof Error ? error.message : "Could not save avatar.",
);
}
};
return (
<div className="grid gap-5">
{showNameField ? (
<div className="grid max-w-md gap-2">
<Label htmlFor="avatar-name">Pet name</Label>
<Input
id="avatar-name"
value={name}
maxLength={32}
placeholder="Name your pet"
onChange={(event) => setName(event.target.value)}
/>
</div>
) : null}
<CharacterLayerTabs
activeLayerId={activeLayerId}
onLayerChange={handleLayerChange}
/>
<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>
<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">
<SectionLabel>Style</SectionLabel>
<div className="min-h-14">
<CharacterPieceSelector
variants={pieceVariants}
lockedVariantIds={lockedVariantIds}
activeId={variants[activeLayerId]}
color={activeColor}
skinColor={colors.skin}
onSelect={handleVariantChange}
/>
</div>
</section>
<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>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { applyEquippedItemsToVariants } from "@/lib/avatar-display";
import {
drawTintedSprite,
getCachedImage,
hslToRgb,
} from "@/lib/character/color-utils";
import {
CHARACTER_LAYERS,
getSelectedVariant,
} from "@/lib/character/presets";
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
import { cn } from "@/lib/utils";
const EMPTY_EQUIPPED_ITEMS: string[] = [];
type CharacterLayerPreviewProps = {
colors: LayerColorState;
variants: LayerVariantState;
equippedItems?: string[];
scale?: number;
className?: string;
compact?: boolean;
};
export function CharacterLayerPreview({
colors,
variants,
equippedItems,
scale = 10,
className,
compact = false,
}: CharacterLayerPreviewProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
const resolvedEquippedItems = equippedItems ?? EMPTY_EQUIPPED_ITEMS;
const equippedKey = resolvedEquippedItems.join("\0");
const equippedItemsRef = useRef(resolvedEquippedItems);
equippedItemsRef.current = resolvedEquippedItems;
useEffect(() => {
let cancelled = false;
const items = equippedItemsRef.current;
async function render() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
try {
const displayVariants = applyEquippedItemsToVariants(
variants,
items,
);
const activeLayers = CHARACTER_LAYERS.flatMap((layer) => {
const selected = getSelectedVariant(layer.id, displayVariants[layer.id]);
if (!selected) return [];
return [{ layer, variant: selected }];
});
const images = await Promise.all(
activeLayers.map(async ({ layer, variant }) => ({
layer,
image: await getCachedImage(variant.src),
})),
);
if (cancelled || images.length === 0) return;
const width = images[0]?.image.width ?? 0;
const height = images[0]?.image.height ?? 0;
canvas.width = width;
canvas.height = height;
const nextWidth = width * scale;
const nextHeight = height * scale;
setDisplaySize((current) =>
current.width === nextWidth && current.height === nextHeight
? current
: { width: nextWidth, height: nextHeight },
);
ctx.clearRect(0, 0, width, height);
for (const { layer, image } of images) {
const layerColor = colors[layer.id];
const rgb = hslToRgb(layerColor.h, layerColor.s, layerColor.l);
drawTintedSprite(
ctx,
image,
rgb,
layer.id === "eyes"
? {
mode: "eyes",
skinColor: hslToRgb(
colors.skin.h,
colors.skin.s,
colors.skin.l,
),
}
: undefined,
);
}
} catch {
// Ignore missing or broken sprite assets so the UI stays responsive.
}
}
void render();
return () => {
cancelled = true;
};
}, [colors, variants, equippedKey, scale]);
return (
<div
className={cn(
"relative flex items-center justify-center",
compact ? "h-[180px]" : "h-[320px]",
className,
)}
>
<canvas
ref={canvasRef}
className="block"
style={{
imageRendering: "pixelated",
width: displaySize.width || undefined,
height: displaySize.height || undefined,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { CHARACTER_LAYERS, type CharacterLayerId } from "@/lib/character/presets";
import { cn } from "@/lib/utils";
type CharacterLayerTabsProps = {
activeLayerId: CharacterLayerId;
onLayerChange: (layerId: CharacterLayerId) => void;
};
export function CharacterLayerTabs({
activeLayerId,
onLayerChange,
}: 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"
>
{CHARACTER_LAYERS.map((layer) => (
<button
key={layer.id}
type="button"
role="tab"
aria-selected={activeLayerId === layer.id}
onClick={() => onLayerChange(layer.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",
)}
>
{layer.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { CircleSlash, Lock } from "lucide-react";
import type { HSL } from "@/lib/character/color-utils";
import { NONE_VARIANT_ID, PIECE_SLOT_COUNT } from "@/lib/character/presets";
import { cn } from "@/lib/utils";
import type { LayerVariant } from "@/lib/character/presets";
import { TintedSpriteIcon } from "./tinted-sprite-icon";
type CharacterPieceSelectorProps = {
variants: LayerVariant[];
lockedVariantIds?: ReadonlySet<string>;
activeId: string;
color: HSL;
skinColor?: HSL;
onSelect: (variantId: string) => void;
};
export function CharacterPieceSelector({
variants,
lockedVariantIds,
activeId,
color,
skinColor,
onSelect,
}: CharacterPieceSelectorProps) {
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
variants[index] ?? null,
);
return (
<div className="grid grid-cols-8 gap-2">
{slots.map((variant, index) => {
if (!variant) {
return (
<div
key={`empty-${index}`}
aria-hidden
className="size-14 rounded-lg border border-dashed border-border/50 bg-muted/15"
/>
);
}
if (variant.id === NONE_VARIANT_ID) {
return (
<button
key={variant.id}
type="button"
title={variant.label}
aria-label={variant.label}
aria-pressed={activeId === variant.id}
onClick={() => onSelect(variant.id)}
className={cn(
"flex size-14 items-center justify-center rounded-lg border bg-muted/20 transition-colors",
activeId === variant.id
? "border-foreground bg-muted/35 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
: "border-border/60 hover:border-border hover:bg-muted/30",
)}
>
<CircleSlash
className="size-6 text-muted-foreground"
strokeWidth={1.75}
aria-hidden
/>
</button>
);
}
const locked = lockedVariantIds?.has(variant.id) ?? false;
return (
<button
key={variant.id}
type="button"
disabled={locked}
aria-disabled={locked}
title={
locked ? `${variant.label} — unlock in the shop` : variant.label
}
aria-label={
locked ? `${variant.label}, locked — unlock in the shop` : variant.label
}
aria-pressed={activeId === variant.id}
onClick={() => onSelect(variant.id)}
className={cn(
"relative flex size-14 items-center justify-center rounded-lg border bg-zinc-950/80 p-1.5 transition-colors",
locked
? "cursor-not-allowed border-border/40 opacity-80"
: activeId === variant.id
? "border-foreground bg-zinc-900 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
: "border-border/60 hover:border-border hover:bg-zinc-900/60",
)}
>
<TintedSpriteIcon
src={variant.src}
color={color}
skinColor={skinColor}
size={36}
/>
{locked ? (
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/75"
>
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
</span>
) : null}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef } from "react";
import {
drawTintedSprite,
getCachedImage,
hslToRgb,
type HSL,
} from "@/lib/character/color-utils";
import { cn } from "@/lib/utils";
type TintedSpriteIconProps = {
src: string;
color: HSL;
skinColor?: HSL;
size?: number;
className?: string;
};
export function TintedSpriteIcon({
src,
color,
skinColor,
size = 40,
className,
}: TintedSpriteIconProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let cancelled = false;
async function render() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
try {
const image = await getCachedImage(src);
if (cancelled) return;
canvas.width = image.width;
canvas.height = image.height;
ctx.clearRect(0, 0, image.width, image.height);
const rgb = hslToRgb(color.h, color.s, color.l);
const isEyesLayer = src.includes("/character/eyes/");
drawTintedSprite(
ctx,
image,
rgb,
isEyesLayer && skinColor
? {
mode: "eyes",
skinColor: hslToRgb(skinColor.h, skinColor.s, skinColor.l),
}
: undefined,
);
} catch {
// Ignore missing or broken sprite assets so the UI stays responsive.
}
}
void render();
return () => {
cancelled = true;
};
}, [src, color.h, color.s, color.l, skinColor?.h, skinColor?.s, skinColor?.l]);
return (
<canvas
ref={canvasRef}
aria-hidden
className={cn("block max-h-full max-w-full", className)}
style={{
imageRendering: "pixelated",
width: size,
height: size,
}}
/>
);
}

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import { AppNav } from "@/components/layout/app-nav";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getOnboardingStatusClient } from "@/lib/onboarding-status";
import { createClient } from "@/lib/supabase/client";
export function GlobalBottomNav() {
@@ -16,9 +18,13 @@ export function GlobalBottomNav() {
data: { user },
} = await supabase.auth.getUser();
setShowNav(
!!user && user.user_metadata?.onboarding_completed === true,
);
if (!user) {
setShowNav(false);
return;
}
const status = await getOnboardingStatusClient();
setShowNav(status?.appComplete === true);
}
void syncNavState();
@@ -29,7 +35,12 @@ export function GlobalBottomNav() {
void syncNavState();
});
return () => subscription.unsubscribe();
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncNavState);
return () => {
subscription.unsubscribe();
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, syncNavState);
};
}, []);
if (!showNav) {

View File

@@ -1,18 +1,12 @@
import { redirect } from "next/navigation";
import {
getSessionUser,
hasCompletedOnboarding,
} from "@/lib/auth-helpers";
import { routes } from "@/lib/routes";
import { getOnboardingRedirectPath, getSessionUser } from "@/lib/auth-helpers";
export async function LoggedInRedirect() {
const user = await getSessionUser();
if (user) {
redirect(
hasCompletedOnboarding(user) ? routes.avatar : routes.onboardingQuiz,
);
redirect(await getOnboardingRedirectPath());
}
return null;

View File

@@ -1,10 +1,9 @@
import { redirect } from "next/navigation";
import {
hasCompletedOnboarding,
requireSessionUser,
} from "@/lib/auth-helpers";
import { requireSessionUser } from "@/lib/auth-helpers";
import { fetchOnboardingStatusForUser } from "@/lib/onboarding-status";
import { routes } from "@/lib/routes";
import { createClient } from "@/lib/supabase/server";
type OnboardingAuthGateProps = {
children: React.ReactNode;
@@ -12,8 +11,10 @@ type OnboardingAuthGateProps = {
export async function OnboardingAuthGate({ children }: OnboardingAuthGateProps) {
const user = await requireSessionUser();
const supabase = await createClient();
const status = await fetchOnboardingStatusForUser(user.id, supabase);
if (hasCompletedOnboarding(user)) {
if (status.appComplete) {
redirect(routes.avatar);
}

View File

@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { requireSessionUser } from "@/lib/auth-helpers";
import {
fetchOnboardingStatusForUser,
} from "@/lib/onboarding-status";
import { routes } from "@/lib/routes";
import { createClient } from "@/lib/supabase/server";
type OnboardingCustomizeAuthGateProps = {
children: React.ReactNode;
};
export async function OnboardingCustomizeAuthGate({
children,
}: OnboardingCustomizeAuthGateProps) {
const user = await requireSessionUser();
const supabase = await createClient();
const status = await fetchOnboardingStatusForUser(user.id, supabase);
if (status.appComplete) {
redirect(routes.avatar);
}
if (!status.quizComplete) {
redirect(routes.onboardingQuiz);
}
return children;
}

View File

@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { requireSessionUser } from "@/lib/auth-helpers";
import {
fetchOnboardingStatusForUser,
getRedirectPathFromStatus,
} from "@/lib/onboarding-status";
import { createClient } from "@/lib/supabase/server";
type OnboardingQuizAuthGateProps = {
children: React.ReactNode;
};
export async function OnboardingQuizAuthGate({
children,
}: OnboardingQuizAuthGateProps) {
const user = await requireSessionUser();
const supabase = await createClient();
const status = await fetchOnboardingStatusForUser(user.id, supabase);
if (status.quizComplete) {
redirect(getRedirectPathFromStatus(status));
}
return children;
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
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,
type AvatarCustomization,
} from "@/lib/avatar-customization-storage";
import { routes } from "@/lib/routes";
export function OnboardingCustomizeForm() {
const router = useRouter();
const { toast } = useToast();
const [avatarName, setAvatarName] = useState(defaultAvatarCustomization.name);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSave = async (customization: AvatarCustomization) => {
setIsSubmitting(true);
try {
await saveAvatarCustomization(
{
...customization,
name: avatarName.trim() || customization.name,
},
{ completeOnboarding: true },
);
toast("Avatar confirmed. Welcome to Habit Pet!", "success");
router.push(routes.avatar);
router.refresh();
} catch (saveError) {
toast(
saveError instanceof Error
? saveError.message
: "Could not save your avatar.",
"error",
);
throw saveError;
} finally {
setIsSubmitting(false);
}
};
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>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
focusTopicOptions,
saveOnboardingPreferences,
} from "@/lib/profile-preferences-storage";
import { routes } from "@/lib/routes";
export function OnboardingQuizForm() {
const router = useRouter();
const [focusTopic, setFocusTopic] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleContinue = async () => {
if (!focusTopic) {
return;
}
setIsSubmitting(true);
setError(null);
try {
await saveOnboardingPreferences(focusTopic);
router.push(routes.onboardingCustomize);
router.refresh();
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: "Could not save onboarding answers.",
);
} finally {
setIsSubmitting(false);
}
};
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">
<div className="mb-8 text-center">
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
Step 1 of 2
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">
Welcome to Habit Pet
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Choose a focus area, then design your pet in the next step.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
What habit do you most want to build first?
</CardTitle>
<CardDescription>
This helps personalize your daily tasks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{focusTopicOptions.map((option) => {
const selected = focusTopic === option;
return (
<button
key={option}
type="button"
onClick={() => setFocusTopic(option)}
className={`w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors ${
selected
? "border-primary bg-primary/10"
: "hover:bg-muted"
}`}
>
<Label className="cursor-pointer">{option}</Label>
</button>
);
})}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Button
className="w-full"
disabled={!focusTopic || isSubmitting}
onClick={() => void handleContinue()}
>
{isSubmitting ? "Saving..." : "Continue to avatar"}
</Button>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { TintedSpriteIcon } from "@/components/character/tinted-sprite-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -11,33 +12,46 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
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 {
getShopState,
equipShopItem,
getShopInventory,
purchaseShopItem,
type ShopItem,
unequipShopItem,
} from "@/lib/shop-storage";
import {
getLayerLabel,
type ShopItemRecord,
type ShopLayerId,
} from "@/lib/shop-catalog";
function formatItemType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
const SHOP_LAYER_ORDER: ShopLayerId[] = [
"head",
"torso",
"pants",
"shoes",
"eyes",
];
export function ShopContent() {
const [coins, setCoins] = useState<number | null>(null);
const [items, setItems] = useState<ShopItem[]>([]);
const { toast } = useToast();
const [items, setItems] = useState<ShopItemRecord[]>([]);
const [ownedItemIds, setOwnedItemIds] = useState<string[]>([]);
const [equippedItems, setEquippedItems] = useState<string[]>([]);
const [coins, setCoins] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [purchaseError, setPurchaseError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
const refreshShop = useCallback(async () => {
try {
setError(null);
const state = await getShopState();
setCoins(state.coins);
setItems(state.items);
setOwnedItemIds(state.ownedItemIds);
const inventory = await getShopInventory();
setItems(inventory.items);
setOwnedItemIds(inventory.ownedItemIds);
setEquippedItems(inventory.equippedItems);
setCoins(inventory.coins);
} catch (refreshError) {
setError(
refreshError instanceof Error
@@ -45,10 +59,6 @@ export function ShopContent() {
: "Could not load the shop.",
);
setCoins(0);
setItems([]);
setOwnedItemIds([]);
} finally {
setIsLoading(false);
}
}, []);
@@ -61,30 +71,56 @@ export function ShopContent() {
};
}, [refreshShop]);
const handlePurchase = async (item: ShopItem) => {
if (pendingItemId) {
return;
}
const handlePurchase = async (itemId: string) => {
setPendingItemId(itemId);
try {
setPurchaseError(null);
setPendingItemId(item.id);
const remainingCoins = await purchaseShopItem(item.id);
setCoins(remainingCoins);
setOwnedItemIds((current) =>
current.includes(item.id) ? current : [...current, item.id],
);
} catch (purchaseFailure) {
setPurchaseError(
purchaseFailure instanceof Error
? purchaseFailure.message
: "Could not complete purchase.",
const item = await purchaseShopItem(itemId);
toast(`Purchased ${item.name}.`, "success");
await refreshShop();
} catch (purchaseError) {
toast(
purchaseError instanceof Error
? purchaseError.message
: "Purchase failed.",
"error",
);
} finally {
setPendingItemId(null);
}
};
const handleEquipToggle = async (item: ShopItemRecord) => {
setPendingItemId(item.id);
try {
const isEquipped = equippedItems.includes(item.id);
if (isEquipped) {
await unequipShopItem(item.id);
toast(`Unequipped ${item.name}.`, "default");
} else {
await equipShopItem(item.id);
toast(`Equipped ${item.name}.`, "success");
}
await refreshShop();
} catch (equipError) {
toast(
equipError instanceof Error ? equipError.message : "Could not equip item.",
"error",
);
} finally {
setPendingItemId(null);
}
};
const groupedItems = SHOP_LAYER_ORDER.map((layerId) => ({
layerId,
label: getLayerLabel(layerId),
items: items.filter((item) => item.type === layerId),
})).filter((group) => group.items.length > 0);
return (
<>
<div className="mb-4 flex items-center justify-between rounded-lg border bg-muted/40 px-4 py-3">
@@ -95,63 +131,76 @@ export function ShopContent() {
</div>
{error ? <p className="mb-4 text-sm text-red-500">{error}</p> : null}
{purchaseError ? (
<p className="mb-4 text-sm text-red-500">{purchaseError}</p>
) : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading shop items...</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">
No items in the shop yet. Add rows to the shop_items table in
Supabase.
</p>
) : (
<div className="grid gap-4">
{items.map((item) => {
const isOwned = ownedItemIds.includes(item.id);
const canAfford = coins !== null && coins >= item.price;
const isPending = pendingItemId === item.id;
<div className="grid gap-6">
{groupedItems.map((group) => (
<section key={group.layerId} className="grid gap-3">
<h3 className="text-sm 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>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{item.name}</CardTitle>
<CardDescription>
{formatItemType(item.type)}
</CardDescription>
</div>
<Badge>{item.price} pts</Badge>
</div>
</CardHeader>
<CardContent>
<Button
className="w-full"
variant={isOwned ? "secondary" : "outline"}
disabled={
isOwned ||
coins === null ||
isPending ||
(!isOwned && !canAfford)
}
onClick={() => void handlePurchase(item)}
>
{isOwned
? "Owned"
: isPending
? "Purchasing..."
: canAfford
? "Buy"
: "Not enough points"}
</Button>
</CardContent>
</Card>
);
})}
</div>
)}
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>
);
})}
</div>
</section>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
type ToastVariant = "default" | "success" | "error";
type ToastMessage = {
id: number;
title: string;
variant: ToastVariant;
};
type ToastContextValue = {
toast: (title: string, variant?: ToastVariant) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<ToastMessage[]>([]);
const toast = useCallback((title: string, variant: ToastVariant = "default") => {
const id = Date.now() + Math.random();
setMessages((current) => [...current, { id, title, variant }]);
window.setTimeout(() => {
setMessages((current) => current.filter((message) => message.id !== id));
}, 3200);
}, []);
const value = useMemo(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={value}>
{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"
>
{messages.map((message) => (
<div
key={message.id}
className={cn(
"rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur-sm",
message.variant === "success" &&
"border-emerald-500/30 bg-emerald-500/15 text-emerald-100",
message.variant === "error" &&
"border-red-500/30 bg-red-500/15 text-red-100",
message.variant === "default" &&
"border-border/60 bg-background/95 text-foreground",
)}
>
{message.title}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within ToastProvider.");
}
return context;
}