avatar to main merge

I have completed the character creation screen on the avatar branch and am preparing to merge it into main.
This commit is contained in:
pricewowen
2026-05-23 23:24:36 -06:00
parent 8957ecb81d
commit 61f98866e8
39 changed files with 2361 additions and 436 deletions

View File

@@ -1,12 +1,11 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import {
fetchOnboardingStatusForUser,
getRedirectPathFromStatus,
} from "@/lib/onboarding-status";
import { routes } from "@/lib/routes";
import type { User } from "@supabase/supabase-js";
export function hasCompletedOnboarding(user: User) {
return user.user_metadata?.onboarding_completed === true;
}
import { createClient } from "@/lib/supabase/server";
export async function getSessionUser() {
const supabase = await createClient();
@@ -32,10 +31,39 @@ export async function requireSessionUser() {
return user;
}
export async function getOnboardingStatusForCurrentUser() {
const user = await requireSessionUser();
const supabase = await createClient();
return fetchOnboardingStatusForUser(user.id, supabase);
}
export async function getOnboardingRedirectPath() {
const status = await getOnboardingStatusForCurrentUser();
return getRedirectPathFromStatus(status);
}
export async function requireOnboardingComplete() {
const user = await requireSessionUser();
const supabase = await createClient();
const status = await fetchOnboardingStatusForUser(user.id, supabase);
if (!hasCompletedOnboarding(user)) {
if (!status.appComplete) {
redirect(getRedirectPathFromStatus(status));
}
return user;
}
export async function requireOnboardingQuizComplete() {
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);
}

View File

@@ -0,0 +1,270 @@
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import {
hexToHsl,
hslToHex,
type HSL,
} from "@/lib/character/color-utils";
import {
assertValidVariants,
normalizeVariantId,
normalizeVariants,
} from "@/lib/character/variant-validation";
import {
buildDefaultVariants,
DEFAULT_GRAY_COLOR,
LAYER_DEFAULT_COLORS,
NONE_VARIANT_ID,
} from "@/lib/character/presets";
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
import { createClient } from "@/lib/supabase/client";
export type AvatarCustomization = {
name: string;
colors: LayerColorState;
variants: LayerVariantState;
customized: boolean;
equippedItems: string[];
};
type AvatarStateRow = {
avatar_name: string | null;
skin_color: string | null;
pants_style: string | null;
pants_color: string | null;
shoe_style: string | null;
shoe_color: string | null;
torso_style: string | null;
torso_color: string | null;
eye_type: string | null;
eye_color: string | null;
head_style: string | null;
head_color: string | null;
avatar_customized: boolean | null;
equipped_items: string[] | null;
};
export const DEFAULT_AVATAR_NAME = "Pixel Me";
const DEFAULT_HEX = DEFAULT_GRAY_COLOR.hex;
const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
export const defaultAvatarCustomization: AvatarCustomization = {
name: DEFAULT_AVATAR_NAME,
colors: { ...LAYER_DEFAULT_COLORS },
variants: buildDefaultVariants(),
customized: false,
equippedItems: [],
};
const avatarSelectFields =
"avatar_name, skin_color, pants_style, pants_color, shoe_style, shoe_color, torso_style, torso_color, eye_type, eye_color, head_style, head_color, avatar_customized, equipped_items";
function colorFromHex(hex: string | null | undefined, fallback: HSL): HSL {
if (!hex?.trim() || !HEX_COLOR_PATTERN.test(hex)) {
return { ...fallback };
}
try {
return hexToHsl(hex);
} catch {
return { ...fallback };
}
}
function normalizeHexColor(color: HSL, fallback: HSL) {
const hex = hslToHex(color);
if (!HEX_COLOR_PATTERN.test(hex)) {
return hslToHex(fallback);
}
return hex.toLowerCase();
}
export function mapRowToAvatarCustomization(
row: AvatarStateRow | null | undefined,
): AvatarCustomization {
if (!row) {
return { ...defaultAvatarCustomization };
}
const defaults = defaultAvatarCustomization;
return {
name: row.avatar_name?.trim() || DEFAULT_AVATAR_NAME,
colors: {
skin: colorFromHex(row.skin_color, defaults.colors.skin),
pants: colorFromHex(row.pants_color, defaults.colors.pants),
shoes: colorFromHex(row.shoe_color, defaults.colors.shoes),
torso: colorFromHex(row.torso_color, defaults.colors.torso),
eyes: colorFromHex(row.eye_color, defaults.colors.eyes),
head: colorFromHex(row.head_color, defaults.colors.head),
},
variants: {
skin: "skin-1",
pants: normalizeVariantId("pants", row.pants_style),
shoes: normalizeVariantId("shoes", row.shoe_style),
torso: normalizeVariantId("torso", row.torso_style),
eyes: normalizeVariantId("eyes", row.eye_type),
head: normalizeVariantId("head", row.head_style),
},
customized: row.avatar_customized === true,
equippedItems: row.equipped_items ?? [],
};
}
export function mapAvatarCustomizationToRow(customization: AvatarCustomization) {
const normalizedVariants = normalizeVariants(customization.variants);
assertValidVariants(normalizedVariants);
const { colors, name } = customization;
const defaults = defaultAvatarCustomization.colors;
return {
avatar_name: name.trim() || DEFAULT_AVATAR_NAME,
skin_color: normalizeHexColor(colors.skin, defaults.skin),
pants_style: normalizedVariants.pants,
pants_color: normalizeHexColor(colors.pants, defaults.pants),
shoe_style: normalizedVariants.shoes,
shoe_color: normalizeHexColor(colors.shoes, defaults.shoes),
torso_style: normalizedVariants.torso,
torso_color: normalizeHexColor(colors.torso, defaults.torso),
eye_type: normalizedVariants.eyes,
eye_color: normalizeHexColor(colors.eyes, defaults.eyes),
head_style: normalizedVariants.head,
head_color: normalizeHexColor(colors.head, defaults.head),
avatar_customized: true,
updated_at: new Date().toISOString(),
};
}
async function getAuthenticatedUserId() {
const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null;
}
return user.id;
}
export async function getAvatarCustomization(): Promise<AvatarCustomization> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return { ...defaultAvatarCustomization };
}
const supabase = createClient();
const { data, error } = await supabase
.from("avatar_state")
.select(avatarSelectFields)
.eq("user_id", userId)
.maybeSingle();
if (error) {
throw new Error(error.message);
}
if (!data) {
const { error: insertError } = await supabase.from("avatar_state").insert({
user_id: userId,
avatar_name: DEFAULT_AVATAR_NAME,
skin_color: DEFAULT_HEX,
pants_style: NONE_VARIANT_ID,
pants_color: DEFAULT_HEX,
shoe_style: NONE_VARIANT_ID,
shoe_color: DEFAULT_HEX,
torso_style: NONE_VARIANT_ID,
torso_color: DEFAULT_HEX,
eye_type: NONE_VARIANT_ID,
eye_color: DEFAULT_HEX,
head_style: NONE_VARIANT_ID,
head_color: DEFAULT_HEX,
});
if (insertError) {
throw new Error(insertError.message);
}
return { ...defaultAvatarCustomization };
}
return mapRowToAvatarCustomization(data as AvatarStateRow);
}
export async function saveAvatarCustomization(
customization: AvatarCustomization,
options?: { completeOnboarding?: boolean },
) {
const userId = await getAuthenticatedUserId();
if (!userId) {
throw new Error("You must be signed in to save your avatar.");
}
const supabase = createClient();
const payload = mapAvatarCustomizationToRow(customization);
const { error } = await supabase
.from("avatar_state")
.update(payload)
.eq("user_id", userId);
if (error) {
throw new Error(error.message);
}
if (options?.completeOnboarding) {
const { error: profileError } = await supabase
.from("profiles")
.update({ onboarding_complete: true })
.eq("id", userId);
if (profileError) {
throw new Error(profileError.message);
}
const { error: authError } = await supabase.auth.updateUser({
data: {
onboarding_completed: true,
onboarding_quiz_completed: true,
},
});
if (authError) {
throw new Error(authError.message);
}
}
notifyHabitPetDataUpdated();
return customization;
}
export async function saveAvatarName(name: string) {
const userId = await getAuthenticatedUserId();
if (!userId) {
throw new Error("You must be signed in to rename your avatar.");
}
const trimmed = name.trim() || DEFAULT_AVATAR_NAME;
const supabase = createClient();
const { error } = await supabase
.from("avatar_state")
.update({
avatar_name: trimmed,
updated_at: new Date().toISOString(),
})
.eq("user_id", userId);
if (error) {
throw new Error(error.message);
}
notifyHabitPetDataUpdated();
return trimmed;
}

