Files
HaBit/lib/avatar-customization-storage.ts
Harkamal Randhawa 5dda46cb87 Improve shop UX, starting balance, and top bar branding.
Give new users 100 free points, allow shop previews regardless of balance, rename clothing items with descriptive labels, and show only the HaBit logo in the top-left.
2026-05-24 04:51:01 -06:00

316 lines
8.7 KiB
TypeScript

import { DEFAULT_STARTING_COINS } from "@/lib/coins";
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 {
DEFAULT_ROOM_BACKGROUND,
normalizeRoomBackgroundId,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
import { createClient } from "@/lib/supabase/client";
export type AvatarCustomization = {
name: string;
colors: LayerColorState;
variants: LayerVariantState;
customized: boolean;
equippedItems: string[];
roomBackground: RoomBackgroundId;
};
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;
room_background: 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: [],
roomBackground: DEFAULT_ROOM_BACKGROUND,
};
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, room_background";
const avatarSelectFieldsLegacy =
"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 isMissingRoomBackgroundColumn(message: string) {
return /room_background/i.test(message);
}
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 ?? [],
roomBackground: normalizeRoomBackgroundId(row.room_background),
};
}
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),
room_background: normalizeRoomBackgroundId(customization.roomBackground),
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();
let { data, error } = await supabase
.from("avatar_state")
.select(avatarSelectFields)
.eq("user_id", userId)
.maybeSingle();
if (error && isMissingRoomBackgroundColumn(error.message)) {
const legacy = await supabase
.from("avatar_state")
.select(avatarSelectFieldsLegacy)
.eq("user_id", userId)
.maybeSingle();
if (legacy.error) {
throw new Error(legacy.error.message);
}
data = legacy.data
? ({ ...legacy.data, room_background: null } as AvatarStateRow)
: null;
error = null;
} else if (error) {
throw new Error(error.message);
}
if (!data) {
const { error: insertError } = await supabase.from("avatar_state").insert({
user_id: userId,
coins: DEFAULT_STARTING_COINS,
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);
let { error } = await supabase
.from("avatar_state")
.update(payload)
.eq("user_id", userId);
if (error && isMissingRoomBackgroundColumn(error.message)) {
const { room_background, ...legacyPayload } = payload;
void room_background;
const retry = await supabase
.from("avatar_state")
.update(legacyPayload)
.eq("user_id", userId);
error = retry.error;
}
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;
}