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 ( - +