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>
169 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|