Merge character customization branch and resolve avatar page conflict
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
191
components/avatar/avatar-page-content.tsx
Normal file
191
components/avatar/avatar-page-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
38
components/character/character-color-presets.tsx
Normal file
38
components/character/character-color-presets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
components/character/character-color-sliders.tsx
Normal file
114
components/character/character-color-sliders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
components/character/character-creator.tsx
Normal file
249
components/character/character-creator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
components/character/character-layer-preview.tsx
Normal file
143
components/character/character-layer-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
components/character/character-layer-tabs.tsx
Normal file
40
components/character/character-layer-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
components/character/character-piece-selector.tsx
Normal file
115
components/character/character-piece-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
components/character/tinted-sprite-icon.tsx
Normal file
86
components/character/tinted-sprite-icon.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
30
components/layout/onboarding-customize-auth-gate.tsx
Normal file
30
components/layout/onboarding-customize-auth-gate.tsx
Normal 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;
|
||||
}
|
||||
26
components/layout/onboarding-quiz-auth-gate.tsx
Normal file
26
components/layout/onboarding-quiz-auth-gate.tsx
Normal 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;
|
||||
}
|
||||
94
components/onboarding/onboarding-customize-form.tsx
Normal file
94
components/onboarding/onboarding-customize-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
108
components/onboarding/onboarding-quiz-form.tsx
Normal file
108
components/onboarding/onboarding-quiz-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
79
components/ui/toast-provider.tsx
Normal file
79
components/ui/toast-provider.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user