Files
pargar/frontend/src/components/app-shell.tsx
T
ahpandClaude Sonnet 4.6 089c114019 feat: mobile-first responsive overhaul — safe areas, touch targets, bottom sheet, bottom nav
Viewport:
- Add `viewport` export with `viewport-fit=cover` (enables CSS safe-area-inset-*
  on bezel-less iOS/Android) and `interactive-widget=resizes-content` (prevents
  layout jumps from virtual keyboard open/close)

iOS zoom prevention:
- input.tsx, textarea.tsx: change text-sm (14px) → text-base sm:text-sm (16px)
  on mobile; iOS Safari auto-zooms any input with font-size < 16px

Touch targets:
- button.tsx: default h-10 → h-11 (44px), sm h-8 → h-9 (36px), icon h-10→h-11
  matching the 44×44px iOS HIG and WCAG 2.5.5 minimum
- Add touch-manipulation to buttons, inputs, textarea to eliminate 300ms tap delay

Dialog → mobile bottom sheet:
- On mobile (< 640px): anchored to bottom with full width, rounded top corners,
  slide-in-from-bottom / slide-out-to-bottom animation, drag handle affordance,
  max-h-[90svh] + overflow-y-auto to prevent viewport overflow
- Safe area padding: pb-[max(1.25rem,env(safe-area-inset-bottom))]
- On desktop (sm+): unchanged centered modal with zoom animation
- Close button gets a 32×32 tap area on mobile

Mobile navigation:
- New bottom-nav.tsx: persistent 3-tab bottom bar (مسیر/مرکز/لیگ) shown below
  md: breakpoint, hidden on desktop; 56px height + safe-area-inset-bottom;
  active tab gets heavier stroke and scale
- Only shown for authenticated non-admin users
- app-shell.tsx: injects BottomNav; main content gets dynamic padding-bottom to
  clear the bar (56px + safe area) on mobile, reverts to py-10 on md:
- Footer: safe-area-inset-bottom padding so content clears iPhone home indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:51:37 +03:30

169 lines
5.7 KiB
TypeScript

"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Navbar } from "@/components/navbar";
import { ChatWidget } from "@/components/mentor-chat";
import { BrandMark } from "@/components/brand-mark";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { http } from "@/lib/api";
import type { User } from "@/lib/types";
import { hasInfiniteHearts } from "@/lib/hearts";
import { BottomNav } from "@/components/bottom-nav";
function SiteFooter() {
return (
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
<BrandMark size="sm" logoOnly />
<p className="text-xs text-muted-foreground">بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر</p>
</div>
);
}
function isPublic(p: string) {
return p === "/" || p === "/login";
}
const PROTECTED_PREFIXES = [
"/cap",
"/unwrap",
"/leaderboard",
"/admin",
"/player",
"/quiz",
"/notifications",
"/lockout",
"/profile",
"/settings",
];
function needsAuth(p: string) {
if (isPublic(p)) return false;
return PROTECTED_PREFIXES.some((prefix) => p === prefix || p.startsWith(prefix + "/"));
}
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const hydrated = useAuthHydrated();
const { user, setUser, accessToken, clear } = useAuth();
const [bootDone, setBootDone] = useState(false);
// Wait for persist hydration, then optionally refresh /me
useEffect(() => {
if (!hydrated) return;
let cancelled = false;
const boot = async () => {
if (!accessToken) {
if (!cancelled) setBootDone(true);
return;
}
try {
const { user: me } = await http.get<{ user: User }>("/api/auth/me");
if (!cancelled) setUser(me);
} catch {
if (!cancelled) clear();
} finally {
if (!cancelled) setBootDone(true);
}
};
void boot();
return () => {
cancelled = true;
};
}, [hydrated, accessToken, clear, setUser]);
const ready = hydrated && bootDone;
const requiresAuth = needsAuth(pathname);
// Redirects only in effects — never during render
useEffect(() => {
if (!ready) return;
if (!user && requiresAuth) {
router.replace("/login");
return;
}
if (user && pathname === "/login") {
router.replace("/cap");
return;
}
if (
user?.isLocked &&
!hasInfiniteHearts(user.role) &&
pathname !== "/lockout" &&
pathname !== "/unwrap" &&
requiresAuth
) {
router.replace("/lockout");
}
}, [ready, user, requiresAuth, pathname, router]);
if (!ready) {
return (
<div className="flex min-h-screen flex-col">
<div aria-hidden className="h-1 w-full bg-gradient-to-l from-primary via-accent to-gold" />
<div className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-3.5 w-28" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-24 rounded-xl" />
</div>
</div>
</div>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6">
<div className="space-y-6">
<Skeleton className="h-8 w-56" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-2xl" />
))}
</div>
</div>
</main>
</div>
);
}
// Protected routes: never mount children when logged out. Unknown URLs (404) stay visible.
const allowChildren = !requiresAuth || !!user;
const showLockBanner = !!user?.isLocked && !hasInfiniteHearts(user.role) && pathname !== "/lockout";
return (
<div className="flex min-h-screen flex-col">
<div aria-hidden className="h-1 w-full bg-gradient-to-l from-primary via-accent to-gold" />
<Navbar />
{showLockBanner && (
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-center text-sm font-medium text-destructive">
حساب شما قفل شده است — رفع محدودیت فقط با بررسی مدیریت انجام می‌شود.
<button
type="button"
className="underline underline-offset-2 hover:text-destructive/80"
onClick={() => router.push("/lockout")}
>
صفحه قفل
</button>
</div>
)}
<main
className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 md:pb-10"
// Bottom nav is 56px + safe area; only shown for authed non-admin on mobile
style={user && !pathname.startsWith("/admin") ? { paddingBottom: "calc(4.5rem + env(safe-area-inset-bottom, 0px))" } : undefined}
>
{allowChildren ? children : null}
</main>
<footer
className="border-t border-border/70 bg-muted/20 backdrop-blur"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<SiteFooter />
</footer>
{user && <ChatWidget />}
{user && !pathname.startsWith("/admin") && <BottomNav />}
</div>
);
}