From 6294cb4248d53b02e0f9641591fcf23d76783012 Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 18 Jun 2026 01:42:14 +0330 Subject: [PATCH] another step --- client/.env.development | 22 +++++++++ client/CLAUDE.md | 2 +- client/middleware.ts | 19 ++++++- client/src/app/[locale]/layout.tsx | 8 ++- client/src/app/globals.css | 1 - client/src/app/layout.tsx | 34 ++----------- client/src/constants/headers.ts | 3 ++ client/src/constants/index.ts | 1 + client/src/layout/TopBarAndSideBarLayout.tsx | 8 --- client/src/lib/api/server.ts | 3 +- client/src/lib/toast/NotistackProvider.tsx | 14 ++++++ client/src/lib/toast/index.ts | 1 + client/src/theme/ColorSchemeScript.tsx | 52 -------------------- client/src/theme/index.ts | 3 +- 14 files changed, 70 insertions(+), 101 deletions(-) create mode 100644 client/.env.development create mode 100644 client/src/constants/headers.ts create mode 100644 client/src/lib/toast/NotistackProvider.tsx delete mode 100644 client/src/theme/ColorSchemeScript.tsx diff --git a/client/.env.development b/client/.env.development new file mode 100644 index 0000000..0db0a4c --- /dev/null +++ b/client/.env.development @@ -0,0 +1,22 @@ +# Environment similar to NODE_ENV. +# Analytics and public resources are enabled only in "production" +# How to use: set value to "production" to get fully functional application. +NEXT_PUBLIC_ENV = development +# NEXT_PUBLIC_ENV = preview +# NEXT_PUBLIC_ENV = production + +# Enables additional debug features, no additional debug information if the variable is not set +# How to use: set value to "true" to get more debugging information, but don't do it on production. +NEXT_PUBLIC_DEBUG = true + +# Public URL of the application/website. +# How to use: Do not set any value until you need custom domain for your application. +# NEXT_PUBLIC_PUBLIC_URL = https://xxx.com +# NEXT_PUBLIC_PUBLIC_URL = https://xxx.web.app +NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000 + + +# API/Backend basic URL (the Baya server) +NEXT_PUBLIC_API_URL = https://localhost:5002 +# NEXT_PUBLIC_API_URL = https://dev-api.domain.com +# NEXT_PUBLIC_API_URL = https://api.domain.com \ No newline at end of file diff --git a/client/CLAUDE.md b/client/CLAUDE.md index e55bdde..170264e 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -81,7 +81,7 @@ client/ ## Server / Client Component Boundaries **Root layout** (`src/app/layout.tsx`) is a **lean Server Component** (RSC). It: -- Reads locale from the **`x-next-intl-locale` request header** set by the middleware (with fallback to `defaultLocale` for build-time pre-rendering where no request context exists). +- Reads locale from the **`x-app-locale` request header** (`HEADER_NAMES.LOCALE` from `@/constants`) set by the middleware (with fallback to `defaultLocale` for build-time pre-rendering where no request context exists). - Calls `getThemeMode()` from `@/lib/cookies/server` (only `colorScheme` is used). - Renders `` with `data-mui-color-scheme`, `lang`, and `dir` from the locale/cookie. - Does **NOT** wrap the tree with `NextIntlClientProvider`, `ThemeProvider`, or `AppStoreProvider` — those live in `[locale]/layout.tsx` where the locale is reliably sourced from URL params. diff --git a/client/middleware.ts b/client/middleware.ts index 1c7aa14..d1f67a7 100644 --- a/client/middleware.ts +++ b/client/middleware.ts @@ -2,7 +2,7 @@ import createMiddleware from 'next-intl/middleware'; import { type NextRequest, NextResponse } from 'next/server'; import { routing } from './src/i18n/routing'; import { COOKIE_NAMES } from './src/lib/cookies'; -import { PUBLIC_PATHS, ROUTES } from './src/constants'; +import { HEADER_NAMES, PUBLIC_PATHS, ROUTES } from './src/constants'; const intlMiddleware = createMiddleware(routing); @@ -39,7 +39,22 @@ export default function middleware(request: NextRequest) { } } - return i18nResponse; + // Detect locale from the normalized URL (next-intl always puts it at position 1) + const locale = routing.locales.find( + (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`), + ) ?? routing.defaultLocale; + + const requestHeaders = new Headers(request.headers); + requestHeaders.set(HEADER_NAMES.LOCALE, locale); + + const response = NextResponse.next({ request: { headers: requestHeaders } }); + + // Preserve response headers set by next-intl (e.g. Link: alternate hreflang) + i18nResponse.headers.forEach((value, key) => { + response.headers.set(key, value); + }); + + return response; } export const config = { diff --git a/client/src/app/[locale]/layout.tsx b/client/src/app/[locale]/layout.tsx index 0aed5f1..d15b049 100644 --- a/client/src/app/[locale]/layout.tsx +++ b/client/src/app/[locale]/layout.tsx @@ -1,11 +1,10 @@ import type { ReactNode } from 'react'; import { setRequestLocale, getMessages } from 'next-intl/server'; import { NextIntlClientProvider } from 'next-intl'; -import { SnackbarProvider } from 'notistack'; import { getThemeMode } from '@/lib/cookies/server'; import { AppStoreProvider } from '@/store'; import { ThemeProvider, getDirection } from '@/theme'; -import { ToastBridge } from '@/lib/toast'; +import { NotistackProvider } from '@/lib/toast'; import { QueryProvider } from '@/lib/query/QueryProvider'; import { routing } from '@/i18n/routing'; @@ -46,10 +45,9 @@ export default async function LocaleLayout({ - - + {children} - + diff --git a/client/src/app/globals.css b/client/src/app/globals.css index 55ce595..30b6834 100644 --- a/client/src/app/globals.css +++ b/client/src/app/globals.css @@ -8,7 +8,6 @@ html, body { max-width: 100vw; overflow-x: hidden; - /* required for sticky elements: HeaderMobile, and so on */ max-height: 100vh; } diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index f5808bf..06c42e6 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -1,23 +1,15 @@ import type { ReactNode } from 'react'; import { Metadata, Viewport } from 'next'; -import { Space_Grotesk } from 'next/font/google'; import localFont from 'next/font/local'; import { headers } from 'next/headers'; import { getThemeMode } from '@/lib/cookies/server'; -import { ColorSchemeScript, getDirection } from '@/theme'; +import { getDirection } from '@/theme'; import { BRAND } from '@/theme/colors'; import { routing } from '@/i18n/routing'; +import { HEADER_NAMES } from '@/constants'; import './globals.css'; import '@/theme/tokens.css'; -// EN brand font — loaded for all locales so the CSS variable is always defined -const spaceGrotesk = Space_Grotesk({ - subsets: ['latin'], - weight: ['400', '500', '600', '700'], - display: 'swap', - variable: '--font-space-grotesk', -}); - // FA brand font — Mikhak, a free Persian typeface const mikhak = localFont({ src: [ @@ -40,23 +32,11 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: { children: ReactNode }) { - /* - * The root layout is intentionally kept as a lean HTML shell. - * NextIntlClientProvider, ThemeProvider, and AppStoreProvider live in - * [locale]/layout.tsx so they always receive the correct locale from URL - * params — independent of whether this root layout is rendered statically - * or dynamically. - * - * We still read x-next-intl-locale here for lang/dir on so that - * screen readers and CSS direction are correct on real requests. - * During build-time pre-rendering (no request context) we fall back to - * defaultLocale — this only affects the static HTML shell; the live - * request always re-renders with the correct locale header. - */ + let locale: string = routing.defaultLocale; try { const hdrs = await headers(); - const headerLocale = hdrs.get('x-next-intl-locale'); + const headerLocale = hdrs.get(HEADER_NAMES.LOCALE); if (headerLocale && routing.locales.includes(headerLocale as (typeof routing.locales)[number])) { locale = headerLocale; } @@ -71,13 +51,9 @@ export default async function RootLayout({ children }: { children: ReactNode }) - - - {children} ); diff --git a/client/src/constants/headers.ts b/client/src/constants/headers.ts new file mode 100644 index 0000000..eedb91d --- /dev/null +++ b/client/src/constants/headers.ts @@ -0,0 +1,3 @@ +export const HEADER_NAMES = { + LOCALE: 'x-app-locale', +} as const; diff --git a/client/src/constants/index.ts b/client/src/constants/index.ts index a382098..5e201ac 100644 --- a/client/src/constants/index.ts +++ b/client/src/constants/index.ts @@ -1 +1,2 @@ +export * from './headers'; export * from './routes'; diff --git a/client/src/layout/TopBarAndSideBarLayout.tsx b/client/src/layout/TopBarAndSideBarLayout.tsx index 148ecea..9b5a212 100644 --- a/client/src/layout/TopBarAndSideBarLayout.tsx +++ b/client/src/layout/TopBarAndSideBarLayout.tsx @@ -1,7 +1,6 @@ 'use client'; import { FunctionComponent, useMemo, useState } from 'react'; import { Stack, StackProps } from '@mui/material'; -import { IS_DEBUG } from '@/config'; import { AppIconButton, ErrorBoundary } from '@/components'; import { LinkToPage } from '@/utils'; import { useIsMobile } from '@/hooks'; @@ -22,11 +21,6 @@ interface Props extends StackProps { variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent'; } -/** - * Renders "TopBar and SideBar" composition. - * Does NOT subscribe to the color scheme — only does. - * @layout TopBarAndSideBarLayout - */ const TopBarAndSideBarLayout: FunctionComponent = ({ children, sidebarItems, title, variant }) => { const [sidebarVisible, setSidebarVisible] = useState(false); const onMobile = useIsMobile(); @@ -87,8 +81,6 @@ const TopBarAndSideBarLayout: FunctionComponent = ({ children, sidebarIte ? { startNode: LogoButton, endNode: } : { startNode: , endNode: LogoButton }; - IS_DEBUG && console.log('Render ', { onMobile, sidebarProps }); - return ( diff --git a/client/src/lib/api/server.ts b/client/src/lib/api/server.ts index a72b23e..35f6592 100644 --- a/client/src/lib/api/server.ts +++ b/client/src/lib/api/server.ts @@ -9,6 +9,7 @@ import { headers } from 'next/headers'; import { API_URL } from '@/config'; import { COOKIE_NAMES } from '@/lib/cookies'; +import { HEADER_NAMES } from '@/constants'; import { getServerCookie } from '@/lib/cookies/server'; import { ApiError } from './errors'; @@ -26,7 +27,7 @@ export async function serverFetch(path: string, options?: RequestInit): Promi let locale = 'fa'; try { const reqHeaders = await headers(); - locale = reqHeaders.get('x-next-intl-locale') ?? 'fa'; + locale = reqHeaders.get(HEADER_NAMES.LOCALE) ?? 'fa'; } catch { // Outside request context (build-time prerendering) — use default locale } diff --git a/client/src/lib/toast/NotistackProvider.tsx b/client/src/lib/toast/NotistackProvider.tsx new file mode 100644 index 0000000..227219f --- /dev/null +++ b/client/src/lib/toast/NotistackProvider.tsx @@ -0,0 +1,14 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { SnackbarProvider } from 'notistack'; +import { ToastBridge } from './ToastBridge'; + +export function NotistackProvider({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/client/src/lib/toast/index.ts b/client/src/lib/toast/index.ts index 4a48064..cd930ac 100644 --- a/client/src/lib/toast/index.ts +++ b/client/src/lib/toast/index.ts @@ -1,2 +1,3 @@ export { ToastBridge } from './ToastBridge'; +export { NotistackProvider } from './NotistackProvider'; export { dispatchToast, type ToastSeverity } from './dispatchToast'; diff --git a/client/src/theme/ColorSchemeScript.tsx b/client/src/theme/ColorSchemeScript.tsx deleted file mode 100644 index 370da64..0000000 --- a/client/src/theme/ColorSchemeScript.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { COOKIE_NAMES } from '@/lib/cookies'; - -/** - * Inline synchronous script placed in — runs before any paint. - * - * Two jobs: - * 1. Set data-mui-color-scheme on from our cookie, so the server-set - * attribute and the first-paint attribute always agree (no flash). - * 2. Patch Storage.prototype so MUI v9's internal localStorage reads for key - * 'mode' return null (forcing MUI to use the defaultMode prop we derive - * from the cookie on the server) and writes to 'mode' are routed to our - * cookie instead of localStorage. - * - * Why patch Storage.prototype instead of storageWindow={null}? - * In MUI v9 localStorageManager: `if (!storageWindow && typeof window !== 'undefined') { - * storageWindow = window; }` — null is falsy, so storageWindow={null} is silently - * overridden to window in the browser. The prototype patch is the only reliable way. - */ -export function ColorSchemeScript() { - const cookieName = COOKIE_NAMES.COLOR_SCHEME; // 'color-scheme' - const maxAge = 60 * 60 * 24 * 365; - // MUI v9 default modeStorageKey (InitColorSchemeScript.mjs: DEFAULT_MODE_STORAGE_KEY = 'mode') - const muiModeKey = 'mode'; - - const script = - `(function(){` + - // ── 1. Set attribute from cookie before first paint ───────────────────── - `var m=document.cookie.match(/(^|;)\\s*${cookieName}=([^;]*)/);` + - `var s=m?decodeURIComponent(m[2]):(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');` + - `document.documentElement.setAttribute('data-mui-color-scheme',s==='dark'?'dark':'light');` + - // ── 2. Intercept Storage so MUI never touches localStorage ─────────────── - `try{` + - `var _s=Storage.prototype.setItem,_g=Storage.prototype.getItem,_r=Storage.prototype.removeItem;` + - `Storage.prototype.setItem=function(k,v){` + - `if(this===window.localStorage&&k==='${muiModeKey}'){` + - `if(v==='dark'||v==='light')document.cookie='${cookieName}='+v+';path=/;max-age=${maxAge};samesite=lax';` + - `else document.cookie='${cookieName}=;path=/;max-age=0';` + - `return;}` + - `_s.call(this,k,v);};` + - `Storage.prototype.getItem=function(k){` + - `if(this===window.localStorage&&k==='${muiModeKey}')return null;` + - `return _g.call(this,k);};` + - `Storage.prototype.removeItem=function(k){` + - `if(this===window.localStorage&&k==='${muiModeKey}'){` + - `document.cookie='${cookieName}=;path=/;max-age=0';return;}` + - `_r.call(this,k);};` + - `}catch(e){}` + - `})();`; - - // eslint-disable-next-line react/no-danger - return