316 lines
8.7 KiB
TypeScript
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 = "My Bit";
|
|
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;
|
|
}
|