From 50b5a5e145fd0f9fd0b20f5fb6ec55983527459b Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 15 Aug 2026 23:17:32 +0330 Subject: [PATCH 1/3] f --- src/app/layout.tsx | 6 +- src/components/Componentes/token-switcher.tsx | 236 +++++++++++++++--- 2 files changed, 197 insertions(+), 45 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5f53462..33d61f6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -211,11 +211,7 @@ export default function RootLayout({ suppressHydrationWarning > - {isDevelopment ? ( -
- -
- ) : null} + {isDevelopment ? : null}
{children}
{isDevelopment ? : null} diff --git a/src/components/Componentes/token-switcher.tsx b/src/components/Componentes/token-switcher.tsx index 2d00828..98beb24 100644 --- a/src/components/Componentes/token-switcher.tsx +++ b/src/components/Componentes/token-switcher.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { MdOutlineSwitchAccount } from "react-icons/md"; +import { useEffect, useRef, useState } from "react"; +import { MdOutlineSwitchAccount, MdDragIndicator } from "react-icons/md"; import { getClientCookie, setClientCookie } from "@/lib/cookies"; import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; @@ -14,22 +14,37 @@ export const MALE_EMAIL = "muhammadamin.ghorbani@gmail.com"; export const MALE_2_EMAIL = "habibwabackup@gmail.com"; const TOKEN_COOKIE_NAME = "HABIB_TOKEN"; +const STORAGE_POS_KEY = "DEV_TOKEN_SWITCHER_POS"; type TokenSwitcherProps = { variant?: "default" | "transparent"; className?: string; + isFloating?: boolean; }; export function TokenSwitcher({ variant = "default", className, + isFloating = true, }: TokenSwitcherProps) { const [isOpen, setIsOpen] = useState(false); const [currentToken, setCurrentToken] = useState(""); const [customTokenInput, setCustomTokenInput] = useState(""); - const [isCustomInputOpen, setIsCustomInputOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + + const containerRef = useRef(null); + const dragStart = useRef({ + x: 0, + y: 0, + startX: 0, + startY: 0, + hasMoved: false, + }); useEffect(() => { + setMounted(true); if (typeof window !== "undefined") { const token = (window as any).HABIB_TOKEN ?? @@ -38,8 +53,45 @@ export function TokenSwitcher({ sessionStorage.getItem(TOKEN_COOKIE_NAME) ?? ""; setCurrentToken(token); + + if (isFloating) { + try { + const savedPos = localStorage.getItem(STORAGE_POS_KEY); + if (savedPos) { + const parsed = JSON.parse(savedPos); + if (typeof parsed.x === "number" && typeof parsed.y === "number") { + const clampedX = Math.max(10, Math.min(window.innerWidth - 180, parsed.x)); + const clampedY = Math.max(10, Math.min(window.innerHeight - 50, parsed.y)); + setPosition({ x: clampedX, y: clampedY }); + return; + } + } + } catch (e) { + console.warn("Failed to load dev token switcher position:", e); + } + // Default initial position (top-left) + setPosition({ x: 12, y: 12 }); + } } - }, []); + }, [isFloating]); + + // Keep floating position bounded on window resize + useEffect(() => { + if (!isFloating) return; + const handleResize = () => { + setPosition((prev) => { + if (!prev) return null; + const width = containerRef.current?.offsetWidth || 180; + const height = containerRef.current?.offsetHeight || 50; + return { + x: Math.max(10, Math.min(window.innerWidth - width - 10, prev.x)), + y: Math.max(10, Math.min(window.innerHeight - height - 10, prev.y)), + }; + }); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [isFloating]); const isMale = currentToken === MALE_TOKEN; const isMale2 = currentToken === MALE_2_TOKEN; @@ -52,7 +104,6 @@ export function TokenSwitcher({ if (!trimmed) return; if (typeof window !== "undefined") { - // Clear local storage and session storage so answers and cached state from previous user are removed try { window.localStorage.clear(); window.sessionStorage.clear(); @@ -72,7 +123,6 @@ export function TokenSwitcher({ setCurrentToken(trimmed); setIsOpen(false); - // Navigate to the beginning of the flow (intro page) window.location.replace("/"); } }; @@ -88,7 +138,6 @@ export function TokenSwitcher({ } try { - // 1. Clear local storage and session storage completely if (typeof window !== "undefined") { try { window.localStorage.clear(); @@ -98,7 +147,6 @@ export function TokenSwitcher({ } } - // 2. Call backend reset script via API route if it is a real DB user if (userId !== null) { const response = await fetch("/api/dev-reset-profile", { method: "POST", @@ -115,7 +163,6 @@ export function TokenSwitcher({ } } - // 3. Set the target token in the cookies and redirect to / const targetToken = userId === 17119 ? MALE_TOKEN @@ -146,41 +193,150 @@ export function TokenSwitcher({ } }; + // Pointer drag handlers + const handlePointerDown = (e: React.PointerEvent) => { + if (!isFloating || isOpen) return; + + e.currentTarget.setPointerCapture(e.pointerId); + setIsDragging(true); + const startX = position?.x ?? 12; + const startY = position?.y ?? 12; + dragStart.current = { + x: e.clientX, + y: e.clientY, + startX, + startY, + hasMoved: false, + }; + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDragging || !isFloating) return; + + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + + if (Math.hypot(dx, dy) > 4) { + dragStart.current.hasMoved = true; + } + + const width = containerRef.current?.offsetWidth || 140; + const height = containerRef.current?.offsetHeight || 40; + + const newX = Math.max( + 10, + Math.min(window.innerWidth - width - 10, dragStart.current.startX + dx) + ); + const newY = Math.max( + 10, + Math.min(window.innerHeight - height - 10, dragStart.current.startY + dy) + ); + + setPosition({ x: newX, y: newY }); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!isDragging || !isFloating) return; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch (err) {} + setIsDragging(false); + + if (dragStart.current.hasMoved) { + if (position) { + try { + localStorage.setItem(STORAGE_POS_KEY, JSON.stringify(position)); + } catch (e) {} + } + } + }; + + const handleBadgeClick = (e: React.MouseEvent) => { + if (dragStart.current.hasMoved) { + e.preventDefault(); + e.stopPropagation(); + return; + } + setIsOpen(true); + }; + + const currentRoleLabel = isMale + ? "آقا 👨" + : isMale2 + ? "آقا ۲ 👨" + : isFemale + ? "خانم 👩" + : isNoToken + ? "بدون توکن 👤" + : "سفارشی 🔑"; + + if (!mounted && isFloating) { + return null; + } + + const badgeContent = ( +
setIsDragging(false)} + onClick={handleBadgeClick} + style={ + isFloating + ? { + position: "fixed", + left: position ? `${position.x}px` : "12px", + top: position ? `${position.y}px` : "12px", + zIndex: 99999, + touchAction: "none", + userSelect: "none", + } + : undefined + } + className={[ + isFloating + ? "fixed flex items-center gap-1.5 px-3 py-2 rounded-[15px] backdrop-blur-md shadow-md border select-none transition-shadow cursor-pointer" + : "inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 shadow-md border backdrop-blur-md cursor-pointer", + variant === "transparent" + ? "bg-black/60 text-white border-white/20 hover:bg-black/70" + : "bg-white/95 text-slate-800 border-slate-200/80 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100 dark:border-slate-700/80", + isDragging + ? "cursor-grabbing shadow-2xl ring-2 ring-rose-500/50 scale-[1.02]" + : "cursor-grab active:scale-95", + className, + ] + .filter(Boolean) + .join(" ")} + title="تغییر توکن کاربر (تست) - برای جابجایی بکشید / برای انتخاب کلیک کنید" + > + {/* Drag handle */} + {isFloating && ( +
+ +
+ )} + + {/* Role icon & selected account label only */} + + {currentRoleLabel} +
+ ); + return ( <> - + {badgeContent} {isOpen && ( -
+
setIsOpen(false)} + >
e.stopPropagation()} >
@@ -191,7 +347,7 @@ export function TokenSwitcher({ From 34c316490368187b2167150da8a5c702099e56b6 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 16 Aug 2026 02:11:11 +0330 Subject: [PATCH 2/3] feat(core): optimize bootstrap, locale routing, and assessment progress Refactor the application initialization and data fetching strategy to improve performance, reliability, and user experience, especially within the Flutter webview context. Key changes: - **Bootstrap & Initialization**: Implemented a `beforeInteractive` bootstrap script to handle locale correction and safe-area insets before the app becomes visible, reducing layout shift and flickering. - **Locale Routing**: Enhanced the middleware proxy to prioritize authoritative cookie-based locales and improved `Accept-Language` header parsing to support complex device headers. - **Assessment Progress**: Introduced a local assessment progress tracking mechanism that merges server-side completion data with local `localStorage` drafts, ensuring users don't lose progress during transitions. - **Data Fetching**: Optimized React Query configurations by increasing `staleTime` and disabling aggressive refetching on window focus/mount to reduce unnecessary network overhead. - **UI/UX Improvements**: - Replaced `next/image` icon loading with an inline `UiIcon` component to eliminate per-icon network requests and ensure icons are available in the initial HTML. - Implemented a Flutter-style skeleton shimmer animation using CSS transforms for smoother performance in the webview. - Updated CSS variables to use `env(safe-area-inset-*)` for better mobile compatibility. - **Refactoring**: - Consolidated marriage-related data fetching into a unified `useFormSchemaQuery` pattern. - Simplified component logic by removing redundant `silent-reloader` calls and manual cache-busting headers. - Improved type safety for the Flutter bridge and window global objects. --- proxy.test.ts | 72 ++++ proxy.ts | 42 ++- src/app/[lang]/layout.tsx | 5 +- src/app/globals.css | 129 +++++-- src/app/layout.tsx | 127 ++++++- src/app/providers.tsx | 8 +- .../[slug]/question-detail-client.tsx | 8 +- src/app/questions-list/page.tsx | 130 ++++--- src/app/questions-list/sections-request.tsx | 5 +- .../Componentes/flutter-locale-sync.tsx | 49 ++- .../Componentes/info-progress-card.tsx | 28 +- .../Componentes/navigation-button.tsx | 50 +-- .../Componentes/page-background.tsx | 51 ++- .../Componentes/question-answer-storage.tsx | 67 +++- src/components/Componentes/question-card.tsx | 59 +-- .../question-exit-navigation-button.tsx | 4 - .../Componentes/question-section-flow.tsx | 6 +- .../Componentes/silent-reloader.tsx | 65 +--- .../Componentes/test-questions-flow.tsx | 3 +- src/components/Componentes/ui-icon.tsx | 335 ++++++++++++++++++ src/hooks/marriage/types.ts | 1 + src/hooks/marriage/use-cattell.ts | 30 ++ src/hooks/marriage/use-form-schema.ts | 21 +- src/hooks/marriage/use-glasser.ts | 30 ++ src/hooks/marriage/use-section-data.ts | 118 ++---- src/hooks/marriage/use-sections.ts | 71 ++-- src/hooks/useFlutterBridge.ts | 9 +- src/lib/assessment-progress.test.ts | 14 + src/lib/assessment-progress.ts | 17 + src/lib/auth-bridge.ts | 3 - src/lib/http.ts | 14 - src/lib/view-paddings.ts | 42 ++- src/translations/provider.tsx | 19 +- src/types/window.d.ts | 2 + 34 files changed, 1080 insertions(+), 554 deletions(-) create mode 100644 proxy.test.ts create mode 100644 src/components/Componentes/ui-icon.tsx create mode 100644 src/lib/assessment-progress.test.ts create mode 100644 src/lib/assessment-progress.ts diff --git a/proxy.test.ts b/proxy.test.ts new file mode 100644 index 0000000..2be38f0 --- /dev/null +++ b/proxy.test.ts @@ -0,0 +1,72 @@ +import { NextRequest } from "next/server"; +import { describe, expect, it } from "vitest"; +import { config, proxy } from "./proxy"; + +function request(path: string, cookie?: string, acceptLanguage?: string) { + return new NextRequest(`https://example.test${path}`, { + headers: { + ...(cookie ? { cookie } : {}), + ...(acceptLanguage ? { "accept-language": acceptLanguage } : {}), + }, + }); +} + +describe("locale proxy", () => { + it("redirects a localized path when the authoritative cookie differs", () => { + const response = proxy(request("/en/questions-list", "HABIB_LANGUAGE=fa")); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://example.test/fa/questions-list", + ); + }); + + it("passes the path locale to the root layout", () => { + const response = proxy(request("/fa/questions-list", "HABIB_LANGUAGE=fa")); + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-request-x-habib-locale")).toBe( + "fa", + ); + }); + + it("excludes health checks from locale routing", () => { + expect(config.matcher[0]).toContain("healthz"); + }); + + it("falls back to the first supported accept-language entry", () => { + const response = proxy( + request("/questions-list", undefined, "fa-IR, fa;q=0.9, en;q=0.8"), + ); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://example.test/fa/questions-list", + ); + }); + + it("normalizes region subtags in accept-language", () => { + const response = proxy( + request("/questions-list", undefined, "en-US, en;q=0.9"), + ); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://example.test/en/questions-list", + ); + }); + + it("skips wildcard and unsupported accept-language entries", () => { + const response = proxy(request("/questions-list", undefined, "*, und")); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://example.test/en/questions-list", + ); + }); + + it("keeps the cookie authoritative over accept-language", () => { + const response = proxy( + request("/en/questions-list", "HABIB_LANGUAGE=fa", "en-US, en;q=0.9"), + ); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://example.test/fa/questions-list", + ); + }); +}); diff --git a/proxy.ts b/proxy.ts index 0b35cd1..550a13e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -5,12 +5,22 @@ function getPreferredLocale(request: NextRequest) { const cookieLocale = request.cookies.get("HABIB_LANGUAGE")?.value ?? request.cookies.get("habib_language")?.value; - const acceptLanguage = request.headers.get("accept-language") ?? ""; - const acceptedLocale = acceptLanguage.split(",")[0]?.split("-")[0]; - for (const locale of [cookieLocale, acceptedLocale]) { - const normalizedLocale = locale?.trim().toLowerCase(); - if (isLocale(normalizedLocale)) return normalizedLocale; + if (cookieLocale) { + const normalizedCookie = cookieLocale.trim().toLowerCase(); + if (isLocale(normalizedCookie)) return normalizedCookie; + } + + // Walk the full Accept-Language list (not only the first entry): some + // devices send `fa-IR, fa;q=0.9, en;q=0.8` where the region subtag belongs + // to the first entry but a bare supported language follows. Normalize + // region/case (`fa-IR` → `fa`, `zh-Hans-CN` → `zh`) and skip wildcards. + const acceptLanguage = request.headers.get("accept-language") ?? ""; + for (const part of acceptLanguage.split(",")) { + const tag = part.split(";")[0]?.trim().toLowerCase(); + if (!tag || tag === "*") continue; + const language = tag.split("-")[0]; + if (isLocale(language)) return language; } return defaultLocale; @@ -18,10 +28,26 @@ function getPreferredLocale(request: NextRequest) { export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - const pathnameHasLocale = isLocale(pathname.split("/")[1]); + const pathnameLocale = pathname.split("/")[1]; + const pathnameHasLocale = isLocale(pathnameLocale); + const cookieLocale = + request.cookies.get("HABIB_LANGUAGE")?.value ?? + request.cookies.get("habib_language")?.value; + const authoritativeLocale = isLocale(cookieLocale?.toLowerCase()) + ? cookieLocale.toLowerCase() + : null; if (pathnameHasLocale) { - return NextResponse.next(); + if (authoritativeLocale && authoritativeLocale !== pathnameLocale) { + const url = request.nextUrl.clone(); + const segments = pathname.split("/"); + segments[1] = authoritativeLocale; + url.pathname = segments.join("/"); + return NextResponse.redirect(url); + } + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-habib-locale", pathnameLocale); + return NextResponse.next({ request: { headers: requestHeaders } }); } const locale = getPreferredLocale(request); @@ -32,5 +58,5 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: ["/((?!api|_next|favicon.ico|assets|fonts).*)"], + matcher: ["/((?!api|_next|favicon.ico|healthz|assets|fonts).*)"], }; diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 9ec5569..e8556bd 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,10 +1,9 @@ import { notFound } from "next/navigation"; -import LanguageSwitcher from "@/components/Componentes/language-switcher"; -import { isLocale, localeDirections } from "@/translations/config"; +import { isLocale, localeDirections, locales } from "@/translations/config"; import { I18nProvider } from "@/translations/provider"; export function generateStaticParams() { - return [{ lang: "en" }, { lang: "fa" }]; + return locales.map((lang) => ({ lang })); } export default async function LocaleLayout({ diff --git a/src/app/globals.css b/src/app/globals.css index 4e62b46..a01a36c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,10 +2,10 @@ :root { --default-page-background-image: url("/assets/images/home-Checkups-List.svg"); - --safe-top: 0px; - --safe-bottom: 0px; - --safe-left: 0px; - --safe-right: 0px; + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); + --safe-right: env(safe-area-inset-right, 0px); /* ─── Semantic Tokens (Light) ─── */ --semantic-neutral-bg: #f1f5f9; @@ -191,6 +191,10 @@ html:lang(ar) body, background-size: cover; } +html[data-web-bootstrap="pending"] .app-shell { + visibility: hidden; +} + @media (min-width: 640px) { .app-shell { width: 375px; @@ -201,10 +205,24 @@ body[data-page-background="none"] .app-shell { background-image: none; } +.app-shell:has(.page-background-none) { + background-image: none; +} + body[data-page-background="custom"] .app-shell { background-image: var(--page-background-image); } +.app-shell:has(.page-background-custom) { + background-image: var(--page-background-image); +} + +.page-background-none, +.page-background-custom, +.page-background-default { + display: none; +} + .question-slider-range { height: 18px; width: 100%; @@ -285,50 +303,97 @@ body[data-page-background="custom"] .app-shell { animation-delay: 0.3s; } -/* ─── Premium Habib Shimmer Animations ─── */ -@keyframes shimmer { - 0% { - background-position: 200% 0; +/* ─── Flutter AppShimmer-style skeleton ─── + Mirrors najm's AppShimmer (shimmer 3.0.0): + - 1500ms linear sweep, no easing curve + - one soft band travels -100% -> +100% on a composited transform + (no background-position repaint, so it stays smooth inside the + Flutter webview instead of flickering) + - gradient stops mirror Shimmer.fromColors: base .0 / base .35 / + highlight .5 / base .65 / base 1.0 + - light mode colors are derived from a white scaffold the same way + AppShimmer lerps scaffold bg toward black (base +5%, band peaks +10%) */ +@keyframes skeleton-sweep { + from { + transform: translateX(-100%); } - 100% { - background-position: -200% 0; + to { + transform: translateX(100%); } } .shimmer-bg { + position: relative; + overflow: hidden; + isolation: isolate; + background-color: rgba(0, 0, 0, 0.05); +} + +.shimmer-bg::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + transform: translateX(-100%); background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.04) 0%, - rgba(0, 0, 0, 0.08) 35%, - rgba(0, 0, 0, 0.12) 50%, - rgba(0, 0, 0, 0.08) 65%, - rgba(0, 0, 0, 0.04) 100% + 100deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0) 35%, + rgba(0, 0, 0, 0.05) 50%, + rgba(0, 0, 0, 0) 65%, + rgba(0, 0, 0, 0) 100% ); - background-size: 250% 100%; - animation: shimmer 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + pointer-events: none; + will-change: transform; + animation: skeleton-sweep 1.5s linear infinite; } .dark .shimmer-bg { + background-color: rgba(255, 255, 255, 0.08); +} + +.dark .shimmer-bg::before { background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.04) 0%, - rgba(255, 255, 255, 0.09) 35%, - rgba(255, 255, 255, 0.14) 50%, - rgba(255, 255, 255, 0.09) 65%, - rgba(255, 255, 255, 0.04) 100% + 100deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0) 35%, + rgba(255, 255, 255, 0.09) 50%, + rgba(255, 255, 255, 0) 65%, + rgba(255, 255, 255, 0) 100% ); - background-size: 250% 100%; - animation: shimmer 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; } +/* Light band variant for skeleton blocks sitting on dark surfaces + (e.g. the blue required-steps card on questions-list). */ .shimmer-white-bg { + position: relative; + overflow: hidden; + isolation: isolate; + background-color: rgba(255, 255, 255, 0.12); +} + +.shimmer-white-bg::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + transform: translateX(-100%); background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.1) 0%, - rgba(255, 255, 255, 0.25) 50%, - rgba(255, 255, 255, 0.1) 100% + 100deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0) 35%, + rgba(255, 255, 255, 0.13) 50%, + rgba(255, 255, 255, 0) 65%, + rgba(255, 255, 255, 0) 100% ); - background-size: 250% 100%; - animation: shimmer 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + pointer-events: none; + will-change: transform; + animation: skeleton-sweep 1.5s linear infinite; } +/* span elements using these classes directly (inline in JSX) need a + formatted box for the absolutely-positioned sweep layer to align. */ +span.shimmer-bg, +span.shimmer-white-bg { + display: inline-block; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 33d61f6..6f18bf7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,26 +1,35 @@ import type { Metadata, Viewport } from "next"; import { Amiri } from "next/font/google"; import localFont from "next/font/local"; +import { headers } from "next/headers"; import Script from "next/script"; import Providers from "./providers"; import "./globals.css"; import DevClickToComponent from "@/components/Componentes/dev-click-to-component"; import TokenSwitcher from "@/components/Componentes/token-switcher"; +import { + defaultLocale, + isLocale, + localeDirections, +} from "@/translations/config"; const faminela = localFont({ src: "../../public/fonts/Faminela/Faminela.otf", variable: "--font-faminela-local", display: "swap", - preload: true, + preload: false, fallback: ["Arial", "sans-serif"], }); +// Amiri has no UI consumer above the fold (arabic fonts are mapped to Segoe +// UI in globals.css), so preloading it only competes with critical resources +// on a cold start. `display: swap` lets it load lazily if a consumer appears. const amiri = Amiri({ weight: ["400", "700"], subsets: ["arabic"], variable: "--font-amiri", display: "swap", - preload: true, + preload: false, fallback: ["Arial", "sans-serif"], }); @@ -38,19 +47,25 @@ export const viewport: Viewport = { themeColor: "#ffffff", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const requestHeaders = await headers(); + const headerLocale = requestHeaders.get("x-habib-locale"); + const locale = + headerLocale && isLocale(headerLocale) ? headerLocale : defaultLocale; + return ( - +