From 4705b1a0ec2d0db9a8c2ba1113437963fd535261 Mon Sep 17 00:00:00 2001 From: pricewowen <231476659+pricewowen@users.noreply.github.com> Date: Sat, 23 May 2026 19:32:20 -0600 Subject: [PATCH 1/5] Add initial character creation screen Created the first version of the character creation feature, including the base layout and starting UI elements for customizing a character. --- app/character-creator/page.tsx | 14 ++ .../character/character-color-presets.tsx | 38 ++++ .../character/character-color-sliders.tsx | 114 +++++++++++ components/character/character-creator.tsx | 138 ++++++++++++++ .../character/character-layer-preview.tsx | 101 ++++++++++ components/character/character-layer-tabs.tsx | 40 ++++ .../character/character-piece-selector.tsx | 61 ++++++ components/character/tinted-sprite-icon.tsx | 66 +++++++ lib/character/color-utils.ts | 177 ++++++++++++++++++ lib/character/presets.ts | 141 ++++++++++++++ lib/character/types.ts | 5 + public/character/eyes/.gitkeep | 0 public/character/head/.gitkeep | 0 public/character/pants/.gitkeep | 0 public/character/skin/skin-1.png | Bin 0 -> 580 bytes public/character/torso/.gitkeep | 0 public/character/torso/torso-1.png | Bin 0 -> 261 bytes public/character/torso/torso-2.png | Bin 0 -> 261 bytes 18 files changed, 895 insertions(+) create mode 100644 app/character-creator/page.tsx create mode 100644 components/character/character-color-presets.tsx create mode 100644 components/character/character-color-sliders.tsx create mode 100644 components/character/character-creator.tsx create mode 100644 components/character/character-layer-preview.tsx create mode 100644 components/character/character-layer-tabs.tsx create mode 100644 components/character/character-piece-selector.tsx create mode 100644 components/character/tinted-sprite-icon.tsx create mode 100644 lib/character/color-utils.ts create mode 100644 lib/character/presets.ts create mode 100644 lib/character/types.ts create mode 100644 public/character/eyes/.gitkeep create mode 100644 public/character/head/.gitkeep create mode 100644 public/character/pants/.gitkeep create mode 100644 public/character/skin/skin-1.png create mode 100644 public/character/torso/.gitkeep create mode 100644 public/character/torso/torso-1.png create mode 100644 public/character/torso/torso-2.png diff --git a/app/character-creator/page.tsx b/app/character-creator/page.tsx new file mode 100644 index 0000000..cfe3e30 --- /dev/null +++ b/app/character-creator/page.tsx @@ -0,0 +1,14 @@ +import { CharacterCreator } from "@/components/character/character-creator"; + +export default function CharacterCreatorPage() { + return ( +
+
+

+ Character Creation +

+ +
+
+ ); +} diff --git a/components/character/character-color-presets.tsx b/components/character/character-color-presets.tsx new file mode 100644 index 0000000..31772dc --- /dev/null +++ b/components/character/character-color-presets.tsx @@ -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 ( +
+ {presets.map((preset) => ( +
+ ); +} diff --git a/components/character/character-color-sliders.tsx b/components/character/character-color-sliders.tsx new file mode 100644 index 0000000..6d51e6e --- /dev/null +++ b/components/character/character-color-sliders.tsx @@ -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 ( +
+
+ + + {shownValue} + +
+ 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 + } + /> +
+ ); +} + +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 ( +
+ onChange({ ...color, h })} + /> + onChange({ ...color, s })} + /> + + onChange(clampHsl({ ...color, l: displayToLightness(display) })) + } + /> +
+ ); +} diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx new file mode 100644 index 0000000..f817438 --- /dev/null +++ b/components/character/character-creator.tsx @@ -0,0 +1,138 @@ +"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 { + Card, + CardContent, +} from "@/components/ui/card"; +import { clampHsl } from "@/lib/character/color-utils"; +import { + CHARACTER_LAYERS, + COLOR_PRESETS, + DEFAULT_SKIN_COLOR, + LAYER_DEFAULT_COLORS, + buildDefaultVariants, + getLayerById, + type ColorPreset, +} from "@/lib/character/presets"; +import type { LayerColorState, LayerVariantState } from "@/lib/character/types"; + +function buildInitialColors(): LayerColorState { + return { ...LAYER_DEFAULT_COLORS }; +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function CharacterCreator() { + const [colors, setColors] = useState(buildInitialColors); + const [variants, setVariants] = useState( + buildDefaultVariants, + ); + const [activePresetId, setActivePresetId] = useState( + DEFAULT_SKIN_COLOR.id, + ); + const [activeLayerId, setActiveLayerId] = useState< + (typeof CHARACTER_LAYERS)[number]["id"] + >("skin"); + + const activeLayer = getLayerById(activeLayerId); + const activeColor = colors[activeLayerId]; + const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : []; + + 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); + setActivePresetId(""); + }; + + const handleVariantChange = (variantId: string) => { + setVariants((current) => ({ + ...current, + [activeLayerId]: variantId, + })); + }; + + return ( +
+ + +
+ + + + + + + + +
+ Style +
+ +
+
+ +
+ Color +
+ +
+
+ +
+ Adjust + +
+
+
+
+
+ ); +} diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx new file mode 100644 index 0000000..2dbfd57 --- /dev/null +++ b/components/character/character-layer-preview.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +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"; + +type CharacterLayerPreviewProps = { + colors: LayerColorState; + variants: LayerVariantState; + scale?: number; + className?: string; +}; + +export function CharacterLayerPreview({ + colors, + variants, + scale = 10, + className, +}: CharacterLayerPreviewProps) { + const canvasRef = useRef(null); + const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + let cancelled = false; + + async function render() { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return; + + const activeLayers = CHARACTER_LAYERS.flatMap((layer) => { + const selected = getSelectedVariant(layer.id, variants[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; + setDisplaySize({ width: width * scale, height: height * scale }); + ctx.clearRect(0, 0, width, height); + + for (const { layer, image } of images) { + const layerColor = colors[layer.id]; + drawTintedSprite( + ctx, + image, + hslToRgb(layerColor.h, layerColor.s, layerColor.l), + ); + } + } + + render(); + + return () => { + cancelled = true; + }; + }, [colors, variants, scale]); + + return ( +
+ +
+ ); +} diff --git a/components/character/character-layer-tabs.tsx b/components/character/character-layer-tabs.tsx new file mode 100644 index 0000000..9333a27 --- /dev/null +++ b/components/character/character-layer-tabs.tsx @@ -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 ( +
+ {CHARACTER_LAYERS.map((layer) => ( + + ))} +
+ ); +} diff --git a/components/character/character-piece-selector.tsx b/components/character/character-piece-selector.tsx new file mode 100644 index 0000000..1c8c372 --- /dev/null +++ b/components/character/character-piece-selector.tsx @@ -0,0 +1,61 @@ +"use client"; + +import type { HSL } from "@/lib/character/color-utils"; +import { 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[]; + activeId: string; + color: HSL; + onSelect: (variantId: string) => void; +}; + +export function CharacterPieceSelector({ + variants, + activeId, + color, + onSelect, +}: CharacterPieceSelectorProps) { + const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) => + variants[index] ?? null, + ); + + return ( +
+ {slots.map((variant, index) => { + if (!variant) { + return ( +
+ ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/components/character/tinted-sprite-icon.tsx b/components/character/tinted-sprite-icon.tsx new file mode 100644 index 0000000..dbc2407 --- /dev/null +++ b/components/character/tinted-sprite-icon.tsx @@ -0,0 +1,66 @@ +"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; + size?: number; + className?: string; +}; + +export function TintedSpriteIcon({ + src, + color, + size = 40, + className, +}: TintedSpriteIconProps) { + const canvasRef = useRef(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; + + const image = await getCachedImage(src); + if (cancelled) return; + + canvas.width = image.width; + canvas.height = image.height; + ctx.clearRect(0, 0, image.width, image.height); + drawTintedSprite(ctx, image, hslToRgb(color.h, color.s, color.l)); + } + + render(); + + return () => { + cancelled = true; + }; + }, [src, color]); + + return ( + + ); +} diff --git a/lib/character/color-utils.ts b/lib/character/color-utils.ts new file mode 100644 index 0000000..0100279 --- /dev/null +++ b/lib/character/color-utils.ts @@ -0,0 +1,177 @@ +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("")}`; +} + +/** 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; + + const brightness = + Math.max(data[i], data[i + 1], data[i + 2]) / 255; + + data[i] = Math.round(color.r * brightness); + data[i + 1] = Math.round(color.g * brightness); + data[i + 2] = Math.round(color.b * brightness); + } +} + +export function loadImage(src: string): Promise { + 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(); + +export async function getCachedImage(src: string): Promise { + if (!imageCache.has(src)) { + imageCache.set(src, await loadImage(src)); + } + return imageCache.get(src)!; +} + +export function drawTintedSprite( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + color: RGB, +): 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); + tintImageData(imageData, color); + offscreenCtx.putImageData(imageData, 0, 0); + ctx.drawImage(offscreen, 0, 0); +} diff --git a/lib/character/presets.ts b/lib/character/presets.ts new file mode 100644 index 0000000..161d980 --- /dev/null +++ b/lib/character/presets.ts @@ -0,0 +1,141 @@ +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 type CharacterLayerId = "skin" | "pants" | "torso" | "head" | "eyes"; + +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)); +} + +export const COLOR_PRESETS: ColorPreset[] = [ + presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }), + presetFromHsl("gray", "Gray", { h: 0, s: 0, l: 50 }), + presetFromHsl("white", "White", { h: 0, s: 0, l: 100 }), + 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_SKIN_COLOR = + COLOR_PRESETS.find((preset) => preset.id === "light")!; + +export const PIECE_SLOT_COUNT = 8; + +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: [], + }, + { + id: "torso", + label: "Torso", + allowVariants: true, + variants: [ + { id: "torso-1", label: "Torso 1", src: "/character/torso/torso-1.png" }, + { id: "torso-2", label: "Torso 2", src: "/character/torso/torso-2.png" }, + ], + }, + { + id: "head", + label: "Head", + allowVariants: true, + variants: [], + }, + { + id: "eyes", + label: "Eyes", + allowVariants: true, + variants: [], + }, +]; + +export const LAYER_DEFAULT_COLORS: Record = { + skin: DEFAULT_SKIN_COLOR.hsl, + pants: COLOR_PRESETS.find((preset) => preset.id === "blue")!.hsl, + torso: COLOR_PRESETS.find((preset) => preset.id === "red")!.hsl, + head: COLOR_PRESETS.find((preset) => preset.id === "yellow")!.hsl, + eyes: COLOR_PRESETS.find((preset) => preset.id === "indigo")!.hsl, +}; + +export function buildDefaultVariants(): LayerVariantState { + return CHARACTER_LAYERS.reduce((state, layer) => { + state[layer.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 { + return getLayerById(layerId).variants.find( + (variant) => variant.id === variantId, + ); +} + +/** @deprecated Use DEFAULT_SKIN_COLOR */ +export const DEFAULT_BASE_COLOR = DEFAULT_SKIN_COLOR; diff --git a/lib/character/types.ts b/lib/character/types.ts new file mode 100644 index 0000000..4eaee31 --- /dev/null +++ b/lib/character/types.ts @@ -0,0 +1,5 @@ +import type { HSL } from "./color-utils"; +import type { CharacterLayerId } from "./presets"; + +export type LayerColorState = Record; +export type LayerVariantState = Record; diff --git a/public/character/eyes/.gitkeep b/public/character/eyes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/character/head/.gitkeep b/public/character/head/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/character/pants/.gitkeep b/public/character/pants/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/character/skin/skin-1.png b/public/character/skin/skin-1.png new file mode 100644 index 0000000000000000000000000000000000000000..ad00028f45fa139c1da4cefc37705efecd6f6fcf GIT binary patch literal 580 zcmV-K0=xZ*P)ID}TAt|V%AYT9g02y>e zSad^gZEa<4bO1wgWnpw>WFU8GbZ8()Nlj2!fese{00FE?L_t(Y$IVsEYQjJe{xT*+=mhFMWU>b1HoWl_lU+Dy2P$MGz^a&~6X9>t?h0 zp*^$%IXFA>eY4-rjKE(Tk$()V7M$7%!rczdW-}HTMG*$4r=Txy2p6*?fShbn%3OtM z3gFXmhze4YgLrZPKnLJc%0kB>7chk1kb^k0tGO%po0KxyZ>WJ@hG6_m)<6JUTIYf( z&2(o1;C{U>{B(}vXwlgOU@YgG0L%qgss=x{fz^UrpaPjaR>4*O8a1m$+8HG6eLs+P z?`Mgdl2ZlznBK*N)^mS!_#VRf?VkluF&;b;ZEOCt}an8@pP0cG|a4t$sEJ;mK zD9lFz@i_i=LLgUz0gvljyp=z7CRc7?!LEpZd#4!Cug(nc!Oql&MS#Piy1s!{an^LB{Ts56pUC2 literal 0 HcmV?d00001 diff --git a/public/character/torso/torso-2.png b/public/character/torso/torso-2.png new file mode 100644 index 0000000000000000000000000000000000000000..5a25f7f725a5329b4443a3bcc7d9bdeceb30b996 GIT binary patch literal 261 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VRf?VkluF&;b;ZEOCt}an8@pP0cG|a4t$sEJ;mK zD9lFz@i_i=LLgUz0gvljyp=z7CRc7?!LEpZd#4!Cug(nc!Oql&MS#Piy1s!{an^LB{Ts56pUC2 literal 0 HcmV?d00001 From 1023b3edc65897b49062028640cd7e2254224835 Mon Sep 17 00:00:00 2001 From: pricewowen <231476659+pricewowen@users.noreply.github.com> Date: Sat, 23 May 2026 21:35:59 -0600 Subject: [PATCH 2/5] Complete character creation screen Finished the character creation screen with the final layout, UI elements, and customization flow ready for integration. --- components/character/character-creator.tsx | 14 ++- .../character/character-layer-preview.tsx | 14 ++- .../character/character-piece-selector.tsx | 38 ++++++- components/character/tinted-sprite-icon.tsx | 20 +++- lib/character/color-utils.ts | 85 +++++++++++++-- lib/character/presets.ts | 100 +++++++++++++----- public/character/eyes/eyes-1.png | Bin 0 -> 431 bytes public/character/eyes/eyes-2.png | Bin 0 -> 438 bytes public/character/head/head-1.png | Bin 0 -> 282 bytes public/character/head/head-2.png | Bin 0 -> 283 bytes public/character/head/head-3.png | Bin 0 -> 250 bytes public/character/head/head-4.png | Bin 0 -> 315 bytes public/character/head/head-5.png | Bin 0 -> 312 bytes public/character/head/head-6.png | Bin 0 -> 333 bytes public/character/head/head-7.png | Bin 0 -> 308 bytes public/character/pants/pants-1.png | Bin 0 -> 637 bytes public/character/pants/pants-2.png | Bin 0 -> 296 bytes public/character/pants/pants-3.png | Bin 0 -> 351 bytes public/character/shoes/shoes-1.png | Bin 0 -> 258 bytes public/character/shoes/shoes-2.png | Bin 0 -> 269 bytes public/character/shoes/shoes-3.png | Bin 0 -> 267 bytes public/character/skin/skin-1.png | Bin 580 -> 583 bytes public/character/torso/torso-1.png | Bin 261 -> 409 bytes public/character/torso/torso-2.png | Bin 261 -> 468 bytes public/character/torso/torso-3.png | Bin 0 -> 396 bytes public/character/torso/torso-4.png | Bin 0 -> 419 bytes 26 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 public/character/eyes/eyes-1.png create mode 100644 public/character/eyes/eyes-2.png create mode 100644 public/character/head/head-1.png create mode 100644 public/character/head/head-2.png create mode 100644 public/character/head/head-3.png create mode 100644 public/character/head/head-4.png create mode 100644 public/character/head/head-5.png create mode 100644 public/character/head/head-6.png create mode 100644 public/character/head/head-7.png create mode 100644 public/character/pants/pants-1.png create mode 100644 public/character/pants/pants-2.png create mode 100644 public/character/pants/pants-3.png create mode 100644 public/character/shoes/shoes-1.png create mode 100644 public/character/shoes/shoes-2.png create mode 100644 public/character/shoes/shoes-3.png create mode 100644 public/character/torso/torso-3.png create mode 100644 public/character/torso/torso-4.png diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx index f817438..b8c37fb 100644 --- a/components/character/character-creator.tsx +++ b/components/character/character-creator.tsx @@ -15,7 +15,7 @@ import { clampHsl } from "@/lib/character/color-utils"; import { CHARACTER_LAYERS, COLOR_PRESETS, - DEFAULT_SKIN_COLOR, + DEFAULT_GRAY_COLOR, LAYER_DEFAULT_COLORS, buildDefaultVariants, getLayerById, @@ -41,7 +41,7 @@ export function CharacterCreator() { buildDefaultVariants, ); const [activePresetId, setActivePresetId] = useState( - DEFAULT_SKIN_COLOR.id, + DEFAULT_GRAY_COLOR.id, ); const [activeLayerId, setActiveLayerId] = useState< (typeof CHARACTER_LAYERS)[number]["id"] @@ -69,7 +69,14 @@ export function CharacterCreator() { const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => { setActiveLayerId(layerId); - setActivePresetId(""); + 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) => { @@ -107,6 +114,7 @@ export function CharacterCreator() { variants={pieceVariants} activeId={variants[activeLayerId]} color={activeColor} + skinColor={colors.skin} onSelect={handleVariantChange} />
diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx index 2dbfd57..069ff34 100644 --- a/components/character/character-layer-preview.tsx +++ b/components/character/character-layer-preview.tsx @@ -65,10 +65,22 @@ export function CharacterLayerPreview({ for (const { layer, image } of images) { const layerColor = colors[layer.id]; + const rgb = hslToRgb(layerColor.h, layerColor.s, layerColor.l); + drawTintedSprite( ctx, image, - hslToRgb(layerColor.h, layerColor.s, layerColor.l), + rgb, + layer.id === "eyes" + ? { + mode: "eyes", + skinColor: hslToRgb( + colors.skin.h, + colors.skin.s, + colors.skin.l, + ), + } + : undefined, ); } } diff --git a/components/character/character-piece-selector.tsx b/components/character/character-piece-selector.tsx index 1c8c372..9b05be1 100644 --- a/components/character/character-piece-selector.tsx +++ b/components/character/character-piece-selector.tsx @@ -1,7 +1,9 @@ "use client"; +import { CircleSlash } from "lucide-react"; + import type { HSL } from "@/lib/character/color-utils"; -import { PIECE_SLOT_COUNT } from "@/lib/character/presets"; +import { NONE_VARIANT_ID, PIECE_SLOT_COUNT } from "@/lib/character/presets"; import { cn } from "@/lib/utils"; import type { LayerVariant } from "@/lib/character/presets"; @@ -11,6 +13,7 @@ type CharacterPieceSelectorProps = { variants: LayerVariant[]; activeId: string; color: HSL; + skinColor?: HSL; onSelect: (variantId: string) => void; }; @@ -18,6 +21,7 @@ export function CharacterPieceSelector({ variants, activeId, color, + skinColor, onSelect, }: CharacterPieceSelectorProps) { const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) => @@ -37,6 +41,31 @@ export function CharacterPieceSelector({ ); } + if (variant.id === NONE_VARIANT_ID) { + return ( + + ); + } + return ( ); })} diff --git a/components/character/tinted-sprite-icon.tsx b/components/character/tinted-sprite-icon.tsx index dbc2407..f391348 100644 --- a/components/character/tinted-sprite-icon.tsx +++ b/components/character/tinted-sprite-icon.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; type TintedSpriteIconProps = { src: string; color: HSL; + skinColor?: HSL; size?: number; className?: string; }; @@ -20,6 +21,7 @@ type TintedSpriteIconProps = { export function TintedSpriteIcon({ src, color, + skinColor, size = 40, className, }: TintedSpriteIconProps) { @@ -41,7 +43,21 @@ export function TintedSpriteIcon({ canvas.width = image.width; canvas.height = image.height; ctx.clearRect(0, 0, image.width, image.height); - drawTintedSprite(ctx, image, hslToRgb(color.h, color.s, color.l)); + + 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, + ); } render(); @@ -49,7 +65,7 @@ export function TintedSpriteIcon({ return () => { cancelled = true; }; - }, [src, color]); + }, [src, color, skinColor]); return ( = 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 { return new Promise((resolve, reject) => { const image = new Image(); @@ -159,6 +223,7 @@ export function drawTintedSprite( ctx: CanvasRenderingContext2D, image: HTMLImageElement, color: RGB, + options?: TintOptions, ): void { const width = image.width; const height = image.height; @@ -171,7 +236,13 @@ export function drawTintedSprite( offscreenCtx.drawImage(image, 0, 0); const imageData = offscreenCtx.getImageData(0, 0, width, height); - tintImageData(imageData, color); + + 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); } diff --git a/lib/character/presets.ts b/lib/character/presets.ts index 161d980..0889070 100644 --- a/lib/character/presets.ts +++ b/lib/character/presets.ts @@ -21,7 +21,21 @@ export type LayerVariant = { src: string; }; -export type CharacterLayerId = "skin" | "pants" | "torso" | "head" | "eyes"; +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; @@ -45,10 +59,33 @@ function presetFromHex(id: string, name: string, hex: string): ColorPreset { return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b)); } +function layerVariants( + layerId: Exclude, + 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, + label: string, + count: number, +): LayerVariant[] { + return [NONE_VARIANT, ...layerVariants(layerId, label, count)]; +} + export const COLOR_PRESETS: ColorPreset[] = [ - presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }), 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"), @@ -66,11 +103,14 @@ export const COLOR_PRESETS: ColorPreset[] = [ presetFromHex("onyx", "Onyx", "#140C08"), ]; -export const DEFAULT_SKIN_COLOR = - COLOR_PRESETS.find((preset) => preset.id === "light")!; +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", @@ -84,42 +124,48 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [ id: "pants", label: "Pants", allowVariants: true, - variants: [], + variants: layerVariantsWithNone("pants", "Pants", 3), + }, + { + id: "shoes", + label: "Shoes", + allowVariants: true, + variants: layerVariantsWithNone("shoes", "Shoes", 3), }, { id: "torso", label: "Torso", allowVariants: true, - variants: [ - { id: "torso-1", label: "Torso 1", src: "/character/torso/torso-1.png" }, - { id: "torso-2", label: "Torso 2", src: "/character/torso/torso-2.png" }, - ], - }, - { - id: "head", - label: "Head", - allowVariants: true, - variants: [], + variants: layerVariantsWithNone("torso", "Torso", 4), }, { id: "eyes", label: "Eyes", allowVariants: true, - variants: [], + variants: layerVariantsWithNone("eyes", "Eyes", 2), + }, + { + id: "head", + label: "Head", + allowVariants: true, + variants: layerVariantsWithNone("head", "Head", 7), }, ]; export const LAYER_DEFAULT_COLORS: Record = { - skin: DEFAULT_SKIN_COLOR.hsl, - pants: COLOR_PRESETS.find((preset) => preset.id === "blue")!.hsl, - torso: COLOR_PRESETS.find((preset) => preset.id === "red")!.hsl, - head: COLOR_PRESETS.find((preset) => preset.id === "yellow")!.hsl, - eyes: COLOR_PRESETS.find((preset) => preset.id === "indigo")!.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.variants[0]?.id ?? ""; + state[layer.id] = layer.allowVariants + ? NONE_VARIANT_ID + : (layer.variants[0]?.id ?? ""); return state; }, {} as LayerVariantState); } @@ -132,9 +178,15 @@ export function getSelectedVariant( layerId: CharacterLayerId, variantId: string, ): LayerVariant | undefined { - return getLayerById(layerId).variants.find( - (variant) => variant.id === variantId, + 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 */ diff --git a/public/character/eyes/eyes-1.png b/public/character/eyes/eyes-1.png new file mode 100644 index 0000000000000000000000000000000000000000..1b2a17acfa6c3703999ccf001851fd1363816d2c GIT binary patch literal 431 zcmV;g0Z{&lP)IE1ACNE9R2v-0A02y>e zSad^gZEa<4bO1wgWnpw>WFU8GbZ8()Nlj2!fese{009?CL_t(Y$L*3KPXj>^g}-a6 z5GPvEw8^!}c2!lg7hL~B-5F9**Wyr3ia3S3pm5|Ig75>7MdB_cc)}7|LQS#QMNl?( z$DvS=dv4ymd9yRK@aN&AgP~CLTYKounz7%`2rrf^MAPH`^_^>dXZdPT@yTQ#SILZl z^c#e6Lciq!%9>QtqOZ=blGFz?>Vuihxk@tj+vv?YV_eP8B>e^eZ?~ru=9a4rSILX= zPcO=)cVG>AqV~IUjJh3k6tG>)F&JYoMn?fU3T(Vx%+XQ6sN4B@1ON<7Qx~4%fGKWl z4Mfu;jFZm=-W)rV-OR3%Om-YV7$;mlK7ap6L!s!28ZS=|oSh#Ob~`$}$`qFIE1ACO2N8kO2Sy02y>e zSad^gZEa<4bO1wgWnpw>WFU8GbZ8()Nlj2!fese{00ACJL_t(Y$L*4#Z^BRz#(yQK zi3$(|l0s6dP~AoXW6*B>Y@vE`p-?bNTzG^5 z>Wj-jFVU`B1dEt?G&5L&59;9A2lmUEK?K1#=H~hJ`$y{Af{tC`?d6I2)oHHHv+-@J gxj=ye1^yX60ZqkSh^qL-L;wH)07*qoM6N<$f+y~~yZ`_I literal 0 HcmV?d00001 diff --git a/public/character/head/head-1.png b/public/character/head/head-1.png new file mode 100644 index 0000000000000000000000000000000000000000..19a7a71d1df90685cc4f2d490b22b445d1b87bcc GIT binary patch literal 282 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1qVw#Nbv{r?vcxr_#5q4VH#M(>!MP|ku_QG` zp**uBL&4qCHz2%`PaLSI$ML7h~$SJnUZTQ>1z4r-?LlUwisMkeWui!&3cxL(!?LydY5k} VR{tqG5esw;gQu&X%Q~loCIGN4V%q=! literal 0 HcmV?d00001 diff --git a/public/character/head/head-2.png b/public/character/head/head-2.png new file mode 100644 index 0000000000000000000000000000000000000000..f3c8cdaa713ac343d8d4bbe3a38de9933b6726f2 GIT binary patch literal 283 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1V)Av~VoRWqWQl7;iF1B#Zfaf$gL6@8Vo7R> zLV0FMhJw4NZ$Nk>pEyuav!{z=h{y4_6AucuD)6|-pL@@d?d5-e)`Ddf0>=dkY+fl= zH{Mh^J)0q4`>v=_M%g{Hm;cYQ-R|t!`O5jy<<&)3SFpRfPx`YVW(V^@oh95qWCD?_)^mS!_#VWz1V%e>KzXd2HS>hT|;+&tGo0?a`;9QiNSdyBe zP@Y+mq2TW68xY>eCk|AU)^mS!_#VWz1${b>0UJewJEOCt}an8@pP0cG|a4t$sEJ;mK zD90-ej?>FVdQ&MBb@ E064#Ky#N3J literal 0 HcmV?d00001 diff --git a/public/character/head/head-5.png b/public/character/head/head-5.png new file mode 100644 index 0000000000000000000000000000000000000000..a024c0cd2606579096be8c567650c65916ae8200 GIT binary patch literal 312 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1%DLt3y-c8xWQl7;iF1B#Zfaf$gL6@8Vo7R> zLV0FMhJw4NZ$Nk>pEyv_0#6sm5Rc<;C!OYHR^)MEpVIfDb5V@&x|2H{Osuob+UF>J zRAD(?<@SU5kH^iwFC0E>&rOQAPTYUoEiEKujgzpk@MJAEhK6Z(YEu~u8#tnx%j}A4 zwd`{r7H{3@e9k9zV&DYNdw%8fBn$SbEnA%t+oNxCYU=AlvQiEvGyD51wi$#h$qk!) zNNsh*?v>Go^UgNf_n5Aet?KhGwvpj$-~WL3>}~IDUN&)_K({h@y85}Sb4q9e0Ca6_ ABme*a literal 0 HcmV?d00001 diff --git a/public/character/head/head-6.png b/public/character/head/head-6.png new file mode 100644 index 0000000000000000000000000000000000000000..0ff3b9d1fb628fbacfeec19475ab783f5ad8be6b GIT binary patch literal 333 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1${*WT9Rn1SEOCt}an8@pP0cG|a4t$sEJ;mK zD9gnPb;&J?KkRxBS0Z;L&zZ&&i`HS+E@?Ys`wrHg0So=)Q zTDeH#_zus;_-3O?jbfIv8J8cOowmdF_AZ|T4$-;svijd*CYekTJv`In&`M*?lY)MS zr1rTUcK>fZ+o69(K*O88nhjS?Y6`X-&(HI`wC3K0b(W@Eq;r~+`ZEIV%*pSKh_#o< zSUUfI@Egu)=3ORICwf=QSax#W2s-iL-fa)fWx8A!LLYLN9(yeQ__v$Aq2WjI3MSWs Wp-rb4D{6rrVDNPHb6Mw<&;$Sy&3OX= literal 0 HcmV?d00001 diff --git a/public/character/head/head-7.png b/public/character/head/head-7.png new file mode 100644 index 0000000000000000000000000000000000000000..721749b3b794645a48ae1dff47e872ecc7cd6f7f GIT binary patch literal 308 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1#_&wWln*E*S>hT|;+&tGo0?a`;9QiNSdyBe zP@Y+mq2TW68xY>eCk|9J$J50z#N+teNe6k4DDbd|Pw9Kn`MPsff$u*4i@80%a)BlC zVNM*$`e_UmD@1Zb94hZN-BH$BQ@pOq?WEZC+0$klo?LyziP>bgR)f}p+B1u#--=j& z=l*!IPw>bZ!=y(tHj*8R`pdqGNq#uVvx@OYz@C3QUL0A_U+q4}YNI9ZCRKfNe-E2W vM;(sUh6_D!mQ_=Hm^+nq@me7G|BCyNzK!8WrRZrucQSao`njxgN@xNAx94m3 literal 0 HcmV?d00001 diff --git a/public/character/pants/pants-1.png b/public/character/pants/pants-1.png new file mode 100644 index 0000000000000000000000000000000000000000..0d8d741d6232eca420abc94de58befe5bcd1ccb9 GIT binary patch literal 637 zcmV-@0)qXCP)IE1AC=ci$1rY!M02y>e zSad^gZEa<4bO1wgWnpw>WFU8GbZ8()Nlj2!fese{00HGmL_t(Y$L*7`OIuMC#(!x` zp$@M!5j27geR+t4P~^oRlt?x^xwX=g!O|f(6kN>MDIgT5LWUAS3|18TAGjI^DfiLA zbkJvlsi7E%L)sv8(Lvtjy}WBEZi3%*?>Xn5^PO|P3%4Et_hni)85`=O`ui8NiKm{x zlBlDHhsl@A9{U5;<9s%WW*VMABQe0o;&&f|d+v)pesVg}nnB zBMb807+IjOcc5e-6X@Y#xu*WgBBK*&G}Ayc4Mr!@^j8*p3u#(6iRnR3YKMIO@>Ur< zKexg9(XpCAeCj1Xt7ohq9Wy_-@n4TU7=Lk9H4}|c{q%}VG{W}GB-=BS9-Fd-f8rYt z6R*icBYfSrXqA^}TeeRFp;V5*qmX)y?#I`OMy1aC0Dpfp=$!u{{k)B}yh~W2c>rs9 zm$gdWKg4FMAtzrFbsv3zwq;Wib-xm`@g$w|rVIkG#VOql*y2C~FEEn5Z! zw{|X`b^W?CfM_J(Q=)^mS!_#VWz1A(G^J<}Xl4vcxr_#5q4VH#M(>!MP|ku_QG` zp**uBL&4qCHz2%`PaLRdf~SjPh{y5d1PRu~2_ijBfeg$&FPM(;@cdtB!e!CDAn?nE z1mjXQzT1~Jb{IY^zNe#cjD5z3OgrwQn@<=vL>PQvTX3XlTML78&s?*|iEM0b^55eP z|5W^$)d&O++t+9uPINVFlK@GyuQQ6N{m58uGH?HXuGPLfjs)@uJV{Eo;NCj5v61np fT8e2iHv_}(ql!Pp^bSk{x{blp)z4*}Q$iB}$+2Vz literal 0 HcmV?d00001 diff --git a/public/character/pants/pants-3.png b/public/character/pants/pants-3.png new file mode 100644 index 0000000000000000000000000000000000000000..0df9b4a105067fc308e56900187d64e3871cc2d0 GIT binary patch literal 351 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1Au01^g9A`Vvcxr_#5q4VH#M(>!MP|ku_QG` zp**uBL&4qCHz2%`PaLS|xTlL_h{y4_lQ(iT8;H32UvWCFmt?+h|AdYIgZH2O=WlLY zcJ@w=?A&lgJ}bI#1rTU>FlN{@q(!>8s|o*1p3lk9kX_E0P!?uV z(9KX@Z_9A}-696Lb4(YG6sD!K=3ld(n4Yfc5O&EsXQo4naqt$lc(Dn$_ptg(JACzi zyfgVIgXtP+|CE}Eg+H}Evq_dSBz~^3`&hI0@_GgXJx>+(59iDJ8{*u3(mu>{j{O+g n>3jdum)xTL>#qF&*UDda%6F!%)gfS*Ffe$!`njxgN@xNAVy%8` literal 0 HcmV?d00001 diff --git a/public/character/shoes/shoes-1.png b/public/character/shoes/shoes-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6bed3ef0e843a7a9aae10760ba792a180e75c8ef GIT binary patch literal 258 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1ZgOw)hhIP;$r9Iy66gHf+|;}h2Ir#G#FEq$ zh4Rdj3)^mS!_#VWz1Ze_`R_b*ULvcxr_#5q4VH#M(>!MP|ku_QG` zp**uBL&4qCHz2%`PaLSI)YHW=#N&8!f&}a01d*PmKm)E795XJm_DD)d)Btgsk%7US z)P#fyNeKxzdQ|?)_}X%dIsX^lFhy{Ak0JBn|NjGO+8Q73Hx!xgC}i%onc*;NdZ9$! zG>PXoG&+rI_J99mKS|AP#_5ElIbM%?+~4h$ak)^mS!_#VWz1!TfNVrxj30vcxr_#5q4VH#M(>!MP|ku_QG` zp**uBL&4qCHz2%`PaLSI*we)^#N+tei5qzv40u@d9S*a2Oj$EwnyADC%ZNs!$VRbL zCa=pXlSDOTSzG;o9m?OIdNM(xEm1=L()BiWvjlb}1LpQu_pjD`e`zV_vWcnVJlCo3 z3RcEfypxhEPnYkCnBMu^Ymw8sn6N#CGw)O`mEZf3_3U1`sfoLCmIJM4@O1TaS?83{ F1OU)wSG52D literal 0 HcmV?d00001 diff --git a/public/character/skin/skin-1.png b/public/character/skin/skin-1.png index ad00028f45fa139c1da4cefc37705efecd6f6fcf..b59792993adda69d8eb7f380655b05b679244bc0 100644 GIT binary patch delta 474 zcmV<00VV##1jhuBQwSIWC?neVY4VXL_t(Y$IVs2YQjJeeVceHwP3voMMy4E ziUcDfet;dYB=vzZEv;~0a}Q_z<;g!5?@Ku$I(Wv;?B0r2TKM1KV- z$w53h0H6c#DP^JKkP8^XZ^%KM+0~qt`%Oxj>^IavuRt)qCu<-8F0FIHlxMm<0dT)s z9es6<<7m-Y1TdDYNLjMq1z4^IKemC@f-5oQEtx%4!BzhnHLFG11tjfVKah6sCJXQo zKjP`^O9bkYa|QgE+@*x%LV3wj{eN;!DRniJin|U9dLUE9u+sx)j0yydq>6@}-d=;{ z>+``jR@<59Wcpy7ygX=L<5=S_0l=(Wfr&*5#M1!FHY-;EEOr&}8GsyiD0_kQtPLHD zS8(>R?lm^-I`MPZecfbAasUR$&kNREM=2|}!Z1gJ+GnE Q4FCWD07*qoM6N<$f?+e=8UO$Q delta 471 zcmV;|0Vw{*1jGc8QwSFpAt|V%AYYMND1WR;L_t(Y$IVsEYQjJe{xT*+=mhFMWU>b1HoWl_lU+Dy2P$MGz^a&~6X9>t?h0p*^$%IXFA> zeY4-rjKE(Tk$()V7M$7%!rczdW-}HTMG*$4r=Txy2p6*?fShbn%3OtM3gFXmh<^%F zl7o1106+)eQ_4ceAr~-&-;jeiv#Yr)_nVY5*>9+UUWQ=&Ox8dETw3RXDa~|e0^okV zF8p+k<7m;@1Yj)Zn*huOSgHm;wt>}xTc84&JyyY0{~9%`McNr8?R`IxcJC$$@DM%X z>Fi4c>XK6h{FvUwgycebhfnp(Ie(?t(@-kjI>_jOL>0qM51et5^Mt&jVW)T0VD5wA;wycVVT_;mz$@=` z!KmNOO&;~Tu+5ErC*H2!a{<(t#8wV6l7k{!`B#5!e57T?(|rHOz5%@i(FtM(xoH3Z N002ovPDHLkV1l~3-If3V diff --git a/public/character/torso/torso-1.png b/public/character/torso/torso-1.png index 5a25f7f725a5329b4443a3bcc7d9bdeceb30b996..d3832932f58daf8301fc89a6513dc2178b1e0c97 100644 GIT binary patch delta 300 zcmV+{0n`440+|DlQwSIWCk=tNc=C~3Cw~C}Nkl6Om_T9VL1ne zJMi7PbMH3{I2?}Sqx=8~`cf+5N|}Bq+-UtyxY61{i1Gsfgjqm>9s*6^H;P0Qet)uB z76JHp`_eIR1!$?n$#hP#7~2Knbl=T&*GOYCEpYxlVdfRA+jS$2I=4(M8%6m6Ufze% z9*?$Zo;mw}83JOI=%2F^g>4C4V`g@Ek|DAVq|v69N(@?6E4fyGvGV@B!t=!~0FMvX ydq=o?dIum`jBOJS9R}#V-g9$Wb{Rj8xq?S{Hj7^{gGUL}VE_OC07*qoL-! zgHxK`AYB5d{sFP2EzrOv938_yLI*b&|AdAJnhJ7~3=D)$a1gzr!8qi68@l)I6l!W{ zxDPx$UtXT)dB5)u2nh-K6Er0<**vMe1FTL7 z8@~^EdAtW80#K~hF+>il(~?bK+GtDNF#(9jYnM%lQzma18}Zgt0GG8+2lC{$_)kk24XAf>B?t5uW&>xD|qcVPGKykT883pb7f z7uF5vU=QMlUDq8bmr8pM)+xKW8B}mk{_PpX_e9TT>hwfAN*}Hi_WEy(AD&j~x}a&n zqD(dqKyrHrBbFc?OR`wlyv{B9pEEvY69DfIgoOMbz5q30j?7H&2weaG002ovPDHLk FV1hA!o-68xq?S{Hj7^{gGUL}VE_OC07*qoL)^mS!_#VWz1E;&1AO+Qdbvcxr_#5q4VH#M(>!MP|ku_QG` zp**uBL&4qCHz2%`PaLS|i>HfYh{y4_lkB?>8A!Cx4|8k@khyYptM~D~?~1=9V~XUT zs!f+Z%K!LL*m1q>ySDNvu@+hsIZvK&Y)-*pUMG1D-+T7^zl%#rNJ#vUFAAtXd2}I@ zq=t!M{1GmZqqjN~N}U7O{JimI!h$8|c5dbW=Cyaqzf%rtSf1|cs6Dz?x#~*rede9> zy6?=KU@bhg{M&bi#|J{+e9;Z4%Bxw^u(5K6zOsvxnQ%eo&pDh7tGrJy+NTP2 literal 0 HcmV?d00001 diff --git a/public/character/torso/torso-4.png b/public/character/torso/torso-4.png new file mode 100644 index 0000000000000000000000000000000000000000..9ebdc7b2289c147a2050d4a789ad941fa057e09b GIT binary patch literal 419 zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f zVg?31We{epSZZGe6l5>)^mS!_#VWz1E?>mYxeF*HS>hT|;+&tGo0?a`;9QiNSdyBe zP@Y+mq2TW68xY>eC(gjY$nELk7~*mK?Ic4#W^pSTfzQaJ9Os$4as5H z8oPUacQIS6_^gyPz6v1$R;`O!#S6YZi^%?bk>&AOu4k-|n=0kMm*0pvZtGCDSn%9( z`y`=>KQk`rP7rHn(!BJtanAN%XQ!Tj+~m2%%uvv3=If95e=w%4Y!LkZA>|}6>=-;< L{an^LB{Ts5&gP|! literal 0 HcmV?d00001 From 61f98866e825047f46bcef4564f6ff5e99ee1758 Mon Sep 17 00:00:00 2001 From: pricewowen <231476659+pricewowen@users.noreply.github.com> Date: Sat, 23 May 2026 23:24:36 -0600 Subject: [PATCH 3/5] avatar to main merge I have completed the character creation screen on the avatar branch and am preparing to merge it into main. --- README.md | 130 ++++--- app/(app)/avatar/page.tsx | 16 +- app/character-creator/page.tsx | 15 +- app/layout.tsx | 7 +- app/onboarding/customize/page.tsx | 14 + app/onboarding/quiz/page.tsx | 143 +------- components/avatar/avatar-page-content.tsx | 191 +++++++++++ components/avatar/avatar-status-card.tsx | 30 +- components/character/character-creator.tsx | 117 ++++++- .../character/character-layer-preview.tsx | 17 +- components/layout/global-bottom-nav.tsx | 19 +- components/layout/logged-in-redirect.tsx | 10 +- components/layout/onboarding-auth-gate.tsx | 11 +- .../layout/onboarding-customize-auth-gate.tsx | 30 ++ .../layout/onboarding-quiz-auth-gate.tsx | 26 ++ .../onboarding/onboarding-customize-form.tsx | 94 +++++ .../onboarding/onboarding-quiz-form.tsx | 119 +++++++ components/shop/shop-content.tsx | 225 +++++++----- components/ui/toast-provider.tsx | 79 +++++ lib/auth-helpers.ts | 42 ++- lib/avatar-customization-storage.ts | 270 +++++++++++++++ lib/avatar-display.ts | 37 ++ lib/character/color-utils.ts | 10 + lib/character/variant-validation.ts | 79 +++++ lib/onboarding-status.ts | 93 +++++ lib/profile-preferences-storage.ts | 24 +- lib/routes.ts | 5 + lib/shop-catalog.ts | 87 +++++ lib/shop-storage.ts | 212 ++++++++---- package-lock.json | 321 +++++++++++++++++- package.json | 4 +- public/character/eyes/.gitkeep | 0 public/character/head/.gitkeep | 0 public/character/pants/.gitkeep | 0 public/character/torso/.gitkeep | 0 scripts/generate-character-sprites.mjs | 159 +++++++++ ...4180000_avatar_layer_colors_and_styles.sql | 67 ++++ ...nboarding_flags_and_variant_validation.sql | 62 ++++ supabase/seed.sql | 32 +- 39 files changed, 2361 insertions(+), 436 deletions(-) create mode 100644 app/onboarding/customize/page.tsx create mode 100644 components/avatar/avatar-page-content.tsx create mode 100644 components/layout/onboarding-customize-auth-gate.tsx create mode 100644 components/layout/onboarding-quiz-auth-gate.tsx create mode 100644 components/onboarding/onboarding-customize-form.tsx create mode 100644 components/onboarding/onboarding-quiz-form.tsx create mode 100644 components/ui/toast-provider.tsx create mode 100644 lib/avatar-customization-storage.ts create mode 100644 lib/avatar-display.ts create mode 100644 lib/character/variant-validation.ts create mode 100644 lib/onboarding-status.ts create mode 100644 lib/shop-catalog.ts delete mode 100644 public/character/eyes/.gitkeep delete mode 100644 public/character/head/.gitkeep delete mode 100644 public/character/pants/.gitkeep delete mode 100644 public/character/torso/.gitkeep create mode 100644 scripts/generate-character-sprites.mjs create mode 100644 supabase/migrations/20260524180000_avatar_layer_colors_and_styles.sql create mode 100644 supabase/migrations/20260524190000_onboarding_flags_and_variant_validation.sql diff --git a/README.md b/README.md index df5513c..4e8ab70 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,34 @@ # Habit Pet -Bootstrap repo for the Calgary hackathon Habit Pet project. +Calgary hackathon app for building habits while caring for a customizable pixel pet. ## Stack -- Next.js -- Supabase -- Vercel -- GitHub +- Next.js 16 (App Router) +- Supabase (auth + Postgres) +- Tailwind CSS + shadcn/ui +- Vercel deployment via GitHub Actions -## Scope of this bootstrap +## Features -This repo currently stops at: +- **Auth** — email/password sign up and login +- **Onboarding** — focus-topic quiz, then required avatar customization (DB-gated) +- **Avatar** — layered sprite pet with per-layer styles and hex colors, naming, equipped shop items +- **Habits** — catalog + custom habits, streaks, coin rewards +- **Daily quiz** — wellness sliders and journal that affect pet mood +- **Shop** — buy and equip items from `shop_items` / `user_items` (hats, glasses, backgrounds) +- **Profile** — focus topic, vibe, and reminder preferences -- Next.js app scaffold -- Supabase auth wiring from the starter -- environment variable template -- deployment-ready project structure +## User flow -This repo does not yet include MVP feature pages, avatar logic, habits, streaks, or shop logic. +1. Sign up / sign in +2. **Step 1:** onboarding quiz (focus topic only) +3. **Step 2:** avatar customization (required confirm + pet name) +4. Main app via bottom nav: Pet, Habits, Quiz, Shop, Profile + +Onboarding progress is stored in the database (`profiles.onboarding_quiz_complete`, `profiles.onboarding_complete`, `avatar_state.avatar_customized`) and mirrored in auth metadata for fast client checks. + +On the **Pet** tab, users rename their pet and switch between **Overview** and **Customize** sub-tabs. ## Local setup @@ -28,68 +38,80 @@ This repo does not yet include MVP feature pages, avatar logic, habits, streaks, npm install ``` -2. Create local environment file: +2. Generate placeholder character/shop sprites (if missing): + + ```bash + npm run generate:sprites + ``` + +3. Create local environment file: ```bash cp .env.example .env.local ``` -3. Add Supabase values to `.env.local`: +4. Add Supabase values to `.env.local`: ```env NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= ``` -4. Start the app: +5. Apply migrations locally (Supabase CLI): + + ```bash + supabase db reset + ``` + +6. Start the app: ```bash npm run dev ``` -5. Create a production build when needed: +## Avatar customization data - ```bash - npm run build - ``` +Stored in `avatar_state`: + +| Column | Purpose | +|--------|---------| +| `avatar_name` | Pet display name | +| `skin_color` | Skin layer hex color | +| `pants_style` / `pants_color` | Pants variant id + hex | +| `shoe_style` / `shoe_color` | Shoes variant id + hex | +| `torso_style` / `torso_color` | Torso variant id + hex | +| `eye_type` / `eye_color` | Eyes variant id + hex | +| `head_style` / `head_color` | Head variant id + hex | +| `avatar_customized` | True after onboarding confirm | +| `equipped_items` | Shop item ids currently equipped | + +Style columns are validated against allowed variant ids (`none`, `pants-1`, …). Shop accessories overlay equipped variants at render time without overwriting saved customization. + +Sprite assets: `public/character/{layer}/` (regenerate via `npm run generate:sprites`). + +## Shop + +- Sells character layer styles from `public/character/` (`head-1`, `pants-2`, etc.) +- Shop item `id` = variant id, `type` = layer id (`head`, `pants`, `shoes`, `torso`, `eyes`) +- Purchases unlock styles in `user_items`; owned styles appear in Customize and can be equipped from the shop +- Catalog seeded in `supabase/seed.sql` ## Supabase -Create a Supabase project, then copy the project URL and anon key from **Project Settings > API** into `.env.local` and Vercel project environment variables. +- Migrations: `supabase/migrations/` +- Seed: `supabase/seed.sql` +- Config: `supabase/config.toml` -The starter also supports Supabase's publishable key name, but this repo documents the anon key convention from the project plan. +Production deploys on push to `main` via `.github/workflows/deploy.yml` (lint, build, `supabase db push --include-seed`, Vercel). -### Database workflow +## Routes -- Schema migrations live under `supabase/migrations/`. -- Baseline catalog data lives in `supabase/seed.sql` and is safe to rerun during deploys because it uses idempotent upserts. -- Local CLI config lives in `supabase/config.toml` for `supabase start`, `supabase db reset`, and related commands. -- Production database sync runs from GitHub Actions on `main` using `supabase db push --linked --include-seed` before the app deploy relies on those changes. - -## Deploy - -This repo includes `.github/workflows/deploy.yml`, which runs on pushes to `main`. - -The workflow: - -- checks out the repo -- runs `npm ci`, `npm run lint`, and `npm run build` -- links the Supabase CLI to the production project -- applies pending database migrations and reruns `supabase/seed.sql` via `supabase db push --linked --include-seed` -- deploys the app to Vercel production only after the database sync succeeds - -### Required GitHub secrets - -- `SUPABASE_ACCESS_TOKEN` -- `SUPABASE_PROJECT_REF` -- `SUPABASE_DB_PASSWORD` -- `VERCEL_TOKEN` -- `VERCEL_ORG_ID` -- `VERCEL_PROJECT_ID` - -### Required Vercel environment variables - -- `NEXT_PUBLIC_SUPABASE_URL` -- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - -Frontend and backend are both served from this Next.js app. Supabase provides auth and database services. +| Route | Access | +|-------|--------| +| `/` | Public | +| `/auth/*` | Public | +| `/onboarding/quiz` | Authenticated, quiz incomplete | +| `/onboarding/customize` | Authenticated, quiz complete, avatar not confirmed | +| `/avatar` | Authenticated, onboarding complete | +| `/habits`, `/daily/quiz`, `/shop`, `/profile` | Authenticated app | +| `/character-creator` | Redirects to `/avatar?tab=customize` | diff --git a/app/(app)/avatar/page.tsx b/app/(app)/avatar/page.tsx index da2310a..d56b728 100644 --- a/app/(app)/avatar/page.tsx +++ b/app/(app)/avatar/page.tsx @@ -1,19 +1,17 @@ -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 { Suspense } from "react"; + +import { AvatarPageContent } from "@/components/avatar/avatar-page-content"; import { AppShell } from "@/components/layout/app-shell"; export default function AvatarPage() { return ( -
- - - -
+ Loading...

}> + +
); } diff --git a/app/character-creator/page.tsx b/app/character-creator/page.tsx index cfe3e30..9d6407e 100644 --- a/app/character-creator/page.tsx +++ b/app/character-creator/page.tsx @@ -1,14 +1,7 @@ -import { CharacterCreator } from "@/components/character/character-creator"; +import { redirect } from "next/navigation"; + +import { routes } from "@/lib/routes"; export default function CharacterCreatorPage() { - return ( -
-
-

- Character Creation -

- -
-
- ); + redirect(`${routes.avatar}?tab=customize`); } diff --git a/app/layout.tsx b/app/layout.tsx index 916f99b..0326579 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist } from "next/font/google"; import { ThemeProvider } from "next-themes"; import { GlobalBottomNav } from "@/components/layout/global-bottom-nav"; +import { ToastProvider } from "@/components/ui/toast-provider"; import "./globals.css"; const defaultUrl = process.env.VERCEL_URL @@ -35,8 +36,10 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > -
{children}
- + +
{children}
+ +
diff --git a/app/onboarding/customize/page.tsx b/app/onboarding/customize/page.tsx new file mode 100644 index 0000000..98a8e39 --- /dev/null +++ b/app/onboarding/customize/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from "react"; + +import { OnboardingCustomizeAuthGate } from "@/components/layout/onboarding-customize-auth-gate"; +import { OnboardingCustomizeForm } from "@/components/onboarding/onboarding-customize-form"; + +export default function OnboardingCustomizePage() { + return ( + }> + + + + + ); +} diff --git a/app/onboarding/quiz/page.tsx b/app/onboarding/quiz/page.tsx index f8e2997..ad8c046 100644 --- a/app/onboarding/quiz/page.tsx +++ b/app/onboarding/quiz/page.tsx @@ -1,141 +1,14 @@ -"use client"; +import { Suspense } from "react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -import { PixelAvatar } from "@/components/avatar/pixel-avatar"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { - avatarVibeOptions, - focusTopicOptions, - saveOnboardingPreferences, -} from "@/lib/profile-preferences-storage"; -import { routes } from "@/lib/routes"; - -const questions = [ - { - id: "focus", - prompt: "What habit do you most want to build first?", - options: focusTopicOptions, - }, - { - id: "avatarVibe", - prompt: "Pick a starter vibe for your pet.", - options: avatarVibeOptions, - }, -] as const; +import { OnboardingQuizAuthGate } from "@/components/layout/onboarding-quiz-auth-gate"; +import { OnboardingQuizForm } from "@/components/onboarding/onboarding-quiz-form"; export default function OnboardingQuizPage() { - const router = useRouter(); - const [step, setStep] = useState(0); - const [answers, setAnswers] = useState>({}); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - const currentQuestion = questions[step]; - const isLastStep = step === questions.length - 1; - - const handleSelect = (value: string) => { - setAnswers((current) => ({ - ...current, - [currentQuestion.id]: value, - })); - }; - - const handleContinue = async () => { - if (!answers[currentQuestion.id]) { - return; - } - - if (!isLastStep) { - setStep((current) => current + 1); - return; - } - - setIsSubmitting(true); - setError(null); - - try { - await saveOnboardingPreferences(answers.focus, answers.avatarVibe); - router.push(routes.avatar); - router.refresh(); - } catch (submitError) { - setError( - submitError instanceof Error - ? submitError.message - : "Could not save onboarding answers.", - ); - } finally { - setIsSubmitting(false); - } - }; - return ( -
-
-
- -
-