37
lib/avatar-display.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { LayerVariantState } from "@/lib/character/types";
import { parseShopStyleItem, parseVariantId } from "@/lib/shop-catalog";
/** Apply equipped shop styles (variant ids like head-1) onto saved customization. */
export function applyEquippedItemsToVariants(
variants: LayerVariantState,
equippedItems: string[],
): LayerVariantState {
const next = { ...variants };
for (const itemId of equippedItems) {
const style = parseVariantId(itemId);
if (style) {
next[style.layerId] = style.variantId;
}
}
return next;
}
export function applyEquippedShopRecordsToVariants(
variants: LayerVariantState,
equippedItems: Array<{ id: string; type: string }>,
): LayerVariantState {
const next = { ...variants };
for (const item of equippedItems) {
const style = parseShopStyleItem(item.id, item.type);
if (style) {
next[style.layerId] = style.variantId;
}
}
return next;
}

View File

@@ -120,6 +120,16 @@ export function rgbToHex({ r, g, b }: RGB): string {
.join("")}`;
}
export function hslToHex(hsl: HSL): string {
const rgb = hslToRgb(hsl.h, hsl.s, hsl.l);
return rgbToHex(rgb);
}
export function hexToHsl(hex: string): HSL {
const rgb = hexToRgb(hex);
return rgbToHsl(rgb.r, rgb.g, rgb.b);
}
/** Tint a monochrome sprite by mapping pixel brightness to the target color. */
export function tintImageData(imageData: ImageData, color: RGB): void {
const { data } = imageData;

View File

@@ -0,0 +1,79 @@
import type { CharacterLayerId } from "./presets";
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "./presets";
import type { LayerVariantState } from "./types";
const STYLE_FIELD_BY_LAYER: Record<
Exclude<CharacterLayerId, "skin">,
keyof LayerVariantState
> = {
pants: "pants",
shoes: "shoes",
torso: "torso",
eyes: "eyes",
head: "head",
};
const VALID_VARIANT_IDS: Record<CharacterLayerId, readonly string[]> = {
skin: ["skin-1"],
pants: ["none", "pants-1", "pants-2", "pants-3"],
shoes: ["none", "shoes-1", "shoes-2", "shoes-3"],
torso: ["none", "torso-1", "torso-2", "torso-3", "torso-4"],
eyes: ["none", "eyes-1", "eyes-2"],
head: [
"none",
"head-1",
"head-2",
"head-3",
"head-4",
"head-5",
"head-6",
"head-7",
],
};
export function getValidVariantIds(layerId: CharacterLayerId): readonly string[] {
return VALID_VARIANT_IDS[layerId];
}
export function isValidVariantId(layerId: CharacterLayerId, variantId: string) {
return VALID_VARIANT_IDS[layerId].includes(variantId);
}
export function normalizeVariantId(
layerId: CharacterLayerId,
variantId: string | null | undefined,
): string {
if (layerId === "skin") {
return "skin-1";
}
const trimmed = variantId?.trim();
if (trimmed && isValidVariantId(layerId, trimmed)) {
return trimmed;
}
return NONE_VARIANT_ID;
}
export function normalizeVariants(
variants: Partial<LayerVariantState> | null | undefined,
): LayerVariantState {
return CHARACTER_LAYERS.reduce((state, layer) => {
state[layer.id] = normalizeVariantId(
layer.id,
variants?.[layer.id as keyof LayerVariantState],
);
return state;
}, {} as LayerVariantState);
}
export function assertValidVariants(variants: LayerVariantState) {
for (const layer of CHARACTER_LAYERS) {
if (!isValidVariantId(layer.id, variants[layer.id])) {
throw new Error(`Invalid ${layer.label.toLowerCase()} style selected.`);
}
}
}
export { STYLE_FIELD_BY_LAYER, VALID_VARIANT_IDS };

93
lib/onboarding-status.ts Normal file
View File

@@ -0,0 +1,93 @@
import { routes } from "@/lib/routes";
import { createClient as createBrowserClient } from "@/lib/supabase/client";
import { createClient as createServerClient } from "@/lib/supabase/server";
export type OnboardingStatus = {
quizComplete: boolean;
avatarComplete: boolean;
appComplete: boolean;
};
type StatusRow = {
onboarding_quiz_complete: boolean | null;
onboarding_complete: boolean | null;
};
type AvatarRow = {
avatar_customized: boolean | null;
};
export function getRedirectPathFromStatus(status: OnboardingStatus) {
if (status.appComplete) {
return routes.avatar;
}
if (status.quizComplete) {
return routes.onboardingCustomize;
}
return routes.onboardingQuiz;
}
export function mapRowsToOnboardingStatus(
profile: StatusRow | null | undefined,
avatar: AvatarRow | null | undefined,
): OnboardingStatus {
return {
quizComplete: profile?.onboarding_quiz_complete === true,
avatarComplete: avatar?.avatar_customized === true,
appComplete: profile?.onboarding_complete === true,
};
}
export async function fetchOnboardingStatusForUser(
userId: string,
supabase: Awaited<ReturnType<typeof createServerClient>>,
): Promise<OnboardingStatus> {
const [{ data: profile }, { data: avatar }] = await Promise.all([
supabase
.from("profiles")
.select("onboarding_quiz_complete, onboarding_complete")
.eq("id", userId)
.maybeSingle(),
supabase
.from("avatar_state")
.select("avatar_customized")
.eq("user_id", userId)
.maybeSingle(),
]);
return mapRowsToOnboardingStatus(
profile as StatusRow | null,
avatar as AvatarRow | null,
);
}
export async function getOnboardingStatusClient(): Promise<OnboardingStatus | null> {
const supabase = createBrowserClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return null;
}
const [{ data: profile }, { data: avatar }] = await Promise.all([
supabase
.from("profiles")
.select("onboarding_quiz_complete, onboarding_complete")
.eq("id", user.id)
.maybeSingle(),
supabase
.from("avatar_state")
.select("avatar_customized")
.eq("user_id", user.id)
.maybeSingle(),
]);
return mapRowsToOnboardingStatus(
profile as StatusRow | null,
avatar as AvatarRow | null,
);
}

View File

@@ -167,7 +167,10 @@ function getOnboardingAnswersFromMetadata(
async function saveProfileRow(
userId: string,
preferences: ProfilePreferences,
options?: { markOnboardingComplete?: boolean },
options?: {
markOnboardingComplete?: boolean;
markQuizComplete?: boolean;
},
) {
const supabase = createClient();
const serialized = serializeFocusTopicsField(preferences);
@@ -175,9 +178,10 @@ async function saveProfileRow(
serialized,
[serialized],
];
const onboardingFields = options?.markOnboardingComplete
? { onboarding_complete: true }
: {};
const onboardingFields = {
...(options?.markOnboardingComplete ? { onboarding_complete: true } : {}),
...(options?.markQuizComplete ? { onboarding_quiz_complete: true } : {}),
};
let lastError: string | null = null;
@@ -296,10 +300,7 @@ export async function saveProfilePreferences(preferences: ProfilePreferences) {
return normalizedPreferences;
}
export async function saveOnboardingPreferences(
focusTopic: string,
avatarVibe: string,
) {
export async function saveOnboardingPreferences(focusTopic: string) {
const userId = await getAuthenticatedUserId();
if (!userId) {
@@ -307,16 +308,15 @@ export async function saveOnboardingPreferences(
}
const supabase = createClient();
const preferences = normalizePreferences({ focusTopic, avatarVibe });
const preferences = normalizePreferences({ focusTopic });
await saveProfileRow(userId, preferences, { markOnboardingComplete: true });
await saveProfileRow(userId, preferences, { markQuizComplete: true });
const { error: authError } = await supabase.auth.updateUser({
data: {
onboarding_completed: true,
onboarding_quiz_completed: true,
onboarding_answers: {
focus: preferences.focusTopic,
avatarVibe: preferences.avatarVibe,
},
},
});

View File

@@ -4,6 +4,7 @@ export const routes = {
signUp: "/auth/sign-up",
signUpSuccess: "/auth/sign-up-success",
onboardingQuiz: "/onboarding/quiz",
onboardingCustomize: "/onboarding/customize",
avatar: "/avatar",
habits: "/habits",
dailyQuiz: "/daily/quiz",
@@ -27,6 +28,10 @@ export function isAuthPath(pathname: string) {
return pathname.startsWith("/auth");
}
export function isOnboardingPath(pathname: string) {
return pathname.startsWith("/onboarding");
}
export function isPublicPath(pathname: string) {
return pathname === routes.home || isAuthPath(pathname);
}

87
lib/shop-catalog.ts Normal file
View File

@@ -0,0 +1,87 @@
import type { CharacterLayerId } from "@/lib/character/presets";
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets";
export type ShopLayerId = Exclude<CharacterLayerId, "skin">;
export type ShopItemRecord = {
id: string;
name: string;
type: ShopLayerId;
price: number;
image_path: string;
};
export type ShopStyleItem = {
layerId: ShopLayerId;
variantId: string;
};
const SHOP_LAYER_IDS = new Set<ShopLayerId>([
"pants",
"shoes",
"torso",
"eyes",
"head",
]);
export function isShopLayerId(value: string): value is ShopLayerId {
return SHOP_LAYER_IDS.has(value as ShopLayerId);
}
/** Parse a character variant id (e.g. head-1) into layer + variant. */
export function parseVariantId(variantId: string): ShopStyleItem | null {
for (const layerId of SHOP_LAYER_IDS) {
if (variantId.startsWith(`${layerId}-`)) {
return { layerId, variantId };
}
}
return null;
}
export function parseShopStyleItem(
itemId: string,
itemType: string,
): ShopStyleItem | null {
if (!isShopLayerId(itemType) || !itemId.startsWith(`${itemType}-`)) {
return null;
}
return { layerId: itemType, variantId: itemId };
}
export function getShopCatalogFromPresets(): ShopItemRecord[] {
return CHARACTER_LAYERS.flatMap((layer) => {
if (layer.id === "skin") {
return [];
}
return layer.variants
.filter((variant) => variant.id !== NONE_VARIANT_ID)
.map((variant) => ({
id: variant.id,
name: variant.label,
type: layer.id as ShopLayerId,
price: getDefaultPriceForLayer(layer.id as ShopLayerId, variant.id),
image_path: variant.src,
}));
});
}
function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) {
const number = Number.parseInt(variantId.split("-").pop() ?? "1", 10);
const baseByLayer: Record<ShopLayerId, number> = {
pants: 20,
shoes: 20,
torso: 25,
eyes: 15,
head: 30,
};
return baseByLayer[layerId] + (number - 1) * 5;
}
export function getLayerLabel(layerId: ShopLayerId) {
return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId;
}

View File

@@ -1,45 +1,11 @@
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import { getCoins, spendCoins, adjustCoins } from "@/lib/avatar-progression-storage";
import { adjustCoins, getCoins } from "@/lib/avatar-progression-storage";
import {
parseVariantId,
type ShopItemRecord,
} from "@/lib/shop-catalog";
import { createClient } from "@/lib/supabase/client";
export type ShopItem = {
id: string;
name: string;
type: string;
price: number;
};
type ShopItemRow = {
id: string;
name: string;
type: string;
};
function getPriceForType(type: string) {
switch (type.toLowerCase()) {
case "accessory":
return 30;
case "background":
return 40;
case "outfit":
return 50;
case "consumable":
case "snack":
return 25;
default:
return 50;
}
}
function mapShopItemRow(row: ShopItemRow): ShopItem {
return {
id: row.id,
name: row.name,
type: row.type,
price: getPriceForType(row.type),
};
}
async function getAuthenticatedUserId() {
const supabase = createClient();
const {
@@ -54,19 +20,18 @@ async function getAuthenticatedUserId() {
return user.id;
}
export async function getShopItems(): Promise<ShopItem[]> {
export async function getShopItems(): Promise<ShopItemRecord[]> {
const supabase = createClient();
const { data, error } = await supabase
.from("shop_items")
.select("id, name, type")
.order("type", { ascending: true })
.order("name", { ascending: true });
.select("id, name, type, price, image_path")
.order("price", { ascending: true });
if (error) {
throw new Error(error.message);
}
return ((data ?? []) as ShopItemRow[]).map(mapShopItemRow);
return (data ?? []) as ShopItemRecord[];
}
export async function getOwnedItemIds(): Promise<string[]> {
@@ -89,7 +54,28 @@ export async function getOwnedItemIds(): Promise<string[]> {
return (data ?? []).map((row) => row.item_id as string);
}
export async function purchaseShopItem(itemId: string): Promise<number> {
export async function getEquippedItems(): Promise<string[]> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return [];
}
const supabase = createClient();
const { data, error } = await supabase
.from("avatar_state")
.select("equipped_items")
.eq("user_id", userId)
.maybeSingle();
if (error) {
throw new Error(error.message);
}
return (data?.equipped_items as string[] | null) ?? [];
}
export async function purchaseShopItem(itemId: string) {
const userId = await getAuthenticatedUserId();
if (!userId) {
@@ -97,9 +83,9 @@ export async function purchaseShopItem(itemId: string): Promise<number> {
}
const supabase = createClient();
const { data: itemRow, error: itemError } = await supabase
const { data: item, error: itemError } = await supabase
.from("shop_items")
.select("id, name, type")
.select("id, name, type, price, image_path")
.eq("id", itemId)
.maybeSingle();
@@ -107,28 +93,23 @@ export async function purchaseShopItem(itemId: string): Promise<number> {
throw new Error(itemError.message);
}
if (!itemRow) {
throw new Error("Item not found.");
if (!item) {
throw new Error("That shop item is no longer available.");
}
const item = mapShopItemRow(itemRow as ShopItemRow);
const owned = await getOwnedItemIds();
const { data: existingPurchase, error: existingError } = await supabase
.from("user_items")
.select("item_id")
.eq("user_id", userId)
.eq("item_id", itemId)
.maybeSingle();
if (existingError) {
throw new Error(existingError.message);
}
if (existingPurchase) {
if (owned.includes(itemId)) {
throw new Error("You already own this item.");
}
const remainingCoins = await spendCoins(item.price);
const coins = await getCoins();
if (coins < item.price) {
throw new Error("Not enough points for this purchase.");
}
await adjustCoins(-item.price);
const { error: purchaseError } = await supabase.from("user_items").insert({
user_id: userId,
@@ -141,19 +122,106 @@ export async function purchaseShopItem(itemId: string): Promise<number> {
}
notifyHabitPetDataUpdated();
return remainingCoins;
return {
...(item as ShopItemRecord),
type: item.type as ShopItemRecord["type"],
};
}
export async function getShopState(): Promise<{
coins: number;
items: ShopItem[];
ownedItemIds: string[];
}> {
const [coins, items, ownedItemIds] = await Promise.all([
getCoins(),
export async function equipShopItem(itemId: string) {
const userId = await getAuthenticatedUserId();
if (!userId) {
throw new Error("You must be signed in to equip items.");
}
const owned = await getOwnedItemIds();
if (!owned.includes(itemId)) {
throw new Error("Purchase this item before equipping it.");
}
const supabase = createClient();
const { data: item, error: itemError } = await supabase
.from("shop_items")
.select("id, type")
.eq("id", itemId)
.maybeSingle();
if (itemError || !item) {
throw new Error("Could not find that shop item.");
}
const current = await getEquippedItems();
const nextStyle = parseVariantId(itemId);
const withoutSameLayer = current.filter((equippedId) => {
if (equippedId === itemId) {
return false;
}
const equippedStyle = parseVariantId(equippedId);
if (!nextStyle || !equippedStyle) {
return true;
}
return equippedStyle.layerId !== nextStyle.layerId;
});
const next = [...withoutSameLayer, itemId];
const { error } = await supabase
.from("avatar_state")
.update({
equipped_items: next,
equipped_item: itemId,
updated_at: new Date().toISOString(),
})
.eq("user_id", userId);
if (error) {
throw new Error(error.message);
}
notifyHabitPetDataUpdated();
return next;
}
export async function unequipShopItem(itemId: string) {
const userId = await getAuthenticatedUserId();
if (!userId) {
throw new Error("You must be signed in to unequip items.");
}
const current = await getEquippedItems();
const next = current.filter((id) => id !== itemId);
const supabase = createClient();
const { error } = await supabase
.from("avatar_state")
.update({
equipped_items: next,
equipped_item: next[0] ?? "none",
updated_at: new Date().toISOString(),
})
.eq("user_id", userId);
if (error) {
throw new Error(error.message);
}
notifyHabitPetDataUpdated();
return next;
}
export async function getShopInventory() {
const [items, ownedItemIds, equippedItems, coins] = await Promise.all([
getShopItems(),
getOwnedItemIds(),
getEquippedItems(),
getCoins(),
]);
return { coins, items, ownedItemIds };
return { items, ownedItemIds, equippedItems, coins };
}