- {groupedItems.map((group) => (
+ {roomItems.length > 0 ? (
+
))}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index f795980..5542588 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
- "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ "inline-flex items-center rounded-none border-2 border-border px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-[var(--retro-shadow-sm)]",
{
variants: {
variant: {
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index d09a695..8504581 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -5,26 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap border-2 border-border text-xs font-normal transition-[transform,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
- "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ "bg-primary text-primary-foreground shadow-[var(--retro-shadow-sm)] hover:-translate-x-px hover:-translate-y-px hover:shadow-[3px_3px_0_0_hsl(280_45%_28%)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-none",
destructive:
- "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ "bg-destructive text-destructive-foreground shadow-[var(--retro-shadow-sm)]",
outline:
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ "border-border bg-background shadow-[var(--retro-shadow-sm)] hover:bg-muted hover:text-foreground",
secondary:
- "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
- ghost: "hover:bg-accent hover:text-accent-foreground",
- link: "text-primary underline-offset-4 hover:underline",
+ "bg-secondary text-secondary-foreground shadow-[var(--retro-shadow-sm)]",
+ ghost: "border-transparent shadow-none hover:bg-muted hover:text-foreground",
+ link: "border-transparent text-secondary underline-offset-4 shadow-none hover:underline",
},
size: {
- default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
- lg: "h-10 rounded-md px-8",
- icon: "h-9 w-9",
+ default: "h-10 px-4 py-2",
+ sm: "h-8 px-3 text-[10px]",
+ lg: "h-11 px-8",
+ icon: "h-10 w-10",
},
},
defaultVariants: {
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
index 1cc0c12..b09c29c 100644
--- a/components/ui/card.tsx
+++ b/components/ui/card.tsx
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
>(
{messages.map((message) => (
{
}
const supabase = createClient();
- const { data, error } = await supabase
+ let { data, error } = await supabase
.from("avatar_state")
.select(avatarSelectFields)
.eq("user_id", userId)
.maybeSingle();
- if (error) {
+ if (error && isMissingRoomBackgroundColumn(error.message)) {
+ const legacy = await supabase
+ .from("avatar_state")
+ .select(avatarSelectFieldsLegacy)
+ .eq("user_id", userId)
+ .maybeSingle();
+
+ if (legacy.error) {
+ throw new Error(legacy.error.message);
+ }
+
+ data = legacy.data
+ ? ({ ...legacy.data, room_background: null } as AvatarStateRow)
+ : null;
+ error = null;
+ } else if (error) {
throw new Error(error.message);
}
@@ -209,11 +241,22 @@ export async function saveAvatarCustomization(
const supabase = createClient();
const payload = mapAvatarCustomizationToRow(customization);
- const { error } = await supabase
+ let { error } = await supabase
.from("avatar_state")
.update(payload)
.eq("user_id", userId);
+ if (error && isMissingRoomBackgroundColumn(error.message)) {
+ const { room_background, ...legacyPayload } = payload;
+ void room_background;
+ const retry = await supabase
+ .from("avatar_state")
+ .update(legacyPayload)
+ .eq("user_id", userId);
+
+ error = retry.error;
+ }
+
if (error) {
throw new Error(error.message);
}
diff --git a/lib/character/presets.ts b/lib/character/presets.ts
index 0889070..bae21c3 100644
--- a/lib/character/presets.ts
+++ b/lib/character/presets.ts
@@ -152,6 +152,16 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [
},
];
+export type CharacterCreatorTabId = CharacterLayerId | "room";
+
+export const CHARACTER_CUSTOMIZATION_TABS: {
+ id: CharacterCreatorTabId;
+ label: string;
+}[] = [
+ ...CHARACTER_LAYERS.map((layer) => ({ id: layer.id, label: layer.label })),
+ { id: "room", label: "Room" },
+];
+
export const LAYER_DEFAULT_COLORS: Record = {
skin: DEFAULT_GRAY_COLOR.hsl,
pants: DEFAULT_GRAY_COLOR.hsl,
diff --git a/lib/room-backgrounds.ts b/lib/room-backgrounds.ts
new file mode 100644
index 0000000..e3328dd
--- /dev/null
+++ b/lib/room-backgrounds.ts
@@ -0,0 +1,245 @@
+export type RoomBackgroundId =
+
+ | "room-day"
+
+ | "room-dusk"
+
+ | "room-night"
+
+ | "room-forest";
+
+
+
+export type RoomBackgroundLayer = {
+
+ id: string;
+
+ className: string;
+
+ speed: number;
+
+};
+
+
+
+export type RoomBackground = {
+
+ id: RoomBackgroundId;
+
+ name: string;
+
+ free: boolean;
+
+ previewClassName: string;
+
+ layers: RoomBackgroundLayer[];
+
+};
+
+
+
+export const DEFAULT_ROOM_BACKGROUND: RoomBackgroundId = "room-day";
+
+
+
+export const ROOM_BACKGROUNDS: RoomBackground[] = [
+
+ {
+
+ id: "room-day",
+
+ name: "Sunny Field",
+
+ free: true,
+
+ previewClassName: "bg-gradient-to-b from-sky-400 via-sky-300 to-lime-400",
+
+ layers: [
+
+ { id: "sky", className: "bg-gradient-to-b from-sky-500 via-sky-300 to-sky-200", speed: 0.15 },
+
+ { id: "clouds", className: "bg-[radial-gradient(circle_at_20%_30%,rgba(255,255,255,0.85)_0%,transparent_35%),radial-gradient(circle_at_70%_20%,rgba(255,255,255,0.7)_0%,transparent_30%)]", speed: 0.35 },
+
+ { id: "hills", className: "bg-[linear-gradient(to_top,#3d8b37_0%,#3d8b37_40%,transparent_40%),radial-gradient(ellipse_120%_80%_at_50%_100%,#2f6f2c_0%,transparent_70%)]", speed: 0.55 },
+
+ { id: "grass", className: "bg-[repeating-linear-gradient(90deg,rgba(0,0,0,0.06)_0_2px,transparent_2px_6px)] opacity-40", speed: 0.75 },
+
+ ],
+
+ },
+
+ {
+
+ id: "room-dusk",
+
+ name: "Golden Dusk",
+
+ free: true,
+
+ previewClassName: "bg-gradient-to-b from-orange-400 via-pink-400 to-indigo-700",
+
+ layers: [
+
+ { id: "sky", className: "bg-gradient-to-b from-orange-500 via-rose-400 to-indigo-800", speed: 0.12 },
+
+ { id: "sun", className: "bg-[radial-gradient(circle_at_50%_85%,rgba(255,220,120,0.95)_0%,rgba(255,160,80,0.35)_18%,transparent_45%)]", speed: 0.25 },
+
+ { id: "hills", className: "bg-[radial-gradient(ellipse_130%_70%_at_50%_100%,#1f2937_0%,transparent_72%)]", speed: 0.5 },
+
+ { id: "stars", className: "bg-[radial-gradient(circle,#fff_1px,transparent_1px)] bg-[length:18px_18px] opacity-20", speed: 0.65 },
+
+ ],
+
+ },
+
+ {
+
+ id: "room-night",
+
+ name: "Starlit Night",
+
+ free: false,
+
+ previewClassName: "bg-gradient-to-b from-indigo-950 via-violet-900 to-slate-900",
+
+ layers: [
+
+ { id: "sky", className: "bg-gradient-to-b from-indigo-950 via-violet-900 to-slate-900", speed: 0.1 },
+
+ { id: "stars", className: "bg-[radial-gradient(circle,#fff_1.5px,transparent_1.5px)] bg-[length:22px_22px] opacity-80", speed: 0.3 },
+
+ { id: "moon", className: "bg-[radial-gradient(circle_at_78%_18%,rgba(255,255,220,0.95)_0%,rgba(255,255,220,0.15)_8%,transparent_20%)]", speed: 0.2 },
+
+ { id: "ground", className: "bg-[linear-gradient(to_top,#111827_0%,#111827_35%,transparent_35%)]", speed: 0.6 },
+
+ ],
+
+ },
+
+ {
+
+ id: "room-forest",
+
+ name: "Mystic Forest",
+
+ free: false,
+
+ previewClassName: "bg-gradient-to-b from-emerald-900 via-green-900 to-lime-950",
+
+ layers: [
+
+ { id: "sky", className: "bg-gradient-to-b from-emerald-950 via-green-900 to-lime-950", speed: 0.14 },
+
+ { id: "mist", className: "bg-[radial-gradient(circle_at_30%_60%,rgba(180,255,200,0.25)_0%,transparent_45%),radial-gradient(circle_at_70%_40%,rgba(180,255,200,0.18)_0%,transparent_40%)]", speed: 0.4 },
+
+ { id: "trees", className: "bg-[repeating-linear-gradient(90deg,rgba(0,0,0,0.35)_0_14px,transparent_14px_28px)] opacity-50", speed: 0.55 },
+
+ { id: "floor", className: "bg-[linear-gradient(to_top,#14532d_0%,#14532d_30%,transparent_30%)]", speed: 0.7 },
+
+ ],
+
+ },
+
+];
+
+
+
+const ROOM_MAP = new Map(ROOM_BACKGROUNDS.map((room) => [room.id, room]));
+
+
+
+const LEGACY_ROOM_IDS = new Set(["room-space", "room-neon"]);
+
+
+
+export function getRoomBackground(id: string | null | undefined): RoomBackground {
+
+ if (id && LEGACY_ROOM_IDS.has(id)) {
+
+ return ROOM_MAP.get(DEFAULT_ROOM_BACKGROUND)!;
+
+ }
+
+
+
+ return ROOM_MAP.get((id as RoomBackgroundId) ?? DEFAULT_ROOM_BACKGROUND) ?? ROOM_MAP.get(DEFAULT_ROOM_BACKGROUND)!;
+
+}
+
+
+
+export function isValidRoomBackgroundId(id: string): id is RoomBackgroundId {
+
+ return ROOM_MAP.has(id as RoomBackgroundId);
+
+}
+
+
+
+export function normalizeRoomBackgroundId(id: string | null | undefined): RoomBackgroundId {
+
+ if (id && LEGACY_ROOM_IDS.has(id)) {
+
+ return DEFAULT_ROOM_BACKGROUND;
+
+ }
+
+
+
+ if (id && isValidRoomBackgroundId(id)) {
+
+ return id;
+
+ }
+
+
+
+ return DEFAULT_ROOM_BACKGROUND;
+
+}
+
+
+
+export function isRoomBackgroundUnlocked(
+
+ roomId: RoomBackgroundId,
+
+ ownedItemIds: readonly string[],
+
+): boolean {
+
+ const room = getRoomBackground(roomId);
+
+ if (room.free) {
+
+ return true;
+
+ }
+
+
+
+ return ownedItemIds.includes(roomId);
+
+}
+
+
+
+export function getShopRoomBackgrounds() {
+
+ return ROOM_BACKGROUNDS.filter((room) => !room.free).map((room) => ({
+
+ id: room.id,
+
+ name: room.name,
+
+ type: "room" as const,
+
+ price: room.id === "room-night" ? 40 : 50,
+
+ image_path: "",
+
+ }));
+
+}
+
+
diff --git a/lib/routes.ts b/lib/routes.ts
index f0575e1..114f513 100644
--- a/lib/routes.ts
+++ b/lib/routes.ts
@@ -6,6 +6,7 @@ export const routes = {
onboardingQuiz: "/onboarding/quiz",
onboardingCustomize: "/onboarding/customize",
avatar: "/avatar",
+ customize: "/customize",
habits: "/habits",
dailyQuiz: "/daily/quiz",
shop: "/shop",
@@ -16,6 +17,7 @@ export type AppRoute = (typeof routes)[keyof typeof routes];
export const appNavItems = [
{ href: routes.avatar, label: "Pet", shortLabel: "Pet" },
+ { href: routes.customize, label: "Customize", shortLabel: "Style" },
{ href: routes.habits, label: "Habits", shortLabel: "Habits" },
{ href: routes.dailyQuiz, label: "Daily Quiz", shortLabel: "Quiz" },
{ href: routes.shop, label: "Shop", shortLabel: "Shop" },
diff --git a/lib/shop-catalog.ts b/lib/shop-catalog.ts
index e0b6b67..5216735 100644
--- a/lib/shop-catalog.ts
+++ b/lib/shop-catalog.ts
@@ -1,13 +1,16 @@
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
import type { CharacterLayerId } from "@/lib/character/presets";
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets";
+import { getShopRoomBackgrounds } from "@/lib/room-backgrounds";
export type ShopLayerId = Exclude;
+export type ShopItemType = ShopLayerId | "room";
+
export type ShopItemRecord = {
id: string;
name: string;
- type: ShopLayerId;
+ type: ShopItemType;
price: number;
image_path: string;
};
@@ -52,7 +55,7 @@ export function parseShopStyleItem(
}
export function getShopCatalogFromPresets(): ShopItemRecord[] {
- return CHARACTER_LAYERS.flatMap((layer) => {
+ const styleItems = CHARACTER_LAYERS.flatMap((layer) => {
if (layer.id === "skin") {
return [];
}
@@ -70,6 +73,8 @@ export function getShopCatalogFromPresets(): ShopItemRecord[] {
image_path: variant.src,
}));
});
+
+ return [...styleItems, ...getShopRoomBackgrounds()];
}
function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) {
diff --git a/lib/shop-storage.ts b/lib/shop-storage.ts
index 67ea7e1..de6f564 100644
--- a/lib/shop-storage.ts
+++ b/lib/shop-storage.ts
@@ -128,6 +128,31 @@ export async function purchaseShopItem(itemId: string) {
};
}
+export async function getEquippedRoomBackground(): Promise {
+ const userId = await getAuthenticatedUserId();
+
+ if (!userId) {
+ return "room-day";
+ }
+
+ const supabase = createClient();
+ const { data, error } = await supabase
+ .from("avatar_state")
+ .select("room_background")
+ .eq("user_id", userId)
+ .maybeSingle();
+
+ if (error) {
+ if (/room_background/i.test(error.message)) {
+ return "room-day";
+ }
+
+ throw new Error(error.message);
+ }
+
+ return (data?.room_background as string | null) ?? "room-day";
+}
+
export async function equipShopItem(itemId: string) {
const userId = await getAuthenticatedUserId();
@@ -152,6 +177,28 @@ export async function equipShopItem(itemId: string) {
throw new Error("Could not find that shop item.");
}
+ if (item.type === "room") {
+ const { error } = await supabase
+ .from("avatar_state")
+ .update({
+ room_background: itemId,
+ updated_at: new Date().toISOString(),
+ })
+ .eq("user_id", userId);
+
+ if (error) {
+ if (/room_background/i.test(error.message)) {
+ notifyHabitPetDataUpdated();
+ return [itemId];
+ }
+
+ throw new Error(error.message);
+ }
+
+ notifyHabitPetDataUpdated();
+ return [itemId];
+ }
+
const current = await getEquippedItems();
const nextStyle = parseVariantId(itemId);
@@ -216,12 +263,14 @@ export async function unequipShopItem(itemId: string) {
}
export async function getShopInventory() {
- const [items, ownedItemIds, equippedItems, coins] = await Promise.all([
- getShopItems(),
- getOwnedItemIds(),
- getEquippedItems(),
- getCoins(),
- ]);
+ const [items, ownedItemIds, equippedItems, equippedRoomBackground, coins] =
+ await Promise.all([
+ getShopItems(),
+ getOwnedItemIds(),
+ getEquippedItems(),
+ getEquippedRoomBackground(),
+ getCoins(),
+ ]);
- return { items, ownedItemIds, equippedItems, coins };
+ return { items, ownedItemIds, equippedItems, equippedRoomBackground, coins };
}
diff --git a/supabase/migrations/20260524200000_room_background.sql b/supabase/migrations/20260524200000_room_background.sql
new file mode 100644
index 0000000..bcc132f
--- /dev/null
+++ b/supabase/migrations/20260524200000_room_background.sql
@@ -0,0 +1,18 @@
+alter table public.avatar_state
+ add column if not exists room_background text not null default 'room-day';
+
+alter table public.avatar_state
+ drop constraint if exists avatar_state_room_background_check;
+
+alter table public.avatar_state
+ add constraint avatar_state_room_background_check
+ check (
+ room_background in (
+ 'room-day',
+ 'room-dusk',
+ 'room-night',
+ 'room-forest',
+ 'room-space',
+ 'room-neon'
+ )
+ );
diff --git a/supabase/migrations/20260524210000_room_background_four_only.sql b/supabase/migrations/20260524210000_room_background_four_only.sql
new file mode 100644
index 0000000..51d11e6
--- /dev/null
+++ b/supabase/migrations/20260524210000_room_background_four_only.sql
@@ -0,0 +1,23 @@
+alter table public.avatar_state
+ drop constraint if exists avatar_state_room_background_check;
+
+alter table public.avatar_state
+ add constraint avatar_state_room_background_check
+ check (
+ room_background in (
+ 'room-day',
+ 'room-dusk',
+ 'room-night',
+ 'room-forest'
+ )
+ );
+
+update public.avatar_state
+set room_background = 'room-day'
+where room_background in ('room-space', 'room-neon');
+
+delete from public.user_items
+where item_id in ('room-space', 'room-neon');
+
+delete from public.shop_items
+where id in ('room-space', 'room-neon');
diff --git a/supabase/seed.sql b/supabase/seed.sql
index 8abfbe3..2fd7e6a 100644
--- a/supabase/seed.sql
+++ b/supabase/seed.sql
@@ -30,7 +30,9 @@ values
('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')
+ ('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'),
+ ('room-night', 'Starlit Night', 'room', 40, ''),
+ ('room-forest', 'Mystic Forest', 'room', 50, '')
on conflict (id) do update
set
name = excluded.name,
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 61a4382..02da2f1 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -58,6 +58,13 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
+ fontFamily: {
+ sans: ["var(--font-pixel)", "monospace"],
+ pixel: ["var(--font-pixel)", "monospace"],
+ },
+ spacing: {
+ nav: "var(--bottom-nav-height)",
+ },
},
},
plugins: [tailwindcssAnimate],