diff --git a/README.md b/README.md index 54836dd..4851a88 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Habit Pet +# HaBit -Calgary hackathon app for building habits while caring for a customizable pixel pet. +Hackathon app for building habits while growing a customizable digital self (your **bit**). ## Stack @@ -13,9 +13,9 @@ Calgary hackathon app for building habits while caring for a customizable pixel - **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 +- **Avatar** — layered sprite bit 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 +- **Daily quiz** — wellness sliders and journal that affect bit mood - **Shop** — buy and equip items from `shop_items` / `user_items` (hats, glasses, backgrounds) - **Profile** — focus topic, vibe, and reminder preferences @@ -23,12 +23,12 @@ Calgary hackathon app for building habits while caring for a customizable pixel 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 +3. **Step 2:** avatar customization (required confirm + bit name) +4. Main app via bottom nav: Bit, 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. +On the **Bit** tab, users view their digital self and open **Style** to customize name, look, and room. ## Local setup @@ -69,7 +69,7 @@ Stored in `avatar_state`: | Column | Purpose | |--------|---------| -| `avatar_name` | Pet display name | +| `avatar_name` | Bit display name | | `skin_color` | Skin layer hex color | | `pants_style` / `pants_color` | Pants variant id + hex | | `shoe_style` / `shoe_color` | Shoes variant id + hex | @@ -78,34 +78,3 @@ Stored in `avatar_state`: | `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 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 - -- 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 - -- Migrations: `supabase/migrations/` -- Seed: `supabase/seed.sql` -- Config: `supabase/config.toml` - -Production deploys on push to `main` via `.github/workflows/deploy.yml` (lint, build, `supabase db push --include-seed`, Vercel). - -## Routes - -| 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 57ca086..247d916 100644 --- a/app/(app)/avatar/page.tsx +++ b/app/(app)/avatar/page.tsx @@ -6,8 +6,8 @@ import { AppShell } from "@/components/layout/app-shell"; export default function AvatarPage() { return ( diff --git a/app/(app)/customize/page.tsx b/app/(app)/customize/page.tsx index 4a7efa8..f9ba92c 100644 --- a/app/(app)/customize/page.tsx +++ b/app/(app)/customize/page.tsx @@ -7,8 +7,8 @@ export default function CustomizePage() { return ( Loading...

}> diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx index 18b8306..fd4ac3c 100644 --- a/app/auth/error/page.tsx +++ b/app/auth/error/page.tsx @@ -29,7 +29,7 @@ export default function Page({ searchParams: Promise<{ error: string }>; }) { return ( -
+
diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx index 8dcfbb2..ce233a7 100644 --- a/app/auth/forgot-password/page.tsx +++ b/app/auth/forgot-password/page.tsx @@ -2,7 +2,7 @@ import { ForgotPasswordForm } from "@/components/forgot-password-form"; export default function Page() { return ( -
+
diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx index 0120eac..7e53911 100644 --- a/app/auth/layout.tsx +++ b/app/auth/layout.tsx @@ -1,14 +1,7 @@ -import { AuthBottomNav } from "@/components/layout/auth-bottom-nav"; - export default function AuthLayout({ children, }: { children: React.ReactNode; }) { - return ( - <> - {children} - - - ); + return
{children}
; } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 5efe679..f3dbc15 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -2,7 +2,7 @@ import { LoginForm } from "@/components/login-form"; export default function Page() { return ( -
+
diff --git a/app/auth/sign-up-success/page.tsx b/app/auth/sign-up-success/page.tsx index 06bac40..de1807e 100644 --- a/app/auth/sign-up-success/page.tsx +++ b/app/auth/sign-up-success/page.tsx @@ -8,7 +8,7 @@ import { export default function Page() { return ( -
+
@@ -22,7 +22,7 @@ export default function Page() {

- Your account is ready. You can sign in and start using Habit Pet. + Your account is ready. You can sign in and start using HaBit.

diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index d5dca78..c0eb93c 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -2,7 +2,7 @@ import { SignUpForm } from "@/components/sign-up-form"; export default function Page() { return ( -
+
diff --git a/app/auth/update-password/page.tsx b/app/auth/update-password/page.tsx index ff4d176..2b52abb 100644 --- a/app/auth/update-password/page.tsx +++ b/app/auth/update-password/page.tsx @@ -2,7 +2,7 @@ import { UpdatePasswordForm } from "@/components/update-password-form"; export default function Page() { return ( -
+
diff --git a/app/globals.css b/app/globals.css index d402579..5c15d0e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,7 +4,7 @@ @layer base { :root { - --app-topbar-height: 3.5rem; + --app-topbar-height: 3.25rem; --app-viewport-bottom-offset: 0px; --app-nav-offset: 6rem; --bottom-nav-height: 6rem; @@ -117,7 +117,7 @@ border: 3px solid hsl(var(--lcd-border)); } - /* Matches pet-tab LCD: max-w-lg main minus px-5 and shell p-4 */ + /* Matches bit-tab LCD: max-w-lg main minus px-5 and shell p-4 */ .tamagotchi-lcd-pet-match { @apply aspect-[4/3] w-full shrink-0; max-width: calc(32rem - 2.5rem - 2rem); @@ -127,6 +127,15 @@ .tamagotchi-lcd-landing { @apply aspect-[4/3] w-full shrink-0; max-height: clamp(120px, 36vw, 240px); + container-type: inline-size; + } + + .tamagotchi-lcd-landing .lcd-readable-name-landing { + max-width: 58%; + padding: 0.125rem 0.375rem; + font-size: clamp(0.4rem, 4.5cqi, 0.5625rem); + line-height: 1.2; + box-shadow: 1px 1px 0 0 hsl(280 45% 28%); } .tamagotchi-lcd-landing > div.absolute svg { @@ -149,16 +158,6 @@ aspect-ratio: auto; } - .customize-body { - min-height: 26rem; - } - - @media (min-width: 1024px) { - .customize-body { - min-height: 30rem; - } - } - .retro-btn { @apply inline-flex items-center justify-center border-2 border-border bg-primary text-primary-foreground; box-shadow: var(--retro-shadow-sm); @@ -244,6 +243,31 @@ .form-section-locked label { @apply text-muted-foreground; } + + .lcd-readable-label { + @apply border-2 border-border bg-card px-2.5 py-1.5 text-center font-pixel text-[8px] leading-snug text-card-foreground sm:text-[9px]; + box-shadow: var(--retro-shadow-sm); + } + + .lcd-readable-name { + @apply border-2 border-border bg-card px-2 py-1 font-pixel leading-snug text-card-foreground; + box-shadow: var(--retro-shadow-sm); + } +} + +@keyframes emotion-pop { + 0% { + opacity: 0; + transform: translateY(4px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-emotion-pop { + animation: emotion-pop 0.2s steps(4) both; } @keyframes pet-breathe { diff --git a/app/layout.tsx b/app/layout.tsx index 99e3326..8ccb9dd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Press_Start_2P, Share_Tech_Mono } from "next/font/google"; import { ThemeProvider } from "next-themes"; import { GlobalBottomNav } from "@/components/layout/global-bottom-nav"; +import { SiteHeader } from "@/components/layout/site-header"; import { ToastProvider } from "@/components/ui/toast-provider"; import "./globals.css"; @@ -12,8 +13,8 @@ const defaultUrl = process.env.VERCEL_URL export const metadata: Metadata = { metadataBase: new URL(defaultUrl), - title: "Habit Pet", - description: "Build habits, care for your pixel pet, and track wellness every day.", + title: "HaBit", + description: "Build habits, grow your digital self, and track wellness every day.", }; export const viewport: Viewport = { @@ -52,7 +53,8 @@ export default function RootLayout({ disableTransitionOnChange > - {children} + +
{children}
diff --git a/app/page.tsx b/app/page.tsx index 45e71b8..bc89ee8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,6 @@ import { Suspense } from "react"; import { LandingPetPreview } from "@/components/landing/landing-pet-preview"; import { LandingHeroActions } from "@/components/layout/landing-hero-actions"; -import { SiteFooter, SiteHeader } from "@/components/layout/site-header"; import { Card, CardContent, @@ -13,22 +12,17 @@ import { export default function Home() { return ( -
- - +
-

- Calgary Hackathon MVP -

- Build habits. Care for your pixel pet. + Build habits. Grow your digital self.

- HaBit turns daily check-ins, wellness sliders, and journaling - into a simple game. Keep streaks alive, take your once-per-day - quiz, and watch your pet change with you. + HaBit turns daily check-ins, wellness sliders, and journaling into + a simple game. Keep streaks alive, take your once-per-day quiz, + and watch your bit change with you.

@@ -59,8 +53,8 @@ export default function Home() { description: "Sliders and a daily reflection box.", }, { - title: "Pet that responds", - description: "Avatar mood changes from your input.", + title: "A bit that responds", + description: "Your digital self reflects your daily input.", }, ].map((feature) => ( @@ -72,8 +66,6 @@ export default function Home() { ))}
- -
); } diff --git a/components/auth-button.tsx b/components/auth-button.tsx index f55cc89..42efa25 100644 --- a/components/auth-button.tsx +++ b/components/auth-button.tsx @@ -11,18 +11,13 @@ export async function AuthButton() { const user = data?.claims; return user ? ( -
- - -
+ ) : (
- -
diff --git a/components/avatar/avatar-customize-layout.tsx b/components/avatar/avatar-customize-layout.tsx index fde45aa..c95b4df 100644 --- a/components/avatar/avatar-customize-layout.tsx +++ b/components/avatar/avatar-customize-layout.tsx @@ -1,5 +1,4 @@ import { AppPageHeader } from "@/components/layout/app-page-header"; -import { AppTopBar } from "@/components/layout/app-top-bar"; type AvatarCustomizeLayoutProps = { eyebrow?: string; @@ -15,9 +14,7 @@ export function AvatarCustomizeLayout({ children, }: AvatarCustomizeLayoutProps) { return ( -
- - +
Loading your pet...

+

Loading your bit...

); } diff --git a/components/avatar/avatar-status-card.tsx b/components/avatar/avatar-status-card.tsx index fc42088..1ef894e 100644 --- a/components/avatar/avatar-status-card.tsx +++ b/components/avatar/avatar-status-card.tsx @@ -61,7 +61,7 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {

- Loading your pet... + Loading your bit...

@@ -100,11 +100,11 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {

{hasCheckedInToday ? completedTaskCount > 0 - ? `Your pet reacts to today's quiz and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.` - : "Your pet is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health." + ? `Your bit reacts to today's quiz and ${completedTaskCount} completed task${completedTaskCount === 1 ? "" : "s"}. Meals, hydration, sleep, and relaxing habits boost health and mood.` + : "Your bit is reacting to today's wellness check-in. Complete habits to boost mood, energy, and health." : completedTaskCount > 0 - ? "Complete today's quiz for a full wellness read, then keep completing habits to boost your pet." - : "Complete today's quiz to update your pet's mood, energy, and health."} + ? "Complete today's quiz for a full wellness read, then keep completing habits to boost your bit." + : "Complete today's quiz to update your bit's mood, energy, and health."}

{!hasCheckedInToday ? (
); diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx index c16b979..35c282a 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -1,38 +1,38 @@ -import Link from "next/link"; import { Suspense } from "react"; import { AuthButton } from "@/components/auth-button"; import { EnvVarWarning } from "@/components/env-var-warning"; -import { ThemeSwitcher } from "@/components/theme-switcher"; +import { HabitBrand } from "@/components/layout/habit-brand"; +import { HeaderCoinBalance } from "@/components/layout/header-coin-balance"; import { routes } from "@/lib/routes"; import { hasEnvVars } from "@/lib/utils"; export function SiteHeader() { return ( -
-
- - HaBit - - {!hasEnvVars ? ( - - ) : ( - - - - )} +
+
+ +
+ {!hasEnvVars ? ( + + ) : ( + <> + + + + + + + + )} +
); } - -export function SiteFooter() { - return ( -
-
-

Build habits. Care for your pet. Feel better every day.

- -
-
- ); -} diff --git a/components/onboarding/onboarding-customize-form.tsx b/components/onboarding/onboarding-customize-form.tsx index 1aa02a5..7adbae8 100644 --- a/components/onboarding/onboarding-customize-form.tsx +++ b/components/onboarding/onboarding-customize-form.tsx @@ -30,7 +30,7 @@ export function OnboardingCustomizeForm() { }, { completeOnboarding: true }, ); - toast("Avatar confirmed. Welcome to Habit Pet!", "success"); + toast("Avatar confirmed. Welcome to HaBit!", "success"); router.push(routes.avatar); router.refresh(); } catch (saveError) { @@ -49,8 +49,8 @@ export function OnboardingCustomizeForm() { return (

- Welcome to Habit Pet + Welcome to HaBit

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

diff --git a/components/pet/animated-pet-sprite.tsx b/components/pet/animated-pet-sprite.tsx index 95da511..c31c551 100644 --- a/components/pet/animated-pet-sprite.tsx +++ b/components/pet/animated-pet-sprite.tsx @@ -3,7 +3,9 @@ import { useEffect, useState } from "react"; import { CharacterLayerPreview } from "@/components/character/character-layer-preview"; +import { PetEmotionBubble } from "@/components/pet/pet-emotion-bubble"; import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; +import type { AvatarMood } from "@/lib/avatar-state"; import { PET_WANDER_BOUNDS, PET_WANDER_START, @@ -14,6 +16,10 @@ type AnimatedPetSpriteProps = { customization: AvatarCustomization; className?: string; petScale?: number; + interactive?: boolean; + onInteract?: () => void; + emotionVisible?: boolean; + mood?: AvatarMood; }; type WanderPosition = { @@ -54,6 +60,10 @@ export function AnimatedPetSprite({ customization, className, petScale = 7, + interactive = false, + onInteract, + emotionVisible = false, + mood = "neutral", }: AnimatedPetSpriteProps) { const [position, setPosition] = useState({ x: PET_WANDER_START.x, @@ -80,16 +90,45 @@ export function AnimatedPetSprite({ }; }, []); + const handleActivate = () => { + if (!interactive) { + return; + } + + onInteract?.(); + }; + return (
{ + event.stopPropagation(); + handleActivate(); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.stopPropagation(); + handleActivate(); + } + }} > +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ ); +} diff --git a/components/pet/pet-emotion-caption.tsx b/components/pet/pet-emotion-caption.tsx new file mode 100644 index 0000000..58cec6d --- /dev/null +++ b/components/pet/pet-emotion-caption.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { AvatarMood } from "@/lib/avatar-state"; +import { getPetEmotionMessage } from "@/lib/pet-status"; +import { cn } from "@/lib/utils"; + +type PetEmotionCaptionProps = { + mood: AvatarMood; + visible: boolean; + className?: string; +}; + +export function PetEmotionCaption({ + mood, + visible, + className, +}: PetEmotionCaptionProps) { + if (!visible) { + return null; + } + + return ( +
+

{getPetEmotionMessage(mood)}

+
+ ); +} diff --git a/components/pet/pet-habitat.tsx b/components/pet/pet-habitat.tsx index d3a3809..b8ed7c4 100644 --- a/components/pet/pet-habitat.tsx +++ b/components/pet/pet-habitat.tsx @@ -1,17 +1,19 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatedPetSprite } from "@/components/pet/animated-pet-sprite"; +import { PetEmotionCaption } from "@/components/pet/pet-emotion-caption"; import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background"; -import { PetStyleLink } from "@/components/pet/pet-style-link"; import { PixelGauge } from "@/components/pet/pixel-gauge"; import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; import { defaultAvatarCondition, type AvatarCondition, } from "@/lib/avatar-state"; +import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; import { getAvatarConditionForToday } from "@/lib/daily-quiz-storage"; +import { PET_EMOTION_DISPLAY_MS } from "@/lib/pet-status"; import { normalizeRoomBackgroundId, type RoomBackgroundId, @@ -22,23 +24,18 @@ type PetHabitatProps = { customization: AvatarCustomization; className?: string; fillViewport?: boolean; - showStyleLink?: boolean; + /** Bit name in the LCD top-right (landing preview). */ + nameInLcd?: boolean; petScale?: number; lcdClassName?: string; lcdRef?: React.Ref; }; -const MOOD_LABELS = { - happy: "HAPPY", - neutral: "OKAY", - tired: "SLEEPY", -} as const; - export function PetHabitat({ customization, className, fillViewport = false, - showStyleLink = true, + nameInLcd = false, petScale, lcdClassName: lcdClassNameOverride, lcdRef, @@ -47,16 +44,51 @@ export function PetHabitat({ defaultAvatarCondition, ); const [isReady, setIsReady] = useState(false); + const [emotionVisible, setEmotionVisible] = useState(false); + const emotionTimeoutRef = useRef(null); const roomId = normalizeRoomBackgroundId(customization.roomBackground); + const interactive = fillViewport; + + const clearEmotionTimeout = useCallback(() => { + if (emotionTimeoutRef.current !== null) { + window.clearTimeout(emotionTimeoutRef.current); + emotionTimeoutRef.current = null; + } + }, []); + + const showEmotionBriefly = useCallback(() => { + clearEmotionTimeout(); + setEmotionVisible(true); + emotionTimeoutRef.current = window.setTimeout(() => { + setEmotionVisible(false); + emotionTimeoutRef.current = null; + }, PET_EMOTION_DISPLAY_MS); + }, [clearEmotionTimeout]); + + const loadStatus = useCallback(async () => { + setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition); + setIsReady(true); + }, []); useEffect(() => { - async function loadStatus() { - setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition); - setIsReady(true); - } - void loadStatus(); - }, []); + + return () => { + clearEmotionTimeout(); + }; + }, [loadStatus, clearEmotionTimeout]); + + useEffect(() => { + const handleDataUpdated = () => { + void loadStatus(); + }; + + window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated); + + return () => { + window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated); + }; + }, [loadStatus]); const shellClassName = cn( "tamagotchi-shell overflow-hidden p-3 sm:p-4", @@ -71,11 +103,15 @@ export function PetHabitat({ : (lcdClassNameOverride ?? "tamagotchi-lcd-pet-match"), ); + const gaugeOverlaySize = fillViewport ? "responsive" : "md"; + const showNameAboveShell = !fillViewport && !nameInLcd; + const showNameInLcdOverlay = fillViewport || nameInLcd; + if (!isReady) { return (

- Waking up pet... + Waking up your bit...

); @@ -83,24 +119,48 @@ export function PetHabitat({ return (
-
-

{customization.name}

-

- {MOOD_LABELS[condition.mood]} -

-
+ {showNameAboveShell ? ( +
+

{customization.name}

+
+ ) : null}
- -
- - + + +
+ +
- {showStyleLink ? ( -
- -
+ {showNameInLcdOverlay ? ( +

+ {customization.name} +

) : null}
diff --git a/components/pet/pet-style-link.tsx b/components/pet/pet-style-link.tsx index 060a33f..f799973 100644 --- a/components/pet/pet-style-link.tsx +++ b/components/pet/pet-style-link.tsx @@ -12,7 +12,7 @@ export function PetStyleLink({ className }: PetStyleLinkProps) { return ( ) : null} -
+
{Array.from({ length: max }, (_, index) => (

- Pick the scene behind your pet. Premium rooms unlock in the shop. + Pick the scene behind your bit. Premium rooms unlock in the shop.

) : ( diff --git a/components/profile/profile-preferences-form.tsx b/components/profile/profile-preferences-form.tsx index 656234e..a175632 100644 --- a/components/profile/profile-preferences-form.tsx +++ b/components/profile/profile-preferences-form.tsx @@ -266,7 +266,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { Focus topic preference - Choose the one area you want your pet and daily tasks to focus on. + Choose the one area you want your bit and daily tasks to focus on. @@ -288,7 +288,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { Avatar preferences - Personalize your pet's vibe. + Personalize your bit's vibe. {avatarVibeOptions.map((vibe) => ( @@ -369,7 +369,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { />

- In-app reminders appear inside Habit Pet while a tab is open. + In-app reminders appear inside HaBit while a tab is open. Browser (OS) reminders use the Web Notifications API and may appear in your system notification center. Reminders fire once per day at your chosen local time. diff --git a/components/reminders/in-app-reminder-banner.tsx b/components/reminders/in-app-reminder-banner.tsx index c01d4e9..37a0fd4 100644 --- a/components/reminders/in-app-reminder-banner.tsx +++ b/components/reminders/in-app-reminder-banner.tsx @@ -59,7 +59,7 @@ export function InAppReminderBanner() { router.push(reminder.targetUrl); }} > - Open Pet tab + Open Bit tab

+ ); + } + + return ( +
+
+ ); +} const SHOP_LAYER_ORDER: ShopLayerId[] = [ "head", @@ -94,13 +132,15 @@ function ShopItemCard({ )}
-
-
-

{item.name}

- - {item.price} pts - -
+
+

+ {item.name} +

+
@@ -287,15 +327,6 @@ export function ShopContent() { }} /> -
- - Your balance - - - {coins === null ? "..." : `${coins} points`} - -
- {error ?

{error}

: null} {!error && coins !== null && items.length === 0 ? ( @@ -312,7 +343,7 @@ export function ShopContent() {
{roomItems.length > 0 ? (
-

+

Room backgrounds

@@ -335,7 +366,7 @@ export function ShopContent() { {styleGroups.map((group) => (
-

+

{group.label}

diff --git a/components/shop/shop-item-preview-modal.tsx b/components/shop/shop-item-preview-modal.tsx index 7ad1248..6e10015 100644 --- a/components/shop/shop-item-preview-modal.tsx +++ b/components/shop/shop-item-preview-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { X } from "lucide-react"; +import { Coins, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -87,7 +87,7 @@ export function ShopItemPreviewModal({ id="shop-preview-title" className="text-[10px] uppercase tracking-wider text-muted-foreground" > - Preview on your pet + Preview on your bit

{item.name}

@@ -119,13 +119,16 @@ export function ShopItemPreviewModal({

- Preview only — nothing is purchased or equipped until you confirm. + This is a preview only. Nothing is purchased or equipped until you confirm.

-
- Price - {item.price} points +
+
{owned ? ( diff --git a/lib/ai-prompt-security.ts b/lib/ai-prompt-security.ts index 251990f..edee108 100644 --- a/lib/ai-prompt-security.ts +++ b/lib/ai-prompt-security.ts @@ -1,4 +1,4 @@ -export const AI_TASK_SYSTEM_PROMPT = `You are a supportive wellness coach for a habit-tracking pet app. +export const AI_TASK_SYSTEM_PROMPT = `You are a supportive wellness coach for a habit-tracking app where users care for a digital self (their "bit"). Your only allowed action is to suggest practical daily habit task labels based on trusted app context and untrusted user journal text. diff --git a/lib/ai-task-context.ts b/lib/ai-task-context.ts index 7a9fbeb..b77b215 100644 --- a/lib/ai-task-context.ts +++ b/lib/ai-task-context.ts @@ -60,7 +60,7 @@ export function formatAiTaskContextForPrompt(context: AiTaskContext) { `Daily reminder enabled: ${preferences.dailyReminderEnabled ? "yes" : "no"}`, `Daily reminder time: ${preferences.dailyReminderTime}`, `Primary focus: ${onboarding.focusTopic ?? "unknown"}`, - `Starter pet vibe: ${onboarding.avatarVibe ?? "unknown"}`, + `Starter bit vibe: ${onboarding.avatarVibe ?? "unknown"}`, ].join("\n"), ); diff --git a/lib/avatar-customization-storage.ts b/lib/avatar-customization-storage.ts index 450a90a..0662620 100644 --- a/lib/avatar-customization-storage.ts +++ b/lib/avatar-customization-storage.ts @@ -51,7 +51,7 @@ type AvatarStateRow = { room_background: string | null; }; -export const DEFAULT_AVATAR_NAME = "Pixel Me"; +export const DEFAULT_AVATAR_NAME = "My Bit"; const DEFAULT_HEX = DEFAULT_GRAY_COLOR.hex; const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/; diff --git a/lib/brand.ts b/lib/brand.ts new file mode 100644 index 0000000..9805614 --- /dev/null +++ b/lib/brand.ts @@ -0,0 +1 @@ +export const HABIT_LOGO_SRC = "/habit-logo.png"; diff --git a/lib/daily-reminders.ts b/lib/daily-reminders.ts index 8462a86..761a2d4 100644 --- a/lib/daily-reminders.ts +++ b/lib/daily-reminders.ts @@ -129,7 +129,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) { if (!status.quizCompleted) { return { title: "Time for your daily check-in", - body: "Complete today's wellness quiz to keep your pet happy.", + body: "Complete today's wellness quiz to keep your bit in good spirits.", targetUrl, }; } @@ -174,7 +174,7 @@ export function showTestSystemDailyReminderNotification( } const { body, targetUrl } = buildDailyReminderNotification(status); - const notification = new Notification("Test: Habit Pet reminder", { + const notification = new Notification("Test: HaBit reminder", { body: `This is a browser test notification. ${body}`, tag: `habit-pet-daily-reminder-test-${Date.now()}`, }); diff --git a/lib/landing-random-pet.ts b/lib/landing-random-pet.ts index 4be9491..76fff6f 100644 --- a/lib/landing-random-pet.ts +++ b/lib/landing-random-pet.ts @@ -1,43 +1,45 @@ -import { - DEFAULT_AVATAR_NAME, - type AvatarCustomization, -} from "@/lib/avatar-customization-storage"; +import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; import { CHARACTER_LAYERS, COLOR_PRESETS, NONE_VARIANT_ID, type CharacterLayerId, } from "@/lib/character/presets"; -import { ROOM_BACKGROUNDS } from "@/lib/room-backgrounds"; +import { clampHsl } from "@/lib/character/color-utils"; +import { + ROOM_BACKGROUNDS, + type RoomBackgroundId, +} from "@/lib/room-backgrounds"; const GUEST_PET_STORAGE_KEY = "habit-pet-guest-landing-pet"; -const SKIN_TONE_IDS = [ - "porcelain", - "fair", - "light", - "medium", - "tan", - "brown", - "dark", - "deep", - "onyx", +export const GUEST_LANDING_BIT_NAMES = [ + "Alex", + "Jordan", + "Sam", + "Taylor", + "Morgan", ] as const; -const OUTFIT_COLOR_IDS = [ - "red", - "orange", - "yellow", - "green", - "blue", - "indigo", - "black", - "white", -] as const; +const SKIN_COLOR_PRESETS = COLOR_PRESETS.filter((preset) => + [ + "porcelain", + "fair", + "light", + "medium", + "tan", + "brown", + "dark", + "deep", + "onyx", + ].includes(preset.id), +); -const EYE_COLOR_IDS = ["blue", "black", "green", "brown"] as const; +const STYLE_COLOR_PRESETS = COLOR_PRESETS.filter( + (preset) => !SKIN_COLOR_PRESETS.some((skin) => skin.id === preset.id), +); -const HAIR_COLOR_IDS = ["brown", "black", "yellow", "red", "orange", "white"] as const; +const ALL_ROOM_IDS = ROOM_BACKGROUNDS.map((room) => room.id); function pickRandom(items: readonly T[]): T { return items[Math.floor(Math.random() * items.length)]!; @@ -52,48 +54,61 @@ function presetHsl(id: string) { return { ...preset.hsl }; } -function pickRandomVariant( - layerId: Exclude, - allowNone = false, -): string { +function randomHsl(): { h: number; s: number; l: number } { + return clampHsl({ + h: Math.floor(Math.random() * 360), + s: 35 + Math.floor(Math.random() * 50), + l: 28 + Math.floor(Math.random() * 45), + }); +} + +function pickRandomLayerColor(layerId: CharacterLayerId) { + if (layerId === "skin") { + return presetHsl(pickRandom(SKIN_COLOR_PRESETS).id); + } + + if (Math.random() < 0.35) { + return randomHsl(); + } + + return presetHsl(pickRandom(STYLE_COLOR_PRESETS).id); +} + +function pickRandomLayerVariant(layerId: CharacterLayerId): string { const layer = CHARACTER_LAYERS.find((entry) => entry.id === layerId); if (!layer) { throw new Error(`Missing character layer: ${layerId}`); } - const options = layer.variants - .map((variant) => variant.id) - .filter((id) => allowNone || id !== NONE_VARIANT_ID); + if (!layer.allowVariants) { + return layer.variants[0]?.id ?? NONE_VARIANT_ID; + } - return pickRandom(options); + return pickRandom(layer.variants.map((variant) => variant.id)); } export function buildRandomLandingPetCustomization(): AvatarCustomization { - const freeRooms = ROOM_BACKGROUNDS.filter((room) => room.free).map( - (room) => room.id, - ); - return { - name: DEFAULT_AVATAR_NAME, + name: pickRandom(GUEST_LANDING_BIT_NAMES), colors: { - skin: presetHsl(pickRandom(SKIN_TONE_IDS)), - pants: presetHsl(pickRandom(OUTFIT_COLOR_IDS)), - shoes: presetHsl(pickRandom(OUTFIT_COLOR_IDS)), - torso: presetHsl(pickRandom(OUTFIT_COLOR_IDS)), - eyes: presetHsl(pickRandom(EYE_COLOR_IDS)), - head: presetHsl(pickRandom(HAIR_COLOR_IDS)), + skin: pickRandomLayerColor("skin"), + pants: pickRandomLayerColor("pants"), + shoes: pickRandomLayerColor("shoes"), + torso: pickRandomLayerColor("torso"), + eyes: pickRandomLayerColor("eyes"), + head: pickRandomLayerColor("head"), }, variants: { - skin: "skin-1", - pants: pickRandomVariant("pants"), - shoes: pickRandomVariant("shoes"), - torso: pickRandomVariant("torso"), - eyes: pickRandomVariant("eyes"), - head: pickRandomVariant("head", Math.random() < 0.15), + skin: pickRandomLayerVariant("skin"), + pants: pickRandomLayerVariant("pants"), + shoes: pickRandomLayerVariant("shoes"), + torso: pickRandomLayerVariant("torso"), + eyes: pickRandomLayerVariant("eyes"), + head: pickRandomLayerVariant("head"), }, customized: true, equippedItems: [], - roomBackground: pickRandom(freeRooms), + roomBackground: pickRandom(ALL_ROOM_IDS) as RoomBackgroundId, }; } @@ -105,7 +120,14 @@ export function getGuestLandingPetCustomization(): AvatarCustomization { try { const cached = sessionStorage.getItem(GUEST_PET_STORAGE_KEY); if (cached) { - return JSON.parse(cached) as AvatarCustomization; + const parsed = JSON.parse(cached) as AvatarCustomization; + if ( + GUEST_LANDING_BIT_NAMES.includes( + parsed.name as (typeof GUEST_LANDING_BIT_NAMES)[number], + ) + ) { + return parsed; + } } const next = buildRandomLandingPetCustomization(); diff --git a/lib/openrouter.ts b/lib/openrouter.ts index b312155..c7e9cc0 100644 --- a/lib/openrouter.ts +++ b/lib/openrouter.ts @@ -46,7 +46,7 @@ export async function generateAiTaskLabels( Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "HTTP-Referer": siteUrl, - "X-Title": "Habit Pet", + "X-Title": "HaBit", }, body: JSON.stringify({ model: DEFAULT_OPENROUTER_MODEL, diff --git a/lib/pet-status.ts b/lib/pet-status.ts new file mode 100644 index 0000000..0e19669 --- /dev/null +++ b/lib/pet-status.ts @@ -0,0 +1,26 @@ +import type { AvatarMood } from "@/lib/avatar-state"; + +export const PET_EMOTION_DISPLAY_MS = 3200; + +export type PetStatusImageId = "status-1" | "status-2" | "status-3"; + +export const PET_STATUS_BY_MOOD: Record = { + happy: "status-1", + neutral: "status-2", + tired: "status-3", +}; + +export function getPetStatusImagePath(mood: AvatarMood): string { + return `/status/${PET_STATUS_BY_MOOD[mood]}.png`; +} + +export function getPetEmotionMessage(mood: AvatarMood): string { + switch (mood) { + case "happy": + return "Your bit seems cheerful and in good spirits."; + case "tired": + return "Your bit seems worn out and could use some care."; + default: + return "Your bit seems steady and doing okay."; + } +} diff --git a/lib/reminder-delivery.ts b/lib/reminder-delivery.ts index 77d903e..a808d34 100644 --- a/lib/reminder-delivery.ts +++ b/lib/reminder-delivery.ts @@ -3,7 +3,7 @@ export const reminderDeliveryOptions = [ value: "in_app", label: "In-app", description: - "Show reminders inside Habit Pet when you have the site open in a tab.", + "Show reminders inside HaBit when you have the site open in a tab.", }, { value: "system", diff --git a/lib/routes.ts b/lib/routes.ts index 3856cd7..01f5aa9 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -16,7 +16,8 @@ export const routes = { export type AppRoute = (typeof routes)[keyof typeof routes]; export const appNavItems = [ - { href: routes.avatar, label: "Pet", shortLabel: "Pet" }, + { href: routes.avatar, label: "Bit", shortLabel: "Bit" }, + { 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/public/habit-logo.png b/public/habit-logo.png new file mode 100644 index 0000000..6f8d425 Binary files /dev/null and b/public/habit-logo.png differ diff --git a/public/status/status-1.png b/public/status/status-1.png new file mode 100644 index 0000000..db08c3e Binary files /dev/null and b/public/status/status-1.png differ diff --git a/public/status/status-2.png b/public/status/status-2.png new file mode 100644 index 0000000..b2181bd Binary files /dev/null and b/public/status/status-2.png differ diff --git a/public/status/status-3.png b/public/status/status-3.png new file mode 100644 index 0000000..0f4a2ce Binary files /dev/null and b/public/status/status-3.png differ