Merge character customization branch and resolve avatar page conflict
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
270
lib/avatar-customization-storage.ts
Normal file
270
lib/avatar-customization-storage.ts
Normal 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
37
lib/avatar-display.ts
Normal 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;
|
||||
}
|
||||
258
lib/character/color-utils.ts
Normal file
258
lib/character/color-utils.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
export type HSL = { h: number; s: number; l: number };
|
||||
|
||||
export const LIGHTNESS_MIN = 5;
|
||||
|
||||
export function lightnessToDisplay(lightness: number): number {
|
||||
return Math.round(
|
||||
((lightness - LIGHTNESS_MIN) / (100 - LIGHTNESS_MIN)) * 100,
|
||||
);
|
||||
}
|
||||
|
||||
export function displayToLightness(display: number): number {
|
||||
return Math.round(
|
||||
LIGHTNESS_MIN + (display / 100) * (100 - LIGHTNESS_MIN),
|
||||
);
|
||||
}
|
||||
|
||||
export function clampHsl(hsl: HSL): HSL {
|
||||
return {
|
||||
h: hsl.h,
|
||||
s: Math.max(0, Math.min(100, hsl.s)),
|
||||
l: Math.max(LIGHTNESS_MIN, Math.min(100, hsl.l)),
|
||||
};
|
||||
}
|
||||
|
||||
export function hslToRgb(h: number, s: number, l: number): RGB {
|
||||
const saturation = s / 100;
|
||||
const lightness = l / 100;
|
||||
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = lightness - chroma / 2;
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
if (h < 60) {
|
||||
r = chroma;
|
||||
g = x;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = chroma;
|
||||
} else if (h < 180) {
|
||||
g = chroma;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
g = x;
|
||||
b = chroma;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
b = chroma;
|
||||
} else {
|
||||
r = chroma;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToHsl(r: number, g: number, b: number): HSL {
|
||||
const red = r / 255;
|
||||
const green = g / 255;
|
||||
const blue = b / 255;
|
||||
const max = Math.max(red, green, blue);
|
||||
const min = Math.min(red, green, blue);
|
||||
const lightness = (max + min) / 2;
|
||||
|
||||
let hue = 0;
|
||||
let saturation = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const delta = max - min;
|
||||
saturation =
|
||||
lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case red:
|
||||
hue = ((green - blue) / delta + (green < blue ? 6 : 0)) * 60;
|
||||
break;
|
||||
case green:
|
||||
hue = ((blue - red) / delta + 2) * 60;
|
||||
break;
|
||||
default:
|
||||
hue = ((red - green) / delta + 4) * 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(hue),
|
||||
s: Math.round(saturation * 100),
|
||||
l: Math.round(lightness * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): RGB {
|
||||
const normalized = hex.replace("#", "");
|
||||
const value =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("")
|
||||
: normalized;
|
||||
|
||||
return {
|
||||
r: parseInt(value.slice(0, 2), 16),
|
||||
g: parseInt(value.slice(2, 4), 16),
|
||||
b: parseInt(value.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToHex({ r, g, b }: RGB): string {
|
||||
return `#${[r, g, b]
|
||||
.map((channel) => channel.toString(16).padStart(2, "0"))
|
||||
.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;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
if (alpha === 0) continue;
|
||||
|
||||
applyMonochromeTint(data, i, color);
|
||||
}
|
||||
}
|
||||
|
||||
function isWhitePixel(r: number, g: number, b: number): boolean {
|
||||
return r >= 240 && g >= 240 && b >= 240;
|
||||
}
|
||||
|
||||
function isMagentaPixel(r: number, g: number, b: number): boolean {
|
||||
return r >= 200 && g <= 80 && b >= 200;
|
||||
}
|
||||
|
||||
function applyMonochromeTint(
|
||||
data: Uint8ClampedArray,
|
||||
index: number,
|
||||
color: RGB,
|
||||
brightness?: number,
|
||||
): void {
|
||||
const pixelBrightness =
|
||||
brightness ??
|
||||
Math.max(data[index], data[index + 1], data[index + 2]) / 255;
|
||||
|
||||
data[index] = Math.round(color.r * pixelBrightness);
|
||||
data[index + 1] = Math.round(color.g * pixelBrightness);
|
||||
data[index + 2] = Math.round(color.b * pixelBrightness);
|
||||
}
|
||||
|
||||
/** Darkest shadow in the skin template — magenta maps here, not bright red. */
|
||||
const SKIN_TEMPLATE_SHADOW = hexToRgb("#330000");
|
||||
const SKIN_SHADOW_BRIGHTNESS =
|
||||
Math.max(
|
||||
SKIN_TEMPLATE_SHADOW.r,
|
||||
SKIN_TEMPLATE_SHADOW.g,
|
||||
SKIN_TEMPLATE_SHADOW.b,
|
||||
) / 255;
|
||||
|
||||
/** Eyes: white stays white, magenta uses skin color, other pixels use eye color. */
|
||||
export function tintEyesImageData(
|
||||
imageData: ImageData,
|
||||
eyeColor: RGB,
|
||||
skinColor: RGB,
|
||||
): void {
|
||||
const { data } = imageData;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
if (alpha === 0) continue;
|
||||
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
if (isWhitePixel(r, g, b)) {
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMagentaPixel(r, g, b)) {
|
||||
applyMonochromeTint(data, i, skinColor, SKIN_SHADOW_BRIGHTNESS);
|
||||
continue;
|
||||
}
|
||||
|
||||
applyMonochromeTint(data, i, eyeColor);
|
||||
}
|
||||
}
|
||||
|
||||
export type TintOptions = {
|
||||
mode?: "monochrome" | "eyes";
|
||||
skinColor?: RGB;
|
||||
};
|
||||
|
||||
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
const imageCache = new Map<string, HTMLImageElement>();
|
||||
|
||||
export async function getCachedImage(src: string): Promise<HTMLImageElement> {
|
||||
if (!imageCache.has(src)) {
|
||||
imageCache.set(src, await loadImage(src));
|
||||
}
|
||||
return imageCache.get(src)!;
|
||||
}
|
||||
|
||||
export function drawTintedSprite(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
color: RGB,
|
||||
options?: TintOptions,
|
||||
): void {
|
||||
const width = image.width;
|
||||
const height = image.height;
|
||||
const offscreen = document.createElement("canvas");
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
|
||||
const offscreenCtx = offscreen.getContext("2d", { willReadFrequently: true });
|
||||
if (!offscreenCtx) return;
|
||||
|
||||
offscreenCtx.drawImage(image, 0, 0);
|
||||
const imageData = offscreenCtx.getImageData(0, 0, width, height);
|
||||
|
||||
if (options?.mode === "eyes" && options.skinColor) {
|
||||
tintEyesImageData(imageData, color, options.skinColor);
|
||||
} else {
|
||||
tintImageData(imageData, color);
|
||||
}
|
||||
|
||||
offscreenCtx.putImageData(imageData, 0, 0);
|
||||
ctx.drawImage(offscreen, 0, 0);
|
||||
}
|
||||
193
lib/character/presets.ts
Normal file
193
lib/character/presets.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
clampHsl,
|
||||
hexToRgb,
|
||||
hslToRgb,
|
||||
rgbToHex,
|
||||
rgbToHsl,
|
||||
type HSL,
|
||||
} from "./color-utils";
|
||||
import type { LayerVariantState } from "./types";
|
||||
|
||||
export type ColorPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
hex: string;
|
||||
hsl: HSL;
|
||||
};
|
||||
|
||||
export type LayerVariant = {
|
||||
id: string;
|
||||
label: string;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export const NONE_VARIANT_ID = "none";
|
||||
|
||||
export const NONE_VARIANT: LayerVariant = {
|
||||
id: NONE_VARIANT_ID,
|
||||
label: "None",
|
||||
src: "",
|
||||
};
|
||||
|
||||
export type CharacterLayerId =
|
||||
| "skin"
|
||||
| "pants"
|
||||
| "shoes"
|
||||
| "torso"
|
||||
| "eyes"
|
||||
| "head";
|
||||
|
||||
export type CharacterLayer = {
|
||||
id: CharacterLayerId;
|
||||
label: string;
|
||||
allowVariants: boolean;
|
||||
variants: LayerVariant[];
|
||||
};
|
||||
|
||||
function presetFromHsl(id: string, name: string, hsl: HSL): ColorPreset {
|
||||
const clamped = clampHsl(hsl);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
hsl: clamped,
|
||||
hex: rgbToHex(hslToRgb(clamped.h, clamped.s, clamped.l)),
|
||||
};
|
||||
}
|
||||
|
||||
function presetFromHex(id: string, name: string, hex: string): ColorPreset {
|
||||
const rgb = hexToRgb(hex);
|
||||
return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
}
|
||||
|
||||
function layerVariants(
|
||||
layerId: Exclude<CharacterLayerId, "skin">,
|
||||
label: string,
|
||||
count: number,
|
||||
): LayerVariant[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const number = index + 1;
|
||||
return {
|
||||
id: `${layerId}-${number}`,
|
||||
label: `${label} ${number}`,
|
||||
src: `/character/${layerId}/${layerId}-${number}.png`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function layerVariantsWithNone(
|
||||
layerId: Exclude<CharacterLayerId, "skin">,
|
||||
label: string,
|
||||
count: number,
|
||||
): LayerVariant[] {
|
||||
return [NONE_VARIANT, ...layerVariants(layerId, label, count)];
|
||||
}
|
||||
|
||||
export const COLOR_PRESETS: ColorPreset[] = [
|
||||
presetFromHsl("gray", "Gray", { h: 0, s: 0, l: 50 }),
|
||||
presetFromHsl("white", "White", { h: 0, s: 0, l: 100 }),
|
||||
presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }),
|
||||
presetFromHex("red", "Red", "#FF2020"),
|
||||
presetFromHex("orange", "Orange", "#FF8800"),
|
||||
presetFromHex("yellow", "Yellow", "#FFD700"),
|
||||
presetFromHex("green", "Green", "#22AA44"),
|
||||
presetFromHex("blue", "Blue", "#2266FF"),
|
||||
presetFromHex("indigo", "Indigo", "#4B0082"),
|
||||
presetFromHex("porcelain", "Porcelain", "#FFDFC4"),
|
||||
presetFromHex("fair", "Fair", "#F0C9A5"),
|
||||
presetFromHex("light", "Light", "#E0AC69"),
|
||||
presetFromHex("medium", "Medium", "#C68642"),
|
||||
presetFromHex("tan", "Tan", "#A87149"),
|
||||
presetFromHex("brown", "Brown", "#8D5524"),
|
||||
presetFromHex("dark", "Dark", "#5C3317"),
|
||||
presetFromHex("deep", "Deep", "#3B2212"),
|
||||
presetFromHex("onyx", "Onyx", "#140C08"),
|
||||
];
|
||||
|
||||
export const DEFAULT_GRAY_COLOR =
|
||||
COLOR_PRESETS.find((preset) => preset.id === "gray")!;
|
||||
|
||||
export const DEFAULT_SKIN_COLOR = DEFAULT_GRAY_COLOR;
|
||||
|
||||
export const PIECE_SLOT_COUNT = 8;
|
||||
|
||||
/** Stack order: bottom → top */
|
||||
export const CHARACTER_LAYERS: CharacterLayer[] = [
|
||||
{
|
||||
id: "skin",
|
||||
label: "Skin",
|
||||
allowVariants: false,
|
||||
variants: [
|
||||
{ id: "skin-1", label: "Skin 1", src: "/character/skin/skin-1.png" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pants",
|
||||
label: "Pants",
|
||||
allowVariants: true,
|
||||
variants: layerVariantsWithNone("pants", "Pants", 3),
|
||||
},
|
||||
{
|
||||
id: "shoes",
|
||||
label: "Shoes",
|
||||
allowVariants: true,
|
||||
variants: layerVariantsWithNone("shoes", "Shoes", 3),
|
||||
},
|
||||
{
|
||||
id: "torso",
|
||||
label: "Torso",
|
||||
allowVariants: true,
|
||||
variants: layerVariantsWithNone("torso", "Torso", 4),
|
||||
},
|
||||
{
|
||||
id: "eyes",
|
||||
label: "Eyes",
|
||||
allowVariants: true,
|
||||
variants: layerVariantsWithNone("eyes", "Eyes", 2),
|
||||
},
|
||||
{
|
||||
id: "head",
|
||||
label: "Head",
|
||||
allowVariants: true,
|
||||
variants: layerVariantsWithNone("head", "Head", 7),
|
||||
},
|
||||
];
|
||||
|
||||
export const LAYER_DEFAULT_COLORS: Record<CharacterLayerId, HSL> = {
|
||||
skin: DEFAULT_GRAY_COLOR.hsl,
|
||||
pants: DEFAULT_GRAY_COLOR.hsl,
|
||||
shoes: DEFAULT_GRAY_COLOR.hsl,
|
||||
torso: DEFAULT_GRAY_COLOR.hsl,
|
||||
eyes: DEFAULT_GRAY_COLOR.hsl,
|
||||
head: DEFAULT_GRAY_COLOR.hsl,
|
||||
};
|
||||
|
||||
export function buildDefaultVariants(): LayerVariantState {
|
||||
return CHARACTER_LAYERS.reduce((state, layer) => {
|
||||
state[layer.id] = layer.allowVariants
|
||||
? NONE_VARIANT_ID
|
||||
: (layer.variants[0]?.id ?? "");
|
||||
return state;
|
||||
}, {} as LayerVariantState);
|
||||
}
|
||||
|
||||
export function getLayerById(layerId: CharacterLayerId): CharacterLayer {
|
||||
return CHARACTER_LAYERS.find((layer) => layer.id === layerId)!;
|
||||
}
|
||||
|
||||
export function getSelectedVariant(
|
||||
layerId: CharacterLayerId,
|
||||
variantId: string,
|
||||
): LayerVariant | undefined {
|
||||
if (variantId === NONE_VARIANT_ID) return undefined;
|
||||
|
||||
const variant = getLayerById(layerId).variants.find(
|
||||
(entry) => entry.id === variantId,
|
||||
);
|
||||
|
||||
if (!variant?.src) return undefined;
|
||||
|
||||
return variant;
|
||||
}
|
||||
|
||||
/** @deprecated Use DEFAULT_SKIN_COLOR */
|
||||
export const DEFAULT_BASE_COLOR = DEFAULT_SKIN_COLOR;
|
||||
5
lib/character/types.ts
Normal file
5
lib/character/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { HSL } from "./color-utils";
|
||||
import type { CharacterLayerId } from "./presets";
|
||||
|
||||
export type LayerColorState = Record<CharacterLayerId, HSL>;
|
||||
export type LayerVariantState = Record<CharacterLayerId, string>;
|
||||
39
lib/character/variant-access.ts
Normal file
39
lib/character/variant-access.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NONE_VARIANT_ID } from "@/lib/character/presets";
|
||||
|
||||
/** First N numbered styles per layer are free; higher numbers are shop-only. */
|
||||
export const FREE_STYLE_COUNT = 2;
|
||||
|
||||
export function getVariantStyleNumber(variantId: string): number | null {
|
||||
if (variantId === NONE_VARIANT_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = variantId.match(/-(\d+)$/);
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
export function isFreeStyleVariant(variantId: string): boolean {
|
||||
const styleNumber = getVariantStyleNumber(variantId);
|
||||
return styleNumber !== null && styleNumber <= FREE_STYLE_COUNT;
|
||||
}
|
||||
|
||||
export function isShopOnlyStyleVariant(variantId: string): boolean {
|
||||
const styleNumber = getVariantStyleNumber(variantId);
|
||||
return styleNumber !== null && styleNumber > FREE_STYLE_COUNT;
|
||||
}
|
||||
|
||||
export function isVariantUnlocked(
|
||||
variantId: string,
|
||||
ownedVariantIds: ReadonlySet<string> | readonly string[],
|
||||
): boolean {
|
||||
if (variantId === NONE_VARIANT_ID || isFreeStyleVariant(variantId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const owned =
|
||||
ownedVariantIds instanceof Set
|
||||
? ownedVariantIds
|
||||
: new Set(ownedVariantIds);
|
||||
|
||||
return owned.has(variantId);
|
||||
}
|
||||
79
lib/character/variant-validation.ts
Normal file
79
lib/character/variant-validation.ts
Normal 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
93
lib/onboarding-status.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -178,7 +178,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);
|
||||
@@ -186,9 +189,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;
|
||||
|
||||
@@ -307,10 +311,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) {
|
||||
@@ -318,16 +319,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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
91
lib/shop-catalog.ts
Normal file
91
lib/shop-catalog.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
|
||||
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 && isShopOnlyStyleVariant(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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user