Improvements to the UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user