- Step {step + 1} of {questions.length} -

-

- Welcome to Habit Pet -

-
-
- - - - {currentQuestion.prompt} - - This helps personalize your pet and starting habits. - - - - {currentQuestion.options.map((option) => { - const selected = answers[currentQuestion.id] === option; - - return ( - - ); - })} - - {error ?

{error}

: null} - - -
-
-
-
+ }> + + + + ); } diff --git a/components/avatar/avatar-page-content.tsx b/components/avatar/avatar-page-content.tsx new file mode 100644 index 0000000..6e9b21a --- /dev/null +++ b/components/avatar/avatar-page-content.tsx @@ -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("home"); + const [customization, setCustomization] = useState( + null, + ); + const [avatarName, setAvatarName] = useState(""); + const [isSavingName, setIsSavingName] = useState(false); + const [isSavingAvatar, setIsSavingAvatar] = useState(false); + const [ownedVariantIds, setOwnedVariantIds] = useState([]); + + useEffect(() => { + if (searchParams.get("tab") === "customize") { + setActiveTab("customize"); + } + }, [searchParams]); + + const [loadError, setLoadError] = useState(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

{loadError}

; + } + + if (!customization) { + return ( +

Loading your pet...

+ ); + } + + return ( +
+
+ {( + [ + { id: "home", label: "Overview" }, + { id: "customize", label: "Customize" }, + ] as const + ).map((tab) => ( + + ))} +
+ +
+ +
+ setAvatarName(event.target.value)} + /> + +
+
+ + {activeTab === "home" ? ( + <> + + + + + ) : ( + + )} +
+ ); +} diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx index 17de203..7dc7c67 100644 --- a/components/avatar/avatar-status-card.tsx +++ b/components/avatar/avatar-status-card.tsx @@ -3,13 +3,11 @@ import Link from "next/link"; import { 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, @@ -21,7 +19,11 @@ import { } from "@/lib/daily-quiz-storage"; import { routes } from "@/lib/routes"; -export function AvatarStatusCard() { +type AvatarStatusCardProps = { + customization: AvatarCustomization; +}; + +export function AvatarStatusCard({ customization }: AvatarStatusCardProps) { const [condition, setCondition] = useState( defaultAvatarCondition, ); @@ -54,7 +56,21 @@ export function AvatarStatusCard() { return ( - +
+

+ {customization.name} +

+

+ {condition.mood} +

+
+
Energy {condition.energy}/{WELLNESS_SCALE_MAX} diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx index b8c37fb..c83afb1 100644 --- a/components/character/character-creator.tsx +++ b/components/character/character-creator.tsx @@ -7,26 +7,24 @@ import { CharacterColorSliders } from "@/components/character/character-color-sl 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 { - Card, - CardContent, -} from "@/components/ui/card"; +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"; -function buildInitialColors(): LayerColorState { - return { ...LAYER_DEFAULT_COLORS }; -} - function SectionLabel({ children }: { children: React.ReactNode }) { return (

@@ -35,10 +33,45 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } -export function CharacterCreator() { - const [colors, setColors] = useState(buildInitialColors); +type CharacterCreatorProps = { + initialCustomization?: AvatarCustomization; + name?: string; + onNameChange?: (name: string) => void; + showNameField?: boolean; + saveLabel?: string; + isSaving?: boolean; + ownedVariantIds?: string[]; + onSave?: (customization: AvatarCustomization) => Promise; +}; + +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( + initialCustomization?.colors ?? { ...LAYER_DEFAULT_COLORS }, + ); const [variants, setVariants] = useState( - buildDefaultVariants, + initialCustomization?.variants ?? buildDefaultVariants(), ); const [activePresetId, setActivePresetId] = useState( DEFAULT_GRAY_COLOR.id, @@ -46,10 +79,17 @@ export function CharacterCreator() { const [activeLayerId, setActiveLayerId] = useState< (typeof CHARACTER_LAYERS)[number]["id"] >("skin"); + const [saveError, setSaveError] = useState(null); const activeLayer = getLayerById(activeLayerId); const activeColor = colors[activeLayerId]; - const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : []; + const ownedSet = new Set(ownedVariantIds); + const pieceVariants = activeLayer.allowVariants + ? activeLayer.variants.filter( + (variant) => + variant.id === NONE_VARIANT_ID || ownedSet.has(variant.id), + ) + : []; const applyPreset = (preset: ColorPreset) => { setActivePresetId(preset.id); @@ -86,8 +126,45 @@ export function CharacterCreator() { })); }; + 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 (
+ {showNameField ? ( +
+ + setName(event.target.value)} + /> +
+ ) : null} + + + {onSave ? ( +
+ + {saveError ? ( +

{saveError}

+ ) : null} +
+ ) : null}
diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx index 069ff34..220e19a 100644 --- a/components/character/character-layer-preview.tsx +++ b/components/character/character-layer-preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; +import { applyEquippedItemsToVariants } from "@/lib/avatar-display"; import { drawTintedSprite, getCachedImage, @@ -17,15 +18,19 @@ import { cn } from "@/lib/utils"; 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(null); const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 }); @@ -40,8 +45,13 @@ export function CharacterLayerPreview({ const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return; + const displayVariants = applyEquippedItemsToVariants( + variants, + equippedItems, + ); + const activeLayers = CHARACTER_LAYERS.flatMap((layer) => { - const selected = getSelectedVariant(layer.id, variants[layer.id]); + const selected = getSelectedVariant(layer.id, displayVariants[layer.id]); if (!selected) return []; return [{ layer, variant: selected }]; }); @@ -90,12 +100,13 @@ export function CharacterLayerPreview({ return () => { cancelled = true; }; - }, [colors, variants, scale]); + }, [colors, variants, equippedItems, scale]); return (
diff --git a/components/layout/global-bottom-nav.tsx b/components/layout/global-bottom-nav.tsx index a380b89..2b90b15 100644 --- a/components/layout/global-bottom-nav.tsx +++ b/components/layout/global-bottom-nav.tsx @@ -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) { diff --git a/components/layout/logged-in-redirect.tsx b/components/layout/logged-in-redirect.tsx index cece124..ca07c5f 100644 --- a/components/layout/logged-in-redirect.tsx +++ b/components/layout/logged-in-redirect.tsx @@ -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; diff --git a/components/layout/onboarding-auth-gate.tsx b/components/layout/onboarding-auth-gate.tsx index 0c4c075..7501587 100644 --- a/components/layout/onboarding-auth-gate.tsx +++ b/components/layout/onboarding-auth-gate.tsx @@ -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); } diff --git a/components/layout/onboarding-customize-auth-gate.tsx b/components/layout/onboarding-customize-auth-gate.tsx new file mode 100644 index 0000000..888fead --- /dev/null +++ b/components/layout/onboarding-customize-auth-gate.tsx @@ -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; +} diff --git a/components/layout/onboarding-quiz-auth-gate.tsx b/components/layout/onboarding-quiz-auth-gate.tsx new file mode 100644 index 0000000..7f251db --- /dev/null +++ b/components/layout/onboarding-quiz-auth-gate.tsx @@ -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; +} diff --git a/components/onboarding/onboarding-customize-form.tsx b/components/onboarding/onboarding-customize-form.tsx new file mode 100644 index 0000000..0c18c44 --- /dev/null +++ b/components/onboarding/onboarding-customize-form.tsx @@ -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 ( +
+
+
+

+ Step 2 of 2 +

+

+ Create your Habit Pet +

+

+ Customize your pet's look, give them a name, and confirm before + entering the app. +

+
+ + + + Avatar customization + + Pick styles and colors for each layer. You can change these later + from the Pet tab. + + + + + + +
+
+ ); +} diff --git a/components/onboarding/onboarding-quiz-form.tsx b/components/onboarding/onboarding-quiz-form.tsx new file mode 100644 index 0000000..fc1dd1b --- /dev/null +++ b/components/onboarding/onboarding-quiz-form.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { CharacterLayerPreview } from "@/components/character/character-layer-preview"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { defaultAvatarCustomization } from "@/lib/avatar-customization-storage"; +import { + focusTopicOptions, + saveOnboardingPreferences, +} from "@/lib/profile-preferences-storage"; +import { routes } from "@/lib/routes"; + +export function OnboardingQuizForm() { + const router = useRouter(); + const [focusTopic, setFocusTopic] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+ +
+

+ Step 1 of 2 +

+

+ Welcome to Habit Pet +

+

+ Choose a focus area, then design your pet in the next step. +

+
+
+ + + + + What habit do you most want to build first? + + + This helps personalize your daily tasks. + + + + {focusTopicOptions.map((option) => { + const selected = focusTopic === option; + + return ( + + ); + })} + + {error ?

{error}

: null} + + +
+
+
+
+ ); +} diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index 5a74fdf..5691a01 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -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(null); - const [items, setItems] = useState([]); + const { toast } = useToast(); + const [items, setItems] = useState([]); const [ownedItemIds, setOwnedItemIds] = useState([]); + const [equippedItems, setEquippedItems] = useState([]); + const [coins, setCoins] = useState(null); const [error, setError] = useState(null); - const [purchaseError, setPurchaseError] = useState(null); - const [isLoading, setIsLoading] = useState(true); const [pendingItemId, setPendingItemId] = useState(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 ( <>
@@ -95,63 +131,76 @@ export function ShopContent() {
{error ?

{error}

: null} - {purchaseError ? ( -

{purchaseError}

- ) : null} - {isLoading ? ( -

Loading shop items...

- ) : items.length === 0 ? ( -

- No items in the shop yet. Add rows to the shop_items table in - Supabase. -

- ) : ( -
- {items.map((item) => { - const isOwned = ownedItemIds.includes(item.id); - const canAfford = coins !== null && coins >= item.price; - const isPending = pendingItemId === item.id; +
+ {groupedItems.map((group) => ( +
+

+ {group.label} +

+
+ {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 ( - - -
-
- {item.name} - - {formatItemType(item.type)} - -
- {item.price} pts -
-
- - - -
- ); - })} -
- )} + return ( + + +
+
+
+ +
+
+ {item.name} + {item.id} +
+
+ {item.price} pts +
+
+ + {!owned ? ( + + ) : ( + + )} + +
+ ); + })} +
+ + ))} +
); } diff --git a/components/ui/toast-provider.tsx b/components/ui/toast-provider.tsx new file mode 100644 index 0000000..ba69e0a --- /dev/null +++ b/components/ui/toast-provider.tsx @@ -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(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [messages, setMessages] = useState([]); + + 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 ( + + {children} +
+ {messages.map((message) => ( +
+ {message.title} +
+ ))} +
+
+ ); +} + +export function useToast() { + const context = useContext(ToastContext); + + if (!context) { + throw new Error("useToast must be used within ToastProvider."); + } + + return context; +} diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts index 604dabc..7148383 100644 --- a/lib/auth-helpers.ts +++ b/lib/auth-helpers.ts @@ -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); } diff --git a/lib/avatar-customization-storage.ts b/lib/avatar-customization-storage.ts new file mode 100644 index 0000000..86ab86e --- /dev/null +++ b/lib/avatar-customization-storage.ts @@ -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 { + 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; +} diff --git a/lib/avatar-display.ts b/lib/avatar-display.ts new file mode 100644 index 0000000..1795371 --- /dev/null +++ b/lib/avatar-display.ts @@ -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; +} diff --git a/lib/character/color-utils.ts b/lib/character/color-utils.ts index ae615a1..135ae95 100644 --- a/lib/character/color-utils.ts +++ b/lib/character/color-utils.ts @@ -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; diff --git a/lib/character/variant-validation.ts b/lib/character/variant-validation.ts new file mode 100644 index 0000000..6b98f81 --- /dev/null +++ b/lib/character/variant-validation.ts @@ -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, + keyof LayerVariantState +> = { + pants: "pants", + shoes: "shoes", + torso: "torso", + eyes: "eyes", + head: "head", +}; + +const VALID_VARIANT_IDS: Record = { + 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 | 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 }; diff --git a/lib/onboarding-status.ts b/lib/onboarding-status.ts new file mode 100644 index 0000000..15b6d3e --- /dev/null +++ b/lib/onboarding-status.ts @@ -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>, +): Promise { + 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 { + 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, + ); +} diff --git a/lib/profile-preferences-storage.ts b/lib/profile-preferences-storage.ts index 39f95ae..6e9e927 100644 --- a/lib/profile-preferences-storage.ts +++ b/lib/profile-preferences-storage.ts @@ -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, }, }, }); diff --git a/lib/routes.ts b/lib/routes.ts index 905eefd..f0575e1 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -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); } diff --git a/lib/shop-catalog.ts b/lib/shop-catalog.ts new file mode 100644 index 0000000..cc9d419 --- /dev/null +++ b/lib/shop-catalog.ts @@ -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; + +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([ + "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 = { + 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; +} diff --git a/lib/shop-storage.ts b/lib/shop-storage.ts index 598a805..67ea7e1 100644 --- a/lib/shop-storage.ts +++ b/lib/shop-storage.ts @@ -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 { +export async function getShopItems(): Promise { 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 { @@ -89,7 +54,28 @@ export async function getOwnedItemIds(): Promise { return (data ?? []).map((row) => row.item_id as string); } -export async function purchaseShopItem(itemId: string): Promise { +export async function getEquippedItems(): Promise { + 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 { } 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 { 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 { } 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 }; } diff --git a/package-lock.json b/package-lock.json index ba38b58..8fd3459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-slot": "^1.2.2", - "@supabase/ssr": "latest", - "@supabase/supabase-js": "latest", + "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.106.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.511.0", @@ -29,7 +29,10 @@ "autoprefixer": "^10.4.20", "eslint": "^9", "eslint-config-next": "15.3.1", + "pg": "^8.16.0", + "pngjs": "^7.0.0", "postcss": "^8", + "supabase": "^2.101.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "typescript": "^5", @@ -2952,6 +2955,130 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.101.0.tgz", + "integrity": "sha512-SoWYzu2CIE+rADWsZH6H0YZYF8nV2YMelZLbjAqzahb5RRgFOTMWGhKCUj6g4KlNvocUnU19JObUJ7oCx/9aHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.101.0.tgz", + "integrity": "sha512-rPES03BF9KYkirq3X1Yll+T+eORueRvC858Yp2+2aQMvB4qxi6k+WI+ilCQ7NGzOJ3G0jl/ZG/KsSjZIa4AJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.101.0.tgz", + "integrity": "sha512-E+hN1GRIFEi+U1Jf4Omw/gjwpSRTpJ5jai58tQana15AaXt3GCoG7z2YzWVnzD/mvAubTqA7SLHsV+A2lcdTGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.101.0.tgz", + "integrity": "sha512-B5JFFKKpJLLMwn8vWUB7j5euZPytiDOLo9WYXp0mshg45OaFKjRcDImSzBzx4k0W8MK2x75rFEURca90Ic60Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.101.0.tgz", + "integrity": "sha512-7iU9uA+8pbmW5uz+yGxU0fM83a+8SH/gtKmSSRZrOu9vIGcugg9gLbN9GlWkG5P7I7wEjnoVKb7Jzea6onSeQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.101.0.tgz", + "integrity": "sha512-pr9Ar1/aVaQVqjNSfwRgXHWJznQTIaxblGO+hKIl9k55Ajahiq8GConCwx6DjX1Wa9VW//5Dji+O0pkmFSfcHw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.101.0.tgz", + "integrity": "sha512-mSnFDhTIAe9cr1iifv+DTQzpY/0WphywED2YT3yMGeu94jjxrXrreQkywxEaeSXTnc4ys1umtnWOB1CaBlYKag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.101.0.tgz", + "integrity": "sha512-xzZpybVWq62VF102abZr1EP3BbA2bYecwYm3G2dJk+6ua7nAIodu1fiGHAvBSR0BLGQmesNb6KCneawNL3XBsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@supabase/functions-js": { "version": "2.106.1", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", @@ -8897,6 +9024,103 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8936,6 +9160,16 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9131,6 +9365,49 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9960,6 +10237,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/srvx": { "version": "0.8.9", "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", @@ -10260,6 +10547,26 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supabase": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.101.0.tgz", + "integrity": "sha512-9wqlvXGeI+VjOMUKOGEpcZzTJGLia6IOv8LWN3W2u8F6Xiv4ZqAuTuXCp3MWfkhV5x9KQaBtN5d6IiLEMWzkJA==", + "dev": true, + "license": "MIT", + "bin": { + "supabase": "dist/supabase.js" + }, + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.101.0", + "@supabase/cli-darwin-x64": "2.101.0", + "@supabase/cli-linux-arm64": "2.101.0", + "@supabase/cli-linux-arm64-musl": "2.101.0", + "@supabase/cli-linux-x64": "2.101.0", + "@supabase/cli-linux-x64-musl": "2.101.0", + "@supabase/cli-windows-arm64": "2.101.0", + "@supabase/cli-windows-x64": "2.101.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11210,6 +11517,16 @@ "node": ">= 6.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 000db8e..3ced42c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "generate:sprites": "node scripts/generate-character-sprites.mjs" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.1", @@ -31,6 +32,7 @@ "autoprefixer": "^10.4.20", "eslint": "^9", "eslint-config-next": "15.3.1", + "pngjs": "^7.0.0", "postcss": "^8", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", diff --git a/public/character/eyes/.gitkeep b/public/character/eyes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/character/head/.gitkeep b/public/character/head/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/character/pants/.gitkeep b/public/character/pants/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/character/torso/.gitkeep b/public/character/torso/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/generate-character-sprites.mjs b/scripts/generate-character-sprites.mjs new file mode 100644 index 0000000..7b19637 --- /dev/null +++ b/scripts/generate-character-sprites.mjs @@ -0,0 +1,159 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PNG } from "pngjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const publicDir = path.join(root, "public"); + +const W = 32; +const H = 48; + +const RED = [255, 0, 0]; +const DARK_RED = [51, 0, 0]; +const WHITE = [255, 255, 255]; +const MAGENTA = [255, 0, 255]; + +function createCanvas() { + return new PNG({ width: W, height: H }); +} + +function setPixel(png, x, y, [r, g, b], alpha = 255) { + if (x < 0 || y < 0 || x >= W || y >= H) { + return; + } + + const index = (W * y + x) << 2; + png.data[index] = r; + png.data[index + 1] = g; + png.data[index + 2] = b; + png.data[index + 3] = alpha; +} + +function fillRect(png, x, y, width, height, color, alpha = 255) { + for (let row = 0; row < height; row += 1) { + for (let col = 0; col < width; col += 1) { + setPixel(png, x + col, y + row, color, alpha); + } + } +} + +function writeSprite(relativePath, png) { + const filePath = path.join(publicDir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, PNG.sync.write(png)); + console.log(`wrote ${relativePath}`); +} + +function drawSkin() { + const png = createCanvas(); + fillRect(png, 11, 6, 10, 10, RED); + fillRect(png, 10, 16, 12, 14, RED); + fillRect(png, 8, 30, 6, 12, RED); + fillRect(png, 18, 30, 6, 12, RED); + fillRect(png, 6, 18, 4, 10, RED); + fillRect(png, 22, 18, 4, 10, RED); + fillRect(png, 12, 8, 8, 2, DARK_RED); + writeSprite("character/skin/skin-1.png", png); +} + +function drawPantsVariant(index, widthOffset) { + const png = createCanvas(); + fillRect(png, 8 + widthOffset, 28, 7 - widthOffset, 14, RED); + fillRect(png, 17, 28, 7 - widthOffset, 14, RED); + if (index === 2) { + fillRect(png, 8, 26, 16, 4, RED); + } + if (index === 3) { + fillRect(png, 7, 24, 18, 6, RED); + } + writeSprite(`character/pants/pants-${index}.png`, png); +} + +function drawShoesVariant(index) { + const png = createCanvas(); + fillRect(png, 6, 40, 8, 4, RED); + fillRect(png, 18, 40, 8, 4, RED); + if (index >= 2) { + fillRect(png, 5, 42, 10, 2, DARK_RED); + fillRect(png, 17, 42, 10, 2, DARK_RED); + } + if (index === 3) { + fillRect(png, 4, 38, 12, 3, RED); + fillRect(png, 16, 38, 12, 3, RED); + } + writeSprite(`character/shoes/shoes-${index}.png`, png); +} + +function drawTorsoVariant(index) { + const png = createCanvas(); + fillRect(png, 9, 16, 14, 14, RED); + if (index >= 2) { + fillRect(png, 7, 18, 3, 10, RED); + fillRect(png, 22, 18, 3, 10, RED); + } + if (index >= 3) { + fillRect(png, 11, 18, 10, 8, DARK_RED); + } + if (index === 4) { + fillRect(png, 10, 14, 12, 4, RED); + } + writeSprite(`character/torso/torso-${index}.png`, png); +} + +function drawEyesVariant(index) { + const png = createCanvas(); + fillRect(png, 12, 10, 3, 3, index === 1 ? RED : WHITE); + fillRect(png, 17, 10, 3, 3, index === 1 ? RED : WHITE); + fillRect(png, 13, 11, 1, 1, MAGENTA); + fillRect(png, 18, 11, 1, 1, MAGENTA); + if (index === 2) { + fillRect(png, 11, 9, 5, 5, WHITE); + fillRect(png, 16, 9, 5, 5, WHITE); + fillRect(png, 13, 11, 2, 2, RED); + fillRect(png, 18, 11, 2, 2, RED); + } + writeSprite(`character/eyes/eyes-${index}.png`, png); +} + +function drawHeadVariant(index) { + const png = createCanvas(); + const y = index <= 3 ? 4 : 2; + fillRect(png, 10, y, 12, index >= 5 ? 12 : 10, RED); + if (index === 2) { + fillRect(png, 8, y + 2, 3, 3, RED); + fillRect(png, 21, y + 2, 3, 3, RED); + } + if (index === 3) { + fillRect(png, 9, y - 1, 14, 3, RED); + } + if (index >= 4) { + fillRect(png, 11, y + 1, 10, 4, DARK_RED); + } + if (index >= 6) { + fillRect(png, 9, y - 2, 14, 4, RED); + } + if (index === 7) { + fillRect(png, 7, y, 4, 8, RED); + fillRect(png, 21, y, 4, 8, RED); + } + writeSprite(`character/head/head-${index}.png`, png); +} + +drawSkin(); +for (let index = 1; index <= 3; index += 1) { + drawPantsVariant(index, index - 1); + drawShoesVariant(index); +} +for (let index = 1; index <= 4; index += 1) { + drawTorsoVariant(index); +} +for (let index = 1; index <= 2; index += 1) { + drawEyesVariant(index); +} +for (let index = 1; index <= 7; index += 1) { + drawHeadVariant(index); +} + +console.log("Character sprites generated."); diff --git a/supabase/migrations/20260524180000_avatar_layer_colors_and_styles.sql b/supabase/migrations/20260524180000_avatar_layer_colors_and_styles.sql new file mode 100644 index 0000000..965aaf3 --- /dev/null +++ b/supabase/migrations/20260524180000_avatar_layer_colors_and_styles.sql @@ -0,0 +1,67 @@ +-- Layered avatar customization: hex colors per layer and sprite variant ids. + +alter table public.avatar_state + drop constraint if exists avatar_state_skin_tone_check, + drop constraint if exists avatar_state_head_style_check, + drop constraint if exists avatar_state_torso_style_check, + drop constraint if exists avatar_state_shoe_style_check, + drop constraint if exists avatar_state_eye_type_check; + +alter table public.avatar_state + rename column skin_tone to skin_color; + +alter table public.avatar_state + alter column skin_color type text using + case skin_color + when 'peach' then '#FFDFC4' + when 'tan' then '#A87149' + when 'brown' then '#8D5524' + when 'olive' then '#C68642' + when 'deep' then '#3B2212' + else coalesce(nullif(skin_color, ''), '#808080') + end; + +alter table public.avatar_state + alter column skin_color set default '#808080'; + +alter table public.avatar_state + add column if not exists pants_style text not null default 'none', + add column if not exists pants_color text not null default '#808080', + add column if not exists head_color text not null default '#808080', + add column if not exists torso_color text not null default '#808080', + add column if not exists shoe_color text not null default '#808080', + add column if not exists avatar_customized boolean not null default false; + +alter table public.avatar_state + alter column head_style set default 'none', + alter column torso_style set default 'none', + alter column shoe_style set default 'none', + alter column eye_type set default 'none', + alter column eye_color set default '#808080'; + +update public.avatar_state +set + head_style = 'none', + torso_style = 'none', + shoe_style = 'none', + eye_type = 'none', + skin_color = coalesce(nullif(skin_color, ''), '#808080'), + pants_color = '#808080', + head_color = '#808080', + torso_color = '#808080', + shoe_color = '#808080', + eye_color = coalesce(nullif(eye_color, ''), '#808080') +where avatar_customized = false; + +comment on column public.avatar_state.skin_color is 'Skin layer tint hex color.'; +comment on column public.avatar_state.pants_style is 'Pants sprite variant id or none.'; +comment on column public.avatar_state.pants_color is 'Pants layer tint hex color.'; +comment on column public.avatar_state.shoe_style is 'Shoes sprite variant id or none.'; +comment on column public.avatar_state.shoe_color is 'Shoes layer tint hex color.'; +comment on column public.avatar_state.torso_style is 'Torso sprite variant id or none.'; +comment on column public.avatar_state.torso_color is 'Torso layer tint hex color.'; +comment on column public.avatar_state.eye_type is 'Eyes sprite variant id or none.'; +comment on column public.avatar_state.eye_color is 'Eyes layer tint hex color.'; +comment on column public.avatar_state.head_style is 'Head sprite variant id or none.'; +comment on column public.avatar_state.head_color is 'Head layer tint hex color.'; +comment on column public.avatar_state.avatar_customized is 'True after the user confirms avatar setup during onboarding.'; diff --git a/supabase/migrations/20260524190000_onboarding_flags_and_variant_validation.sql b/supabase/migrations/20260524190000_onboarding_flags_and_variant_validation.sql new file mode 100644 index 0000000..107abcb --- /dev/null +++ b/supabase/migrations/20260524190000_onboarding_flags_and_variant_validation.sql @@ -0,0 +1,62 @@ +-- Onboarding flags on profiles, equipped shop items, variant id validation. + +alter table public.profiles + add column if not exists onboarding_quiz_complete boolean not null default false; + +comment on column public.profiles.onboarding_quiz_complete is + 'True after the user completes the focus-topic onboarding quiz.'; +comment on column public.profiles.onboarding_complete is + 'True after the user confirms avatar customization and enters the app.'; + +alter table public.avatar_state + add column if not exists equipped_items text[] not null default '{}'; + +comment on column public.avatar_state.equipped_items is + 'Shop item ids currently equipped on the avatar (accessories and backgrounds).'; + +alter table public.avatar_state + drop constraint if exists avatar_state_pants_style_check, + drop constraint if exists avatar_state_shoe_style_check, + drop constraint if exists avatar_state_torso_style_check, + drop constraint if exists avatar_state_eye_type_check, + drop constraint if exists avatar_state_head_style_check; + +alter table public.avatar_state + add constraint avatar_state_pants_style_check + check (pants_style in ('none', 'pants-1', 'pants-2', 'pants-3')), + add constraint avatar_state_shoe_style_check + check (shoe_style in ('none', 'shoes-1', 'shoes-2', 'shoes-3')), + add constraint avatar_state_torso_style_check + check (torso_style in ('none', 'torso-1', 'torso-2', 'torso-3', 'torso-4')), + add constraint avatar_state_eye_type_check + check (eye_type in ('none', 'eyes-1', 'eyes-2')), + add constraint avatar_state_head_style_check + check (head_style in ('none', 'head-1', 'head-2', 'head-3', 'head-4', 'head-5', 'head-6', 'head-7')); + +alter table public.avatar_state + drop constraint if exists avatar_state_skin_color_check, + drop constraint if exists avatar_state_pants_color_check, + drop constraint if exists avatar_state_shoe_color_check, + drop constraint if exists avatar_state_torso_color_check, + drop constraint if exists avatar_state_eye_color_check, + drop constraint if exists avatar_state_head_color_check; + +alter table public.avatar_state + add constraint avatar_state_skin_color_check + check (skin_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_pants_color_check + check (pants_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_shoe_color_check + check (shoe_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_torso_color_check + check (torso_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_eye_color_check + check (eye_color ~ '^#[0-9A-Fa-f]{6}$'), + add constraint avatar_state_head_color_check + check (head_color ~ '^#[0-9A-Fa-f]{6}$'); + +-- Backfill quiz completion for users who already finished onboarding. +update public.profiles +set onboarding_quiz_complete = true +where onboarding_complete = true + and onboarding_quiz_complete = false; diff --git a/supabase/seed.sql b/supabase/seed.sql index 66eb8bb..dacd10e 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,8 +1,32 @@ -insert into shop_items (id, name, type, price, image_path) +-- Character layer styles sold in the shop (id = variant id, type = layer id). + +delete from public.user_items +where item_id in ('hat_blue', 'glasses_round', 'room_sunset'); + +delete from public.shop_items +where id in ('hat_blue', 'glasses_round', 'room_sunset'); + +insert into public.shop_items (id, name, type, price, image_path) values - ('hat_blue', 'Blue Hat', 'accessory', 20, '/items/hat-blue.png'), - ('glasses_round', 'Round Glasses', 'accessory', 30, '/items/glasses-round.png'), - ('room_sunset', 'Sunset Room', 'background', 40, '/backgrounds/room-sunset.png') + ('pants-1', 'Pants 1', 'pants', 20, '/character/pants/pants-1.png'), + ('pants-2', 'Pants 2', 'pants', 25, '/character/pants/pants-2.png'), + ('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), + ('shoes-1', 'Shoes 1', 'shoes', 20, '/character/shoes/shoes-1.png'), + ('shoes-2', 'Shoes 2', 'shoes', 25, '/character/shoes/shoes-2.png'), + ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), + ('torso-1', 'Torso 1', 'torso', 25, '/character/torso/torso-1.png'), + ('torso-2', 'Torso 2', 'torso', 30, '/character/torso/torso-2.png'), + ('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), + ('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), + ('eyes-1', 'Eyes 1', 'eyes', 15, '/character/eyes/eyes-1.png'), + ('eyes-2', 'Eyes 2', 'eyes', 20, '/character/eyes/eyes-2.png'), + ('head-1', 'Head 1', 'head', 30, '/character/head/head-1.png'), + ('head-2', 'Head 2', 'head', 35, '/character/head/head-2.png'), + ('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), + ('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), + ('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'), + ('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'), + ('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png') on conflict (id) do update set name = excluded.name, From 25e9960d01c5ca591c4f3c36e0b06b005457d0e1 Mon Sep 17 00:00:00 2001 From: pricewowen <231476659+pricewowen@users.noreply.github.com> Date: Sat, 23 May 2026 23:54:36 -0600 Subject: [PATCH 4/5] Removed the auto generated sprites --- README.md | 16 +- .../character/character-layer-preview.tsx | 117 ++++--- components/character/tinted-sprite-icon.tsx | 44 +-- .../onboarding/onboarding-quiz-form.tsx | 31 +- package-lock.json | 321 +----------------- package.json | 4 +- scripts/generate-character-sprites.mjs | 159 --------- 7 files changed, 110 insertions(+), 582 deletions(-) delete mode 100644 scripts/generate-character-sprites.mjs diff --git a/README.md b/README.md index 4e8ab70..54836dd 100644 --- a/README.md +++ b/README.md @@ -38,32 +38,26 @@ On the **Pet** tab, users rename their pet and switch between **Overview** and * npm install ``` -2. Generate placeholder character/shop sprites (if missing): - - ```bash - npm run generate:sprites - ``` - -3. Create local environment file: +2. Create local environment file: ```bash cp .env.example .env.local ``` -4. Add Supabase values to `.env.local`: +3. Add Supabase values to `.env.local`: ```env NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= ``` -5. Apply migrations locally (Supabase CLI): +4. Apply migrations locally (Supabase CLI): ```bash supabase db reset ``` -6. Start the app: +5. Start the app: ```bash npm run dev @@ -87,7 +81,7 @@ Stored in `avatar_state`: Style columns are validated against allowed variant ids (`none`, `pants-1`, …). Shop accessories overlay equipped variants at render time without overwriting saved customization. -Sprite assets: `public/character/{layer}/` (regenerate via `npm run generate:sprites`). +Sprite assets are added manually under `public/character/{layer}/` (e.g. `public/character/head/head-1.png`). Paths must match the variant ids in `lib/character/presets.ts` and `supabase/seed.sql`. ## Shop diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx index 220e19a..ee415d4 100644 --- a/components/character/character-layer-preview.tsx +++ b/components/character/character-layer-preview.tsx @@ -15,6 +15,8 @@ import { import type { LayerColorState, LayerVariantState } from "@/lib/character/types"; import { cn } from "@/lib/utils"; +const EMPTY_EQUIPPED_ITEMS: string[] = []; + type CharacterLayerPreviewProps = { colors: LayerColorState; variants: LayerVariantState; @@ -27,16 +29,21 @@ type CharacterLayerPreviewProps = { export function CharacterLayerPreview({ colors, variants, - equippedItems = [], + equippedItems, scale = 10, className, compact = false, }: CharacterLayerPreviewProps) { const canvasRef = useRef(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; @@ -45,62 +52,74 @@ export function CharacterLayerPreview({ const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return; - const displayVariants = applyEquippedItemsToVariants( - variants, - equippedItems, - ); - - 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; - setDisplaySize({ width: width * scale, height: height * scale }); - 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, + 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. } } - render(); + void render(); return () => { cancelled = true; }; - }, [colors, variants, equippedItems, scale]); + }, [colors, variants, equippedKey, scale]); return (
{ cancelled = true; }; - }, [src, color, skinColor]); + }, [src, color.h, color.s, color.l, skinColor?.h, skinColor?.s, skinColor?.l]); return (
-
- -
-

- Step 1 of 2 -

-

- Welcome to Habit Pet -

-

- Choose a focus area, then design your pet in the next step. -

-
+
+

+ Step 1 of 2 +

+

+ Welcome to Habit Pet +

+

+ Choose a focus area, then design your pet in the next step. +

diff --git a/package-lock.json b/package-lock.json index 8fd3459..ba38b58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-slot": "^1.2.2", - "@supabase/ssr": "^0.10.3", - "@supabase/supabase-js": "^2.106.1", + "@supabase/ssr": "latest", + "@supabase/supabase-js": "latest", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.511.0", @@ -29,10 +29,7 @@ "autoprefixer": "^10.4.20", "eslint": "^9", "eslint-config-next": "15.3.1", - "pg": "^8.16.0", - "pngjs": "^7.0.0", "postcss": "^8", - "supabase": "^2.101.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "typescript": "^5", @@ -2955,130 +2952,6 @@ "node": ">=20.0.0" } }, - "node_modules/@supabase/cli-darwin-arm64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.101.0.tgz", - "integrity": "sha512-SoWYzu2CIE+rADWsZH6H0YZYF8nV2YMelZLbjAqzahb5RRgFOTMWGhKCUj6g4KlNvocUnU19JObUJ7oCx/9aHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@supabase/cli-darwin-x64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.101.0.tgz", - "integrity": "sha512-rPES03BF9KYkirq3X1Yll+T+eORueRvC858Yp2+2aQMvB4qxi6k+WI+ilCQ7NGzOJ3G0jl/ZG/KsSjZIa4AJ6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@supabase/cli-linux-arm64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.101.0.tgz", - "integrity": "sha512-E+hN1GRIFEi+U1Jf4Omw/gjwpSRTpJ5jai58tQana15AaXt3GCoG7z2YzWVnzD/mvAubTqA7SLHsV+A2lcdTGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-linux-arm64-musl": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.101.0.tgz", - "integrity": "sha512-B5JFFKKpJLLMwn8vWUB7j5euZPytiDOLo9WYXp0mshg45OaFKjRcDImSzBzx4k0W8MK2x75rFEURca90Ic60Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-linux-x64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.101.0.tgz", - "integrity": "sha512-7iU9uA+8pbmW5uz+yGxU0fM83a+8SH/gtKmSSRZrOu9vIGcugg9gLbN9GlWkG5P7I7wEjnoVKb7Jzea6onSeQA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-linux-x64-musl": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.101.0.tgz", - "integrity": "sha512-pr9Ar1/aVaQVqjNSfwRgXHWJznQTIaxblGO+hKIl9k55Ajahiq8GConCwx6DjX1Wa9VW//5Dji+O0pkmFSfcHw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-windows-arm64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.101.0.tgz", - "integrity": "sha512-mSnFDhTIAe9cr1iifv+DTQzpY/0WphywED2YT3yMGeu94jjxrXrreQkywxEaeSXTnc4ys1umtnWOB1CaBlYKag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@supabase/cli-windows-x64": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.101.0.tgz", - "integrity": "sha512-xzZpybVWq62VF102abZr1EP3BbA2bYecwYm3G2dJk+6ua7nAIodu1fiGHAvBSR0BLGQmesNb6KCneawNL3XBsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@supabase/functions-js": { "version": "2.106.1", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", @@ -9024,103 +8897,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pg": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", - "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.13.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.14.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", - "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", - "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9160,16 +8936,6 @@ "node": ">= 6" } }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.19.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9365,49 +9131,6 @@ "dev": true, "license": "MIT" }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10237,16 +9960,6 @@ "node": ">=0.10.0" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/srvx": { "version": "0.8.9", "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", @@ -10547,26 +10260,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/supabase": { - "version": "2.101.0", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.101.0.tgz", - "integrity": "sha512-9wqlvXGeI+VjOMUKOGEpcZzTJGLia6IOv8LWN3W2u8F6Xiv4ZqAuTuXCp3MWfkhV5x9KQaBtN5d6IiLEMWzkJA==", - "dev": true, - "license": "MIT", - "bin": { - "supabase": "dist/supabase.js" - }, - "optionalDependencies": { - "@supabase/cli-darwin-arm64": "2.101.0", - "@supabase/cli-darwin-x64": "2.101.0", - "@supabase/cli-linux-arm64": "2.101.0", - "@supabase/cli-linux-arm64-musl": "2.101.0", - "@supabase/cli-linux-x64": "2.101.0", - "@supabase/cli-linux-x64-musl": "2.101.0", - "@supabase/cli-windows-arm64": "2.101.0", - "@supabase/cli-windows-x64": "2.101.0" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11517,16 +11210,6 @@ "node": ">= 6.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 3ced42c..000db8e 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint .", - "generate:sprites": "node scripts/generate-character-sprites.mjs" + "lint": "eslint ." }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.1", @@ -32,7 +31,6 @@ "autoprefixer": "^10.4.20", "eslint": "^9", "eslint-config-next": "15.3.1", - "pngjs": "^7.0.0", "postcss": "^8", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", diff --git a/scripts/generate-character-sprites.mjs b/scripts/generate-character-sprites.mjs deleted file mode 100644 index 7b19637..0000000 --- a/scripts/generate-character-sprites.mjs +++ /dev/null @@ -1,159 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { PNG } from "pngjs"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const root = path.resolve(__dirname, ".."); -const publicDir = path.join(root, "public"); - -const W = 32; -const H = 48; - -const RED = [255, 0, 0]; -const DARK_RED = [51, 0, 0]; -const WHITE = [255, 255, 255]; -const MAGENTA = [255, 0, 255]; - -function createCanvas() { - return new PNG({ width: W, height: H }); -} - -function setPixel(png, x, y, [r, g, b], alpha = 255) { - if (x < 0 || y < 0 || x >= W || y >= H) { - return; - } - - const index = (W * y + x) << 2; - png.data[index] = r; - png.data[index + 1] = g; - png.data[index + 2] = b; - png.data[index + 3] = alpha; -} - -function fillRect(png, x, y, width, height, color, alpha = 255) { - for (let row = 0; row < height; row += 1) { - for (let col = 0; col < width; col += 1) { - setPixel(png, x + col, y + row, color, alpha); - } - } -} - -function writeSprite(relativePath, png) { - const filePath = path.join(publicDir, relativePath); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, PNG.sync.write(png)); - console.log(`wrote ${relativePath}`); -} - -function drawSkin() { - const png = createCanvas(); - fillRect(png, 11, 6, 10, 10, RED); - fillRect(png, 10, 16, 12, 14, RED); - fillRect(png, 8, 30, 6, 12, RED); - fillRect(png, 18, 30, 6, 12, RED); - fillRect(png, 6, 18, 4, 10, RED); - fillRect(png, 22, 18, 4, 10, RED); - fillRect(png, 12, 8, 8, 2, DARK_RED); - writeSprite("character/skin/skin-1.png", png); -} - -function drawPantsVariant(index, widthOffset) { - const png = createCanvas(); - fillRect(png, 8 + widthOffset, 28, 7 - widthOffset, 14, RED); - fillRect(png, 17, 28, 7 - widthOffset, 14, RED); - if (index === 2) { - fillRect(png, 8, 26, 16, 4, RED); - } - if (index === 3) { - fillRect(png, 7, 24, 18, 6, RED); - } - writeSprite(`character/pants/pants-${index}.png`, png); -} - -function drawShoesVariant(index) { - const png = createCanvas(); - fillRect(png, 6, 40, 8, 4, RED); - fillRect(png, 18, 40, 8, 4, RED); - if (index >= 2) { - fillRect(png, 5, 42, 10, 2, DARK_RED); - fillRect(png, 17, 42, 10, 2, DARK_RED); - } - if (index === 3) { - fillRect(png, 4, 38, 12, 3, RED); - fillRect(png, 16, 38, 12, 3, RED); - } - writeSprite(`character/shoes/shoes-${index}.png`, png); -} - -function drawTorsoVariant(index) { - const png = createCanvas(); - fillRect(png, 9, 16, 14, 14, RED); - if (index >= 2) { - fillRect(png, 7, 18, 3, 10, RED); - fillRect(png, 22, 18, 3, 10, RED); - } - if (index >= 3) { - fillRect(png, 11, 18, 10, 8, DARK_RED); - } - if (index === 4) { - fillRect(png, 10, 14, 12, 4, RED); - } - writeSprite(`character/torso/torso-${index}.png`, png); -} - -function drawEyesVariant(index) { - const png = createCanvas(); - fillRect(png, 12, 10, 3, 3, index === 1 ? RED : WHITE); - fillRect(png, 17, 10, 3, 3, index === 1 ? RED : WHITE); - fillRect(png, 13, 11, 1, 1, MAGENTA); - fillRect(png, 18, 11, 1, 1, MAGENTA); - if (index === 2) { - fillRect(png, 11, 9, 5, 5, WHITE); - fillRect(png, 16, 9, 5, 5, WHITE); - fillRect(png, 13, 11, 2, 2, RED); - fillRect(png, 18, 11, 2, 2, RED); - } - writeSprite(`character/eyes/eyes-${index}.png`, png); -} - -function drawHeadVariant(index) { - const png = createCanvas(); - const y = index <= 3 ? 4 : 2; - fillRect(png, 10, y, 12, index >= 5 ? 12 : 10, RED); - if (index === 2) { - fillRect(png, 8, y + 2, 3, 3, RED); - fillRect(png, 21, y + 2, 3, 3, RED); - } - if (index === 3) { - fillRect(png, 9, y - 1, 14, 3, RED); - } - if (index >= 4) { - fillRect(png, 11, y + 1, 10, 4, DARK_RED); - } - if (index >= 6) { - fillRect(png, 9, y - 2, 14, 4, RED); - } - if (index === 7) { - fillRect(png, 7, y, 4, 8, RED); - fillRect(png, 21, y, 4, 8, RED); - } - writeSprite(`character/head/head-${index}.png`, png); -} - -drawSkin(); -for (let index = 1; index <= 3; index += 1) { - drawPantsVariant(index, index - 1); - drawShoesVariant(index); -} -for (let index = 1; index <= 4; index += 1) { - drawTorsoVariant(index); -} -for (let index = 1; index <= 2; index += 1) { - drawEyesVariant(index); -} -for (let index = 1; index <= 7; index += 1) { - drawHeadVariant(index); -} - -console.log("Character sprites generated."); From 53aa559c7b26ed1322eaa4823eafc6d44cd6f7cf Mon Sep 17 00:00:00 2001 From: pricewowen <231476659+pricewowen@users.noreply.github.com> Date: Sun, 24 May 2026 00:04:36 -0600 Subject: [PATCH 5/5] Removed bypass onboarding process --- components/character/character-creator.tsx | 18 +++++++-- .../character/character-piece-selector.tsx | 34 ++++++++++++---- lib/character/variant-access.ts | 39 +++++++++++++++++++ lib/shop-catalog.ts | 6 ++- supabase/seed.sql | 26 +++++++------ 5 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 lib/character/variant-access.ts diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx index c83afb1..9e64cea 100644 --- a/components/character/character-creator.tsx +++ b/components/character/character-creator.tsx @@ -24,6 +24,7 @@ import { 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 ( @@ -84,12 +85,16 @@ export function CharacterCreator({ const activeLayer = getLayerById(activeLayerId); const activeColor = colors[activeLayerId]; const ownedSet = new Set(ownedVariantIds); - const pieceVariants = activeLayer.allowVariants - ? activeLayer.variants.filter( + const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : []; + const lockedVariantIds = new Set( + pieceVariants + .filter( (variant) => - variant.id === NONE_VARIANT_ID || ownedSet.has(variant.id), + variant.id !== NONE_VARIANT_ID && + !isVariantUnlocked(variant.id, ownedSet), ) - : []; + .map((variant) => variant.id), + ); const applyPreset = (preset: ColorPreset) => { setActivePresetId(preset.id); @@ -120,6 +125,10 @@ export function CharacterCreator({ }; const handleVariantChange = (variantId: string) => { + if (!isVariantUnlocked(variantId, ownedSet)) { + return; + } + setVariants((current) => ({ ...current, [activeLayerId]: variantId, @@ -189,6 +198,7 @@ export function CharacterCreator({
; activeId: string; color: HSL; skinColor?: HSL; @@ -19,6 +20,7 @@ type CharacterPieceSelectorProps = { export function CharacterPieceSelector({ variants, + lockedVariantIds, activeId, color, skinColor, @@ -66,19 +68,29 @@ export function CharacterPieceSelector({ ); } + const locked = lockedVariantIds?.has(variant.id) ?? false; + return ( ); })} diff --git a/lib/character/variant-access.ts b/lib/character/variant-access.ts new file mode 100644 index 0000000..016e716 --- /dev/null +++ b/lib/character/variant-access.ts @@ -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 | 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); +} diff --git a/lib/shop-catalog.ts b/lib/shop-catalog.ts index cc9d419..e0b6b67 100644 --- a/lib/shop-catalog.ts +++ b/lib/shop-catalog.ts @@ -1,3 +1,4 @@ +import { isShopOnlyStyleVariant } from "@/lib/character/variant-access"; import type { CharacterLayerId } from "@/lib/character/presets"; import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets"; @@ -57,7 +58,10 @@ export function getShopCatalogFromPresets(): ShopItemRecord[] { } return layer.variants - .filter((variant) => variant.id !== NONE_VARIANT_ID) + .filter( + (variant) => + variant.id !== NONE_VARIANT_ID && isShopOnlyStyleVariant(variant.id), + ) .map((variant) => ({ id: variant.id, name: variant.label, diff --git a/supabase/seed.sql b/supabase/seed.sql index dacd10e..8abfbe3 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -4,24 +4,28 @@ delete from public.user_items where item_id in ('hat_blue', 'glasses_round', 'room_sunset'); delete from public.shop_items -where id in ('hat_blue', 'glasses_round', 'room_sunset'); +where id in ( + 'hat_blue', + 'glasses_round', + 'room_sunset', + 'pants-1', + 'pants-2', + 'shoes-1', + 'shoes-2', + 'torso-1', + 'torso-2', + 'eyes-1', + 'eyes-2', + 'head-1', + 'head-2' +); insert into public.shop_items (id, name, type, price, image_path) values - ('pants-1', 'Pants 1', 'pants', 20, '/character/pants/pants-1.png'), - ('pants-2', 'Pants 2', 'pants', 25, '/character/pants/pants-2.png'), ('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'), - ('shoes-1', 'Shoes 1', 'shoes', 20, '/character/shoes/shoes-1.png'), - ('shoes-2', 'Shoes 2', 'shoes', 25, '/character/shoes/shoes-2.png'), ('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'), - ('torso-1', 'Torso 1', 'torso', 25, '/character/torso/torso-1.png'), - ('torso-2', 'Torso 2', 'torso', 30, '/character/torso/torso-2.png'), ('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'), ('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'), - ('eyes-1', 'Eyes 1', 'eyes', 15, '/character/eyes/eyes-1.png'), - ('eyes-2', 'Eyes 2', 'eyes', 20, '/character/eyes/eyes-2.png'), - ('head-1', 'Head 1', 'head', 30, '/character/head/head-1.png'), - ('head-2', 'Head 2', 'head', 35, '/character/head/head-2.png'), ('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'), ('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'), ('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'),