Improvements to the UI
This commit is contained in:
@@ -11,18 +11,13 @@ export async function AuthButton() {
|
||||
const user = data?.claims;
|
||||
|
||||
return user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={routes.avatar}>Open app</Link>
|
||||
</Button>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
<LogoutButton />
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button asChild size="sm" variant={"outline"}>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={routes.login}>Sign in</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant={"default"}>
|
||||
<Button asChild size="sm" variant="default">
|
||||
<Link href={routes.signUp}>Sign up</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative min-h-dvh bg-background pt-topbar">
|
||||
<AppTopBar />
|
||||
|
||||
<div className="relative min-h-dvh flex-1 bg-background">
|
||||
<main className="mx-auto max-w-lg px-4 py-6 lg:max-w-6xl lg:px-5">
|
||||
<AppPageHeader
|
||||
eyebrow={eyebrow}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function AvatarPageContent() {
|
||||
|
||||
if (!customization) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">Loading your pet...</p>
|
||||
<p className="text-xs text-muted-foreground">Loading your bit...</p>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Loading your pet...
|
||||
Loading your bit...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -100,11 +100,11 @@ export function AvatarStatusCard({ customization }: AvatarStatusCardProps) {
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
{!hasCheckedInToday ? (
|
||||
<Button asChild className="w-full">
|
||||
|
||||
@@ -26,6 +26,7 @@ export function CustomizePageContent() {
|
||||
const [shopItems, setShopItems] = useState<ShopItemRecord[]>([]);
|
||||
const [coins, setCoins] = useState<number | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
|
||||
const loadCustomization = useCallback(async () => {
|
||||
try {
|
||||
@@ -46,6 +47,7 @@ export function CustomizePageContent() {
|
||||
setEquippedItems(inventory.equippedItems);
|
||||
setShopItems(inventory.items);
|
||||
setCoins(inventory.coins);
|
||||
setEditorKey((current) => current + 1);
|
||||
} catch (error) {
|
||||
setLoadError(
|
||||
error instanceof Error ? error.message : "Could not load your avatar.",
|
||||
@@ -93,7 +95,7 @@ export function CustomizePageContent() {
|
||||
|
||||
return (
|
||||
<CharacterCreator
|
||||
key={`${customization.variants.head}-${customization.variants.torso}-${customization.colors.skin.l}-${customization.roomBackground}-${avatarName}-${ownedVariantIds.join(",")}`}
|
||||
key={editorKey}
|
||||
initialCustomization={{
|
||||
...customization,
|
||||
name: avatarName,
|
||||
|
||||
@@ -29,7 +29,7 @@ export function PixelAvatar({
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col items-center gap-3", className)}
|
||||
aria-label={`Pixel pet avatar, mood ${mood}`}
|
||||
aria-label={`Bit avatar, mood ${mood}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -14,7 +14,10 @@ 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 type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import {
|
||||
DEFAULT_AVATAR_NAME,
|
||||
type AvatarCustomization,
|
||||
} from "@/lib/avatar-customization-storage";
|
||||
import { clampHsl } from "@/lib/character/color-utils";
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
@@ -47,7 +50,7 @@ import {
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
@@ -84,7 +87,7 @@ export function CharacterCreator({
|
||||
}: CharacterCreatorProps) {
|
||||
const { toast } = useToast();
|
||||
const [internalName, setInternalName] = useState(
|
||||
initialCustomization?.name ?? "Pixel Me",
|
||||
initialCustomization?.name ?? DEFAULT_AVATAR_NAME,
|
||||
);
|
||||
const name = controlledName ?? internalName;
|
||||
|
||||
@@ -355,12 +358,12 @@ export function CharacterCreator({
|
||||
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
|
||||
{showNameField ? (
|
||||
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
|
||||
<Label htmlFor="avatar-name">Pet name</Label>
|
||||
<Label htmlFor="avatar-name">Bit name</Label>
|
||||
<Input
|
||||
id="avatar-name"
|
||||
value={name}
|
||||
maxLength={32}
|
||||
placeholder="Name your pet"
|
||||
placeholder="Name your bit"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -368,8 +371,8 @@ export function CharacterCreator({
|
||||
|
||||
<CharacterLayerTabs activeTabId={activeTabId} onTabChange={handleTabChange} />
|
||||
|
||||
<div className="customize-body mt-4 grid items-stretch gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
|
||||
<div className="flex min-h-full min-w-0 flex-col">
|
||||
<div className="customize-body mt-4 grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
||||
<ParallaxRoomBackground roomId={displayCustomization.roomBackground} />
|
||||
<div className="relative z-10 flex h-full items-center justify-center">
|
||||
@@ -386,7 +389,7 @@ export function CharacterCreator({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-full min-w-0 flex-col gap-5 border-t-2 border-border/60 pt-4 lg:border-l-2 lg:border-t-0 lg:pl-5 lg:pt-0">
|
||||
<div className="flex min-w-0 flex-col gap-5 border-t-2 border-border/60 pt-4 lg:border-l-2 lg:border-t-0 lg:pl-5 lg:pt-0">
|
||||
{showStyleSection ? (
|
||||
<section className="grid shrink-0 gap-3">
|
||||
<SectionLabel>Style</SectionLabel>
|
||||
@@ -437,7 +440,7 @@ export function CharacterCreator({
|
||||
) : null}
|
||||
|
||||
{onSave ? (
|
||||
<div className="mt-auto grid gap-2 pt-1">
|
||||
<div className="grid gap-2 pt-2">
|
||||
<Button
|
||||
id="avatar-customization-save"
|
||||
type="button"
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CharacterLayerTabs({
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Character customization"
|
||||
className="grid grid-cols-7 gap-0.5 border-2 border-border bg-muted/30 p-0.5"
|
||||
className="grid grid-cols-4 gap-1 border-2 border-border bg-muted/30 p-1 sm:grid-cols-7"
|
||||
>
|
||||
{CHARACTER_CUSTOMIZATION_TABS.map((tab) => (
|
||||
<button
|
||||
@@ -29,7 +29,7 @@ export function CharacterLayerTabs({
|
||||
aria-selected={activeTabId === tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={cn(
|
||||
"min-w-0 truncate border-2 px-0.5 py-1.5 text-[7px] font-medium uppercase leading-tight tracking-wide transition-colors sm:px-1 sm:text-[8px]",
|
||||
"min-w-0 border-2 px-1 py-2 text-[10px] font-semibold uppercase leading-tight tracking-wide transition-colors sm:px-1.5 sm:text-xs",
|
||||
activeTabId === tab.id
|
||||
? "border-secondary bg-background text-foreground shadow-[var(--retro-shadow-sm)]"
|
||||
: "border-transparent text-muted-foreground hover:border-border/60 hover:text-foreground",
|
||||
|
||||
@@ -161,12 +161,12 @@ export function DailyQuizForm() {
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your answers are saved below. Come back tomorrow for your next
|
||||
check-in. How you did today affects your pet. Spend time with{" "}
|
||||
check-in. How you did today affects your bit. Spend time with{" "}
|
||||
<Link
|
||||
href={routes.avatar}
|
||||
className="font-medium text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
your pet
|
||||
your bit
|
||||
</Link>{" "}
|
||||
whenever you want to see how they are doing.
|
||||
</p>
|
||||
|
||||
@@ -20,11 +20,9 @@ function LandingPetPreviewSkeleton() {
|
||||
aria-hidden="true"
|
||||
className="tamagotchi-shell w-full max-w-sm overflow-hidden p-3 sm:p-4"
|
||||
>
|
||||
<div className="mb-3 space-y-2 border-b-2 border-border/60 pb-3">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-2 w-16 animate-pulse rounded bg-muted" />
|
||||
<div className="tamagotchi-lcd tamagotchi-lcd-landing relative animate-pulse bg-muted/40">
|
||||
<div className="absolute right-2 top-2 z-20 h-4 w-20 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="tamagotchi-lcd tamagotchi-lcd-landing animate-pulse bg-muted/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,7 +128,7 @@ export function LandingPetPreview() {
|
||||
<PetHabitat
|
||||
customization={customization}
|
||||
className="w-full max-w-sm"
|
||||
showStyleLink={false}
|
||||
nameInLcd
|
||||
petScale={petScale}
|
||||
lcdClassName="tamagotchi-lcd-landing"
|
||||
lcdRef={lcdRef}
|
||||
@@ -141,7 +139,7 @@ export function LandingPetPreview() {
|
||||
return (
|
||||
<Link
|
||||
href={routes.avatar}
|
||||
aria-label="Go to your pet"
|
||||
aria-label="Go to your bit"
|
||||
className={cn(
|
||||
"block w-full max-w-sm rounded-sm transition-transform",
|
||||
"hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { forwardRef } from "react";
|
||||
import {
|
||||
Home,
|
||||
ListPlus,
|
||||
Palette,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
@@ -29,6 +30,7 @@ const gridColsClass = {
|
||||
|
||||
const navIcons = {
|
||||
[routes.avatar]: Home,
|
||||
[routes.customize]: Palette,
|
||||
[routes.habits]: ListPlus,
|
||||
[routes.dailyQuiz]: Sparkles,
|
||||
[routes.shop]: ShoppingBag,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AppPageHeader } from "@/components/layout/app-page-header";
|
||||
import { AppTopBar } from "@/components/layout/app-top-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AppShellProps = {
|
||||
@@ -27,8 +26,7 @@ export function AppShell({
|
||||
centered = false,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col bg-background pt-topbar">
|
||||
<AppTopBar />
|
||||
<div className="relative flex flex-1 flex-col bg-background">
|
||||
<main
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-1 flex-col px-4 py-6 lg:px-5",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { HabitBrand } from "@/components/layout/habit-brand";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AppTopBarProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppTopBar({ className }: AppTopBarProps) {
|
||||
const pathname = usePathname();
|
||||
const isHome =
|
||||
pathname === routes.home || pathname === routes.avatar;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"app-top-bar fixed inset-x-0 top-0 z-40 border-b-2 border-border bg-background/95 shadow-[var(--retro-shadow-sm)] backdrop-blur supports-[backdrop-filter]:bg-background/90",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
height: "var(--app-topbar-height)",
|
||||
paddingTop: "env(safe-area-inset-top, 0px)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-6xl items-center px-4">
|
||||
<HabitBrand aria-current={isHome ? "page" : undefined} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { forwardRef } from "react";
|
||||
import { Home, LogIn, UserPlus } from "lucide-react";
|
||||
|
||||
import { routes } from "@/lib/routes";
|
||||
@@ -13,12 +14,26 @@ const authNavItems = [
|
||||
{ href: routes.signUp, label: "Sign up", icon: UserPlus },
|
||||
] as const;
|
||||
|
||||
export function AuthBottomNav() {
|
||||
export const AuthBottomNav = forwardRef<HTMLElement>(function AuthBottomNav(
|
||||
_props,
|
||||
ref,
|
||||
) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto grid max-w-lg grid-cols-3 gap-1 px-1 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
|
||||
<nav
|
||||
ref={ref}
|
||||
className="bottom-nav fixed inset-x-0 bottom-0 z-50 border-t-2 border-border bg-background shadow-[0_-4px_0_0_hsl(280_45%_28%/0.35)]"
|
||||
style={{
|
||||
bottom: "var(--app-viewport-bottom-offset, 0px)",
|
||||
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-secondary/40"
|
||||
/>
|
||||
<div className="mx-auto grid max-w-lg grid-cols-3 gap-1 px-2 pb-2 pt-2">
|
||||
{authNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
@@ -28,18 +43,20 @@ export function AuthBottomNav() {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-lg px-1 py-2 font-pixel text-[8px] font-normal leading-tight transition-colors",
|
||||
"bottom-nav-item flex flex-col items-center justify-center gap-1.5 overflow-hidden border-2 px-1 py-2 font-pixel text-[8px] leading-none transition-colors duration-75",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
? "border-secondary bg-primary text-primary-foreground shadow-[var(--retro-shadow-sm)]"
|
||||
: "border-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
<Icon className="bottom-nav-item-icon h-5 w-5 shrink-0" strokeWidth={2.5} />
|
||||
<span className="bottom-nav-item-label max-w-full truncate uppercase tracking-wide">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { AppNav } from "@/components/layout/app-nav";
|
||||
import { AppDataPrefetcher } from "@/components/layout/app-data-prefetcher";
|
||||
import { AuthBottomNav } from "@/components/layout/auth-bottom-nav";
|
||||
import {
|
||||
NavOffsetProvider,
|
||||
useNavOffsetRef,
|
||||
@@ -12,14 +13,20 @@ import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||
import { getOnboardingStatusClient } from "@/lib/onboarding-status";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
type NavKind = "none" | "auth" | "app";
|
||||
|
||||
function MeasuredAppNav() {
|
||||
const navRef = useNavOffsetRef();
|
||||
|
||||
return <AppNav ref={navRef} />;
|
||||
}
|
||||
|
||||
function MeasuredAuthNav() {
|
||||
const navRef = useNavOffsetRef();
|
||||
return <AuthBottomNav ref={navRef} />;
|
||||
}
|
||||
|
||||
export function GlobalBottomNav() {
|
||||
const [showNav, setShowNav] = useState(false);
|
||||
const [navKind, setNavKind] = useState<NavKind>("none");
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
@@ -30,12 +37,12 @@ export function GlobalBottomNav() {
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
setShowNav(false);
|
||||
setNavKind("auth");
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getOnboardingStatusClient();
|
||||
setShowNav(status?.appComplete === true);
|
||||
setNavKind(status?.appComplete === true ? "app" : "none");
|
||||
}
|
||||
|
||||
void syncNavState();
|
||||
@@ -55,9 +62,10 @@ export function GlobalBottomNav() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NavOffsetProvider enabled={showNav}>
|
||||
<AppDataPrefetcher enabled={showNav} />
|
||||
{showNav ? <MeasuredAppNav /> : null}
|
||||
<NavOffsetProvider enabled={navKind !== "none"}>
|
||||
<AppDataPrefetcher enabled={navKind === "app"} />
|
||||
{navKind === "app" ? <MeasuredAppNav /> : null}
|
||||
{navKind === "auth" ? <MeasuredAuthNav /> : null}
|
||||
</NavOffsetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,20 +3,33 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { HABIT_LOGO_SRC } from "@/lib/brand";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HABIT_LOGO_SRC = "/habit-logo.png";
|
||||
const logoSizeClass = {
|
||||
sm: "h-6 w-auto max-h-6",
|
||||
md: "h-8 w-auto max-h-8",
|
||||
/** Compact — leaves vertical padding inside the top bar */
|
||||
nav: "h-7 w-auto max-h-7 sm:h-8 sm:max-h-8",
|
||||
lg: "h-12 w-auto max-h-12 sm:h-14",
|
||||
hero: "h-16 w-auto max-h-16 sm:h-20 md:h-24",
|
||||
} as const;
|
||||
|
||||
type HabitBrandProps = {
|
||||
className?: string;
|
||||
href?: string;
|
||||
size?: keyof typeof logoSizeClass;
|
||||
/** Show retro frame around the logo (top bar / site header). */
|
||||
framed?: boolean;
|
||||
"aria-current"?: "page" | undefined;
|
||||
};
|
||||
|
||||
export function HabitBrand({
|
||||
className,
|
||||
href = routes.home,
|
||||
size = "sm",
|
||||
framed = true,
|
||||
"aria-current": ariaCurrent,
|
||||
}: HabitBrandProps) {
|
||||
const [useText, setUseText] = useState(false);
|
||||
@@ -24,10 +37,13 @@ export function HabitBrand({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label="Go to home"
|
||||
aria-label="HaBit home"
|
||||
aria-current={ariaCurrent}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center justify-center border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
|
||||
"group inline-flex shrink-0 items-center justify-center transition-transform hover:-translate-y-0.5",
|
||||
framed &&
|
||||
"border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)] hover:border-primary/60",
|
||||
!framed && "rounded-sm hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -35,14 +51,16 @@ export function HabitBrand({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={HABIT_LOGO_SRC}
|
||||
alt=""
|
||||
className="size-6 image-pixelated"
|
||||
alt="HaBit"
|
||||
className={cn("image-pixelated object-contain", logoSizeClass[size])}
|
||||
onError={() => setUseText(true)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="font-pixel select-none text-[10px] uppercase tracking-widest text-primary"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"font-pixel select-none uppercase tracking-widest text-primary",
|
||||
size === "hero" ? "text-base sm:text-lg" : "text-[10px]",
|
||||
)}
|
||||
>
|
||||
HaBit
|
||||
</span>
|
||||
|
||||
62
components/layout/header-coin-balance.tsx
Normal file
62
components/layout/header-coin-balance.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { Coins } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||
import { getCoins } from "@/lib/avatar-progression-storage";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export function HeaderCoinBalance() {
|
||||
const [coins, setCoins] = useState<number | null>(null);
|
||||
const [isSignedIn, setIsSignedIn] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
setIsSignedIn(false);
|
||||
setCoins(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSignedIn(true);
|
||||
|
||||
try {
|
||||
setCoins(await getCoins());
|
||||
} catch {
|
||||
setCoins(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
||||
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
|
||||
return () => {
|
||||
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, refresh);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
if (!isSignedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-10 items-center justify-center gap-2 border-2 border-primary/35 bg-primary/10 px-4 font-pixel text-[10px] font-normal uppercase tracking-wide text-primary shadow-[var(--retro-shadow-sm)]"
|
||||
aria-label={
|
||||
coins === null ? "Loading point balance" : `${coins} points available`
|
||||
}
|
||||
>
|
||||
<Coins aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span className="tabular-nums leading-none">
|
||||
{coins === null ? "…" : coins}
|
||||
</span>
|
||||
<span className="leading-none text-primary/80">pts</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { routes } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HomeLogoLinkProps = {
|
||||
className?: string;
|
||||
"aria-current"?: "page" | undefined;
|
||||
};
|
||||
|
||||
export function HomeLogoLink({
|
||||
className,
|
||||
"aria-current": ariaCurrent,
|
||||
}: HomeLogoLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={routes.avatar}
|
||||
aria-label="Go to home"
|
||||
aria-current={ariaCurrent}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center justify-center border-2 border-border bg-card/80 p-2 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className="size-8"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
<rect x="5" y="2" width="6" height="5" fill="#fdba74" stroke="#1e1b4b" strokeWidth="1" />
|
||||
<rect x="4" y="3" width="1" height="1" fill="#1e1b4b" />
|
||||
<rect x="11" y="3" width="1" height="1" fill="#1e1b4b" />
|
||||
<rect x="6" y="5" width="1" height="1" fill="#1e1b4b" />
|
||||
<rect x="9" y="5" width="1" height="1" fill="#1e1b4b" />
|
||||
<rect x="7" y="6" width="2" height="1" fill="#f472b6" />
|
||||
<rect x="4" y="7" width="8" height="5" fill="#6366f1" stroke="#1e1b4b" strokeWidth="1" />
|
||||
<rect x="5" y="12" width="2" height="3" fill="#334155" />
|
||||
<rect x="9" y="12" width="2" height="3" fill="#334155" />
|
||||
<rect x="2" y="4" width="1" height="1" fill="#f472b6" className="opacity-0 group-hover:opacity-100" />
|
||||
<rect x="13" y="4" width="1" height="1" fill="#f472b6" className="opacity-0 group-hover:opacity-100" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
export { HabitBrand as HomeLogoLink } from "@/components/layout/habit-brand";
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function LandingHeroActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild size="lg">
|
||||
<Link href={routes.avatar}>Go to your pet</Link>
|
||||
<Link href={routes.avatar}>Go to your bit</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<header className="w-full border-b border-b-foreground/10">
|
||||
<div className="mx-auto flex h-16 w-full max-w-5xl items-center justify-between px-5 text-sm">
|
||||
<Link href={routes.home} className="font-pixel text-xs tracking-tight">
|
||||
HaBit
|
||||
</Link>
|
||||
{!hasEnvVars ? (
|
||||
<EnvVarWarning />
|
||||
) : (
|
||||
<Suspense>
|
||||
<AuthButton />
|
||||
</Suspense>
|
||||
)}
|
||||
<header
|
||||
className="fixed inset-x-0 top-0 z-40 border-b border-border/50 bg-background"
|
||||
style={{
|
||||
height: "var(--app-topbar-height)",
|
||||
paddingTop: "env(safe-area-inset-top, 0px)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex h-full w-full max-w-5xl items-center justify-between gap-4 px-5">
|
||||
<HabitBrand href={routes.home} size="nav" framed={false} />
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3">
|
||||
{!hasEnvVars ? (
|
||||
<EnvVarWarning />
|
||||
) : (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<HeaderCoinBalance />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<AuthButton />
|
||||
</Suspense>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="w-full border-t py-8 text-center text-xs text-muted-foreground">
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center gap-4 px-5">
|
||||
<p>Build habits. Care for your pet. Feel better every day.</p>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AvatarCustomizeLayout
|
||||
eyebrow="Step 2 of 2"
|
||||
title="Create your Habit Pet"
|
||||
description="Customize your pet's look, give them a name, and confirm before entering the app."
|
||||
title="Create your bit"
|
||||
description="Customize your digital self, give them a name, and confirm before entering the app."
|
||||
>
|
||||
<CharacterCreator
|
||||
initialCustomization={defaultAvatarCustomization}
|
||||
|
||||
@@ -54,10 +54,10 @@ export function OnboardingQuizForm() {
|
||||
Step 1 of 2
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">
|
||||
Welcome to Habit Pet
|
||||
Welcome to HaBit
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose a focus area, then design your pet in the next step.
|
||||
Choose a focus area, then design your bit in the next step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<WanderPosition>({
|
||||
x: PET_WANDER_START.x,
|
||||
@@ -80,16 +90,45 @@ export function AnimatedPetSprite({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleActivate = () => {
|
||||
if (!interactive) {
|
||||
return;
|
||||
}
|
||||
|
||||
onInteract?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none absolute z-10", className)}
|
||||
className={cn(
|
||||
"absolute z-10",
|
||||
interactive
|
||||
? "pointer-events-auto cursor-pointer"
|
||||
: "pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: `${position.x}%`,
|
||||
top: `${position.y}%`,
|
||||
transform: "translate(-50%, -100%)",
|
||||
transition: "left 1.8s steps(10), top 1.8s steps(10)",
|
||||
}}
|
||||
role={interactive ? "button" : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-label={interactive ? "Check how your bit is feeling" : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleActivate();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleActivate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PetEmotionBubble mood={mood} visible={emotionVisible} />
|
||||
<div
|
||||
style={{
|
||||
transform: position.facing === "left" ? "scaleX(-1)" : undefined,
|
||||
|
||||
40
components/pet/pet-emotion-bubble.tsx
Normal file
40
components/pet/pet-emotion-bubble.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { AvatarMood } from "@/lib/avatar-state";
|
||||
import { getPetStatusImagePath } from "@/lib/pet-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PetEmotionBubbleProps = {
|
||||
mood: AvatarMood;
|
||||
visible: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PetEmotionBubble({
|
||||
mood,
|
||||
visible,
|
||||
className,
|
||||
}: PetEmotionBubbleProps) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-full left-1/2 z-20 mb-1 w-[clamp(3.5rem,18vw,5.5rem)] -translate-x-1/2",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="animate-emotion-pop">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPetStatusImagePath(mood)}
|
||||
alt=""
|
||||
className="h-auto w-full object-contain image-pixelated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
components/pet/pet-emotion-caption.tsx
Normal file
35
components/pet/pet-emotion-caption.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 bottom-2 z-30 flex justify-center",
|
||||
"animate-emotion-pop",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="lcd-readable-label max-w-full">{getPetEmotionMessage(mood)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>;
|
||||
};
|
||||
|
||||
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<number | null>(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 (
|
||||
<div className={shellClassName}>
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Waking up pet...
|
||||
Waking up your bit...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -83,24 +119,48 @@ export function PetHabitat({
|
||||
|
||||
return (
|
||||
<div className={shellClassName}>
|
||||
<div className="mb-3 shrink-0 border-b-2 border-border/60 pb-3">
|
||||
<p className="text-sm text-foreground">{customization.name}</p>
|
||||
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
|
||||
{MOOD_LABELS[condition.mood]}
|
||||
</p>
|
||||
</div>
|
||||
{showNameAboveShell ? (
|
||||
<div className="mb-3 shrink-0 border-b-2 border-border/60 pb-3">
|
||||
<p className="text-sm text-foreground">{customization.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={lcdRef} className={lcdClassName}>
|
||||
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
|
||||
<AnimatedPetSprite customization={customization} petScale={petScale} />
|
||||
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5">
|
||||
<PixelGauge type="heart" value={condition.health} variant="overlay" />
|
||||
<PixelGauge type="energy" value={condition.energy} variant="overlay" />
|
||||
<AnimatedPetSprite
|
||||
customization={customization}
|
||||
petScale={petScale}
|
||||
interactive={interactive}
|
||||
emotionVisible={emotionVisible}
|
||||
mood={condition.mood}
|
||||
onInteract={showEmotionBriefly}
|
||||
/>
|
||||
<PetEmotionCaption mood={condition.mood} visible={emotionVisible} />
|
||||
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1 sm:gap-1.5">
|
||||
<PixelGauge
|
||||
type="heart"
|
||||
value={condition.health}
|
||||
variant="overlay"
|
||||
overlaySize={gaugeOverlaySize}
|
||||
/>
|
||||
<PixelGauge
|
||||
type="energy"
|
||||
value={condition.energy}
|
||||
variant="overlay"
|
||||
overlaySize={gaugeOverlaySize}
|
||||
/>
|
||||
</div>
|
||||
{showStyleLink ? (
|
||||
<div className="absolute right-2 top-2 z-20">
|
||||
<PetStyleLink />
|
||||
</div>
|
||||
{showNameInLcdOverlay ? (
|
||||
<p
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-2 top-2 z-20 max-w-[52%] truncate text-right lcd-readable-name",
|
||||
fillViewport
|
||||
? "text-sm sm:text-base md:text-lg"
|
||||
: "lcd-readable-name-landing",
|
||||
)}
|
||||
>
|
||||
{customization.name}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function PetStyleLink({ className }: PetStyleLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={routes.customize}
|
||||
aria-label="Customize pet style"
|
||||
aria-label="Customize bit style"
|
||||
title="Style"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center border-2 border-border bg-card/90 p-2 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
|
||||
|
||||
@@ -11,6 +11,8 @@ type PixelGaugeProps = {
|
||||
max?: number;
|
||||
className?: string;
|
||||
variant?: "default" | "overlay";
|
||||
/** Overlay icon size. Use "responsive" on the bit tab LCD. */
|
||||
overlaySize?: "md" | "lg" | "responsive";
|
||||
};
|
||||
|
||||
export function PixelGauge({
|
||||
@@ -19,6 +21,7 @@ export function PixelGauge({
|
||||
max = WELLNESS_SCALE_MAX,
|
||||
className,
|
||||
variant = "default",
|
||||
overlaySize = "md",
|
||||
}: PixelGaugeProps) {
|
||||
const filled = Math.max(0, Math.min(max, Math.round(value)));
|
||||
const Icon = type === "heart" ? Heart : Zap;
|
||||
@@ -29,6 +32,12 @@ export function PixelGauge({
|
||||
: "fill-amber-300 text-amber-300";
|
||||
const emptyClass = "text-muted-foreground/35";
|
||||
const isOverlay = variant === "overlay";
|
||||
const overlayIconClass =
|
||||
overlaySize === "responsive"
|
||||
? "h-[clamp(1.125rem,3.6vw,3rem)] w-[clamp(1.125rem,3.6vw,3rem)]"
|
||||
: overlaySize === "lg"
|
||||
? "size-12"
|
||||
: "size-6";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -44,13 +53,22 @@ export function PixelGauge({
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={cn("flex items-center", isOverlay ? "gap-1" : "gap-0.5")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
isOverlay
|
||||
? overlaySize === "responsive"
|
||||
? "gap-0.5 sm:gap-1"
|
||||
: "gap-1"
|
||||
: "gap-0.5",
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: max }, (_, index) => (
|
||||
<Icon
|
||||
key={`${type}-${index}`}
|
||||
className={cn(
|
||||
"shrink-0 stroke-[2.5]",
|
||||
isOverlay ? "size-6" : "size-4",
|
||||
isOverlay ? overlayIconClass : "size-4",
|
||||
index < filled ? activeClass : emptyClass,
|
||||
)}
|
||||
aria-hidden
|
||||
|
||||
@@ -32,7 +32,7 @@ export function RoomBackgroundPicker({
|
||||
Room background
|
||||
</h3>
|
||||
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||
Pick the scene behind your pet. Premium rooms unlock in the shop.
|
||||
Pick the scene behind your bit. Premium rooms unlock in the shop.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -266,7 +266,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Focus topic preference</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -288,7 +288,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Avatar preferences</CardTitle>
|
||||
<CardDescription>Personalize your pet's vibe.</CardDescription>
|
||||
<CardDescription>Personalize your bit's vibe.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{avatarVibeOptions.map((vibe) => (
|
||||
@@ -369,7 +369,7 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
|
||||
@@ -59,7 +59,7 @@ export function InAppReminderBanner() {
|
||||
router.push(reminder.targetUrl);
|
||||
}}
|
||||
>
|
||||
Open Pet tab
|
||||
Open Bit tab
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Coins } from "lucide-react";
|
||||
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 { Card, CardContent } from "@/components/ui/card";
|
||||
import { useToast } from "@/components/ui/toast-provider";
|
||||
@@ -24,9 +24,47 @@ import {
|
||||
type ShopItemRecord,
|
||||
type ShopLayerId,
|
||||
} from "@/lib/shop-catalog";
|
||||
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
|
||||
function ShopItemPriceBadge({
|
||||
price,
|
||||
owned,
|
||||
equipped,
|
||||
}: {
|
||||
price: number;
|
||||
owned: boolean;
|
||||
equipped?: boolean;
|
||||
}) {
|
||||
const boxClassName =
|
||||
"inline-flex shrink-0 items-center justify-center gap-2.5 border-2 px-5 font-pixel text-[13px] font-normal uppercase tracking-wide leading-none shadow-[var(--retro-shadow-sm)] h-12";
|
||||
|
||||
if (owned) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
boxClassName,
|
||||
"border-border/60 bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{equipped ? "Equipped" : "Owned"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
boxClassName,
|
||||
"border-primary/35 bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
<Coins aria-hidden="true" className="size-5 shrink-0" />
|
||||
<span className="tabular-nums">{price}</span>
|
||||
<span className="text-primary/80">pts</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SHOP_LAYER_ORDER: ShopLayerId[] = [
|
||||
"head",
|
||||
@@ -94,13 +132,15 @@ function ShopItemCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-xs leading-snug">{item.name}</p>
|
||||
<Badge className="shrink-0 px-1.5 py-0.5 text-[8px]">
|
||||
{item.price} pts
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
|
||||
<p className="min-w-0 pt-1 text-sm font-medium leading-snug">
|
||||
{item.name}
|
||||
</p>
|
||||
<ShopItemPriceBadge
|
||||
price={item.price}
|
||||
owned={owned}
|
||||
equipped={equipped}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -287,15 +327,6 @@ export function ShopContent() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-5 flex items-center justify-between gap-3 border-2 border-border bg-muted/40 px-4 py-3 shadow-[var(--retro-shadow-sm)]">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Your balance
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{coins === null ? "..." : `${coins} points`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mb-4 text-xs text-red-500">{error}</p> : null}
|
||||
|
||||
{!error && coins !== null && items.length === 0 ? (
|
||||
@@ -312,7 +343,7 @@ export function ShopContent() {
|
||||
<div className="grid gap-8">
|
||||
{roomItems.length > 0 ? (
|
||||
<section className="grid gap-3">
|
||||
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Room backgrounds
|
||||
</h3>
|
||||
<div className={itemGridClassName}>
|
||||
@@ -335,7 +366,7 @@ export function ShopContent() {
|
||||
|
||||
{styleGroups.map((group) => (
|
||||
<section key={group.layerId} className="grid gap-3">
|
||||
<h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</h3>
|
||||
<div className={itemGridClassName}>
|
||||
|
||||
@@ -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
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm">{item.name}</p>
|
||||
</div>
|
||||
@@ -119,13 +119,16 @@ export function ShopItemPreviewModal({
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
||||
</div>
|
||||
<p className="mt-3 text-center text-[10px] text-muted-foreground">
|
||||
Preview only — nothing is purchased or equipped until you confirm.
|
||||
This is a preview only. Nothing is purchased or equipped until you confirm.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-2 border-t-2 border-border pt-4">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span>{item.price} points</span>
|
||||
<div className="flex items-center justify-end gap-1.5 border-2 border-primary/30 bg-primary/5 px-3 py-2.5">
|
||||
<Coins aria-hidden="true" className="size-4 text-primary" />
|
||||
<span className="text-xl font-bold tabular-nums text-primary">
|
||||
{item.price}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">points</span>
|
||||
</div>
|
||||
|
||||
{owned ? (
|
||||
|
||||
Reference in New Issue
Block a user