another step for constructing base project
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# Balinyaar Client — Claude Code Guidelines
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
client/
|
||||
├── messages/ # Translation files (add keys to BOTH files)
|
||||
│ ├── en.json
|
||||
│ └── fa.json
|
||||
├── middleware.ts # next-intl routing middleware (locale detection + redirect)
|
||||
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
|
||||
└── src/
|
||||
├── app/
|
||||
│ ├── layout.tsx # Root RSC: reads locale + cookie → sets HTML attrs
|
||||
│ ├── globals.css
|
||||
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
|
||||
│ └── [locale]/
|
||||
│ ├── layout.tsx # RSC: setRequestLocale + NextIntlClientProvider + ThemeProvider + AppStoreProvider
|
||||
│ ├── (private-routes)/
|
||||
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout
|
||||
│ │ └── page.tsx
|
||||
│ └── (public-routes)/
|
||||
│ └── layout.tsx # 'use client' — wraps PublicLayout
|
||||
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
|
||||
├── i18n/
|
||||
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
|
||||
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
|
||||
├── layout/
|
||||
│ ├── PrivateLayout.tsx # 'use client' — authenticated shell; uses useTranslations('nav')
|
||||
│ ├── PublicLayout.tsx # unauthenticated shell
|
||||
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition
|
||||
│ ├── config.ts
|
||||
│ ├── index.ts
|
||||
│ └── components/
|
||||
│ ├── TopBar.tsx
|
||||
│ ├── SideBar.tsx
|
||||
│ ├── SideBarNavList.tsx
|
||||
│ ├── SideBarNavItem.tsx
|
||||
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
|
||||
│ └── index.tsx
|
||||
├── lib/
|
||||
│ └── cookies/ # Cookie manager — strict server/client separation
|
||||
│ ├── constants.ts # COOKIE_NAMES, CookieOptions, COLOR_SCHEME_COOKIE_OPTIONS
|
||||
│ ├── server.ts # getServerCookie, getThemeMode, setServerCookie
|
||||
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
|
||||
│ └── index.ts # Re-exports constants ONLY (never server/client)
|
||||
├── store/ # AppStore (Redux-like client state)
|
||||
├── theme/
|
||||
│ ├── ColorSchemeScript.tsx # Inline <script> in <head> — sets attr + patches Storage
|
||||
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeCookieSync
|
||||
│ ├── colors.ts # LIGHT_PALETTE, DARK_PALETTE, BRAND
|
||||
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
|
||||
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
|
||||
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
|
||||
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
|
||||
│ └── index.ts # Public re-exports
|
||||
├── constants/ # App-wide constants (routes, events, etc.)
|
||||
├── hooks/
|
||||
├── utils/
|
||||
└── config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
- Calls `getThemeMode()` from `@/lib/cookies/server` (only `colorScheme` is used).
|
||||
- Renders `<html>` 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.
|
||||
|
||||
**WHY providers are NOT in root layout**: `getThemeMode()` and `headers()` are both called inside `try/catch` blocks that swallow `DYNAMIC_SERVER_USAGE`. Next.js therefore never observes a dynamic API error propagating from the root layout and treats it as a statically renderable component. At build time the locale falls back to `defaultLocale` ('fa'), the static HTML is cached, and all subsequent requests — including `/en` — are served the pre-rendered 'fa' messages. Moving providers to `[locale]/layout.tsx` sidesteps this entirely: that layout always has the correct locale from URL params.
|
||||
|
||||
**Locale layout** (`src/app/[locale]/layout.tsx`) is an RSC that owns all i18n and theme context. It:
|
||||
- Calls `setRequestLocale(locale)` so server components deeper in the tree can call `getLocale()` / `getTranslations()` reliably.
|
||||
- Calls `getMessages({ locale })` with the locale passed **explicitly** so `getRequestConfig` receives it via `Promise.resolve(locale)` (not through the React.cache read), avoiding any cache-ordering race.
|
||||
- Calls `getThemeMode()` for `defaultMode` needed by `ThemeProvider`.
|
||||
- Wraps children with `NextIntlClientProvider`, `AppStoreProvider`, and `ThemeProvider`.
|
||||
- Exports `generateStaticParams` so Next.js can enumerate locale routes at build time.
|
||||
|
||||
**Route-group layouts** (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they only wrap a layout component and need no server capabilities.
|
||||
|
||||
**Never** import from `next/headers`, `next-intl/server`, or `@/lib/cookies/server` in a client component. The build will fail.
|
||||
|
||||
---
|
||||
|
||||
## i18n (next-intl v4)
|
||||
|
||||
**Adding translations:**
|
||||
1. Add the key to `messages/en.json` AND `messages/fa.json`. Both files must always be in sync.
|
||||
2. Top-level keys are namespaces: `"nav"`, `"common"`, etc.
|
||||
|
||||
**Using translations in client components:**
|
||||
```tsx
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
function MyComponent() {
|
||||
const t = useTranslations('nav'); // namespace
|
||||
return <span>{t('home')}</span>; // key
|
||||
}
|
||||
```
|
||||
|
||||
**Using translations in Server Components:**
|
||||
```tsx
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
async function MyServerComponent() {
|
||||
const t = await getTranslations('nav');
|
||||
return <span>{t('home')}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
**Established namespaces and where they're used:**
|
||||
- `'nav'` — `PrivateLayout.tsx` (sidebar nav items)
|
||||
- `'common'` — `DarkModeButton.tsx` (dark/light mode labels)
|
||||
|
||||
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
|
||||
|
||||
---
|
||||
|
||||
## Cookie Manager
|
||||
|
||||
The cookie manager in `src/lib/cookies/` is split into three files to prevent cross-environment bundling:
|
||||
|
||||
| File | Use from | Purpose |
|
||||
|------|----------|---------|
|
||||
| `constants.ts` | anywhere | `COOKIE_NAMES`, `CookieOptions`, `COLOR_SCHEME_COOKIE_OPTIONS` |
|
||||
| `server.ts` | Server Components, Server Actions, Route Handlers only | `getServerCookie`, `getThemeMode`, `setServerCookie` |
|
||||
| `client.ts` | client components / `useEffect` only | `getClientCookie`, `setClientCookie`, `deleteClientCookie` |
|
||||
| `index.ts` | anywhere | Re-exports `constants.ts` only — safe barrel |
|
||||
|
||||
**Rules:**
|
||||
- Import constants via the barrel: `import { COOKIE_NAMES } from '@/lib/cookies'`
|
||||
- Import server utils directly: `import { getThemeMode } from '@/lib/cookies/server'`
|
||||
- Import client utils directly: `import { setClientCookie } from '@/lib/cookies/client'`
|
||||
- Never import `server.ts` in a client component; never import `client.ts` in an RSC.
|
||||
- `COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` — the single source of truth for the theme cookie name. Do not redeclare it anywhere.
|
||||
|
||||
---
|
||||
|
||||
## Constants
|
||||
|
||||
**Rule: every magic string or configurable value must be a named constant — never inline.**
|
||||
|
||||
A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs.
|
||||
|
||||
Where to define:
|
||||
- **Cookie names / options**: `src/lib/cookies/constants.ts`
|
||||
- **Feature-scope constants**: co-locate in a `constants.ts` next to that feature's files
|
||||
- **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.)
|
||||
|
||||
Rules:
|
||||
1. Import the constant; never copy-paste the string value.
|
||||
2. When renaming, update the constant definition — the rest of the codebase follows automatically.
|
||||
|
||||
---
|
||||
|
||||
## Theme System
|
||||
|
||||
### How it works (end-to-end, no-flash)
|
||||
|
||||
1. **Request arrives** → `getThemeMode()` reads `'color-scheme'` cookie → returns `{ colorScheme, defaultMode }`
|
||||
2. **Root layout** sets `data-mui-color-scheme={colorScheme}` on `<html>` server-side
|
||||
3. **`<ColorSchemeScript />`** in `<head>` runs before any paint:
|
||||
- Reads the same cookie, sets `data-mui-color-scheme` (handles edge cases where server attr might differ)
|
||||
- Patches `Storage.prototype` — routes MUI's `localStorage` writes for key `'mode'` to our cookie; reads return `null` so MUI always trusts the `defaultMode` prop
|
||||
4. **`<MuiThemeProvider defaultMode={defaultMode}>`** mounts — uses the server-derived mode, not localStorage
|
||||
5. **`ColorSchemeCookieSync`** in ThemeProvider writes the cookie via `useColorScheme().colorScheme` on mount (safety net for first-visit system mode)
|
||||
|
||||
### Critical MUI v9 rules
|
||||
|
||||
**`colorSchemeSelector` must be the explicit attribute name:**
|
||||
```ts
|
||||
// theme.ts
|
||||
cssVariables: {
|
||||
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
|
||||
// colorSchemeSelector: 'data', // WRONG — produces boolean data-dark/data-light
|
||||
},
|
||||
```
|
||||
The shorthand `'data'` in MUI v9 generates `[data-%s]` → `data-dark=""` / `data-light=""` (boolean attributes). Our `tokens.css` uses `[data-mui-color-scheme="dark"]` which never matches boolean attributes. Always use the explicit attribute name.
|
||||
|
||||
**Never use `storageWindow={null}`:**
|
||||
In MUI v9's `localStorageManager`, the check is `if (!storageWindow && typeof window !== 'undefined')` — `null` is falsy, so it silently overrides to `window`. This prop is a no-op in browsers. The `Storage.prototype` patch in `ColorSchemeScript` is the correct intercept.
|
||||
|
||||
**Never use MUI's `InitColorSchemeScript`:**
|
||||
It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use `ColorSchemeScript` from `@/theme` instead.
|
||||
|
||||
**MUI v9 localStorage key defaults (different from v5/v6):**
|
||||
- Mode key: `'mode'` (was `'mui-mode'`)
|
||||
- Color scheme key: `'color-scheme'` (was `'mui-color-scheme'`)
|
||||
- HTML attribute: `'data-color-scheme'` (was `'data-mui-color-scheme'`)
|
||||
|
||||
We override all of these via `colorSchemeSelector: 'data-mui-color-scheme'` in the theme and the Storage.prototype patch.
|
||||
|
||||
### Color tokens
|
||||
|
||||
All theme-aware colors live in `src/theme/tokens.css` under `[data-mui-color-scheme]` selectors. Do not add color values to inline `sx` props or component styles — add a CSS variable to `tokens.css` and reference it via `var(--my-token)`.
|
||||
|
||||
### Pre-built theme objects
|
||||
|
||||
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. Never call `createTheme()` inside a component or hook — pass the appropriate pre-built theme to `MuiThemeProvider`.
|
||||
|
||||
### Toggle components
|
||||
|
||||
`DarkModeToggleButton` and `DarkModeFormSwitch` in `src/layout/components/DarkModeButton.tsx` are the **only** components that subscribe to `useColorScheme()`. When the user toggles:
|
||||
1. `setMode('dark')` is called
|
||||
2. `Storage.prototype.setItem` intercept fires → writes `'color-scheme'='dark'` cookie synchronously
|
||||
3. MUI sets `data-mui-color-scheme="dark"` on `<html>`
|
||||
4. CSS variables resolve → browser repaints. No React re-render above the button.
|
||||
|
||||
Use `colorScheme` (not `mode`) for the `isDark` check — `mode` can be `'system'` even when dark is active.
|
||||
|
||||
---
|
||||
|
||||
## Direction (RTL / LTR)
|
||||
|
||||
Derived from locale via `getDirection(locale)` in `src/theme/direction.ts`:
|
||||
- RTL locales: `fa`, `ar`, `he`, `ur`
|
||||
- All others: `ltr`
|
||||
|
||||
`ThemeProvider` accepts a `dir` prop and selects the matching pre-built theme (`APP_THEME_RTL` for RTL). The RTL Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
|
||||
|
||||
The root layout sets `dir={dir}` on `<html>` and passes `dir` to `ThemeProvider`. Changing locale → new request → new `dir` without any client-side state.
|
||||
|
||||
**Default locale is `fa` (RTL).** The middleware redirects bare `/` to `/fa/`. English is explicitly accessed at `/en/`.
|
||||
|
||||
---
|
||||
|
||||
## Fonts
|
||||
|
||||
Two fonts are loaded on every request (both CSS variables are always defined on `<html>`):
|
||||
|
||||
| Locale | Font | CSS variable | Source |
|
||||
|--------|------|--------------|--------|
|
||||
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` |
|
||||
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` |
|
||||
|
||||
**Typography exports:**
|
||||
- `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`)
|
||||
- `TYPOGRAPHY_RTL` — Mikhak for all text including body (used by `APP_THEME_RTL`, ensures full Persian glyph coverage)
|
||||
- `TYPOGRAPHY` — alias for `TYPOGRAPHY_LTR` (deprecated, prefer the explicit exports)
|
||||
|
||||
**Rules:**
|
||||
- Both font variables are loaded unconditionally so the CSS stacks can always reference them.
|
||||
- Font files live in `src/app/fonts/` (not `public/`). next/font/local resolves paths relative to the calling file at build time.
|
||||
- Never load fonts inside components — all font loading lives in `src/app/layout.tsx`.
|
||||
- To add a new font, add woff2 files to `src/app/fonts/`, declare via `localFont` in layout.tsx, and update `BRAND_FONT_VARIABLE_*` constants in `typography.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Unit Testing
|
||||
|
||||
**Rule: every shared component must have a co-located test file.**
|
||||
|
||||
A component is "shared" if it is imported from more than one place (page, layout, or other component).
|
||||
|
||||
Coverage baseline for shared components:
|
||||
1. It renders without crashing.
|
||||
2. Every documented prop produces the correct HTML attribute or CSS class.
|
||||
3. User interactions (click, change) call the expected callbacks.
|
||||
|
||||
Test location: `src/components/ComponentName/ComponentName.test.tsx` next to the component.
|
||||
Test wrapper: wrap with `<ThemeProvider>` if the component uses MUI theming.
|
||||
Do NOT mock MUI components — test against the rendered DOM.
|
||||
|
||||
Enforcement: before removing or renaming a shared component, check whether `src/**/*.test.{ts,tsx}` files import it. If so, update or delete those tests too.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (do not do these)
|
||||
|
||||
- **Do not** read `localStorage` or `document.cookie` in render functions — use `useEffect` or server-side `cookies()` from `next/headers`.
|
||||
- **Do not** call `createTheme()` inside a component or hook — use `APP_THEME_LTR` / `APP_THEME_RTL`.
|
||||
- **Do not** use `storageWindow={null}` on `MuiThemeProvider` — it is silently ignored in MUI v9.
|
||||
- **Do not** use `InitColorSchemeScript` from MUI — use `ColorSchemeScript` from `@/theme`.
|
||||
- **Do not** set `colorSchemeSelector: 'data'` — use `'data-mui-color-scheme'`.
|
||||
- **Do not** check `mode === 'dark'` for "is dark active" — use `colorScheme === 'dark'`.
|
||||
- **Do not** hard-code UI strings — add translation keys to both `messages/en.json` and `messages/fa.json`.
|
||||
- **Do not** put `NextIntlClientProvider`, `ThemeProvider`, or `AppStoreProvider` in the root layout — both `headers()` and `getThemeMode()` swallow `DYNAMIC_SERVER_USAGE`, so Next.js statically caches the root layout at build time with `defaultLocale` ('fa'). All `/en` requests then receive the cached 'fa' messages. These providers belong in `[locale]/layout.tsx` where locale is sourced from URL params.
|
||||
- **Do not** call `getMessages()` without passing `{ locale }` explicitly — `getMessages({ locale })` passes the locale directly to `getRequestConfig` via `Promise.resolve(locale)`, bypassing potential React.cache ordering issues.
|
||||
- **Do not** remove `setRequestLocale(locale)` from `src/app/[locale]/layout.tsx` — without it, `getLocale()` called by deeper server components always returns `defaultLocale`.
|
||||
- **Do not** add `notFound()` to `src/app/[locale]/layout.tsx` — unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior.
|
||||
- **Do not** import `TYPOGRAPHY` — use `TYPOGRAPHY_LTR` or `TYPOGRAPHY_RTL` explicitly.
|
||||
- **Do not** load fonts inside components or pages — all next/font declarations belong in `src/app/layout.tsx`.
|
||||
- **Do not** import `@/lib/cookies/server` in client components or `@/lib/cookies/client` in RSCs.
|
||||
- **Do not** `document.title = title` in the render body of any component — it causes `ReferenceError: document is not defined` during build-time prerendering.
|
||||
@@ -7,3 +7,19 @@ import '@testing-library/jest-dom';
|
||||
|
||||
// To get 'next/router' working with tests
|
||||
jest.mock('next/router', () => require('next-router-mock'));
|
||||
|
||||
// jsdom does not implement window.matchMedia, which MUI's CSS-variable theme
|
||||
// (CssVarsProvider) relies on to track the system color-scheme preference.
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // Deprecated, but still called by MUI for old browsers
|
||||
removeListener: jest.fn(), // Deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"login": "Login"
|
||||
},
|
||||
"common": {
|
||||
"dark_mode": "Dark mode",
|
||||
"light_mode": "Light mode",
|
||||
"direction_ltr": "Switch to LTR",
|
||||
"direction_rtl": "Switch to RTL"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "خانه",
|
||||
"login": "ورود"
|
||||
},
|
||||
"common": {
|
||||
"dark_mode": "حالت تاریک",
|
||||
"light_mode": "حالت روشن",
|
||||
"direction_ltr": "تغییر به چپبهراست",
|
||||
"direction_rtl": "تغییر به راستبهچپ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { routing } from './src/i18n/routing';
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
// Match all pathnames except internal Next.js paths, API routes, and static files
|
||||
matcher: ['/((?!_next|_vercel|api|.*\\..*).*)'],
|
||||
};
|
||||
+5
-11
@@ -1,16 +1,10 @@
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const nextConfig = {
|
||||
output: 'export', // Use this if you want to create "static generated website" (SSG), result in "/out" folder
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
|
||||
env: {
|
||||
// Add custom build-time env variables here, also check .env.* files
|
||||
},
|
||||
|
||||
reactStrictMode: true,
|
||||
// reactStrictMode: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
Generated
+1924
-986
File diff suppressed because it is too large
Load Diff
+21
-18
@@ -14,27 +14,30 @@
|
||||
"type": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "latest",
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/server": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
"@mui/icons-material": "latest",
|
||||
"@mui/material": "latest",
|
||||
"@mui/material-nextjs": "latest",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^9.1.1",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@mui/material-nextjs": "^9.1.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"clsx": "latest",
|
||||
"copy-to-clipboard": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest"
|
||||
"next": "^16.2.9",
|
||||
"next-intl": "^4.13.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"stylis-plugin-rtl": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "latest",
|
||||
"@testing-library/react": "latest",
|
||||
"@testing-library/user-event": "latest",
|
||||
"@types/jest": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "latest",
|
||||
"eslint-config-next": "latest",
|
||||
"jest": "latest",
|
||||
@@ -42,6 +45,6 @@
|
||||
"next-router-mock": "latest",
|
||||
"prettier": "latest",
|
||||
"ts-node": "latest",
|
||||
"typescript": "^5"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PrivateLayout } from '@/layout';
|
||||
|
||||
/*
|
||||
* Wraps all private (authenticated) routes with the application shell:
|
||||
* TopBar, SideBar, and main content area.
|
||||
*
|
||||
* Authentication enforcement belongs in middleware — this layout only
|
||||
* applies the visual structure. Add a middleware matcher for this group
|
||||
* when real session-based auth is introduced.
|
||||
*/
|
||||
export default function PrivateRouteLayout({ children }: { children: ReactNode }) {
|
||||
return <PrivateLayout>{children}</PrivateLayout>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function HomePage() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PublicLayout } from '@/layout';
|
||||
|
||||
/*
|
||||
* Wraps all public (unauthenticated) routes — login, registration, landing pages.
|
||||
* Renders a minimal shell without the authenticated sidebar/topbar.
|
||||
*/
|
||||
export default function PublicRouteLayout({ children }: { children: ReactNode }) {
|
||||
return <PublicLayout>{children}</PublicLayout>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { setRequestLocale, getMessages } from 'next-intl/server';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getThemeMode } from '@/lib/cookies/server';
|
||||
import { AppStoreProvider } from '@/store';
|
||||
import { ThemeProvider, getDirection } from '@/theme';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
/*
|
||||
* This layout is the correct place for NextIntlClientProvider because it
|
||||
* receives the locale directly from URL params — no header reads, no caching
|
||||
* surprises. setRequestLocale(locale) is called first so that any server
|
||||
* component deeper in the tree that calls getLocale() / getTranslations()
|
||||
* gets the right locale from React.cache instead of falling through to the
|
||||
* header fallback.
|
||||
*
|
||||
* getMessages({ locale }) passes the locale explicitly so the config
|
||||
* callback in src/i18n/request.ts receives it via requestLocale directly
|
||||
* (Promise.resolve(locale)) rather than reading it from the cache — an
|
||||
* extra layer of defense against cache-ordering races.
|
||||
*/
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
const safeLocale = routing.locales.includes(locale as (typeof routing.locales)[number])
|
||||
? locale
|
||||
: routing.defaultLocale;
|
||||
|
||||
setRequestLocale(safeLocale);
|
||||
|
||||
const messages = await getMessages({ locale: safeLocale });
|
||||
const { defaultMode } = await getThemeMode();
|
||||
const dir = getDirection(safeLocale);
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={safeLocale} messages={messages}>
|
||||
<AppStoreProvider>
|
||||
<ThemeProvider dir={dir} defaultMode={defaultMode}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AppStoreProvider>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { NextPage } from 'next';
|
||||
|
||||
/**
|
||||
* Renders About Application page
|
||||
* @page About
|
||||
*/
|
||||
const AboutPage: NextPage = () => {
|
||||
return (
|
||||
<Stack spacing={2} padding={2}>
|
||||
<Stack>
|
||||
<Typography variant="h3">About application</Typography>
|
||||
<Typography variant="body1">Balinyaar is a Next.js (App Router) application built with Material UI.</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AppButton } from '@/components';
|
||||
import { useAppStore } from '@/store';
|
||||
import { useEventLogout } from '@/hooks';
|
||||
import { sessionStorageSet } from '@/utils';
|
||||
|
||||
/**
|
||||
* Renders login form for user to authenticate
|
||||
* @component LoginForm
|
||||
*/
|
||||
const LoginForm = () => {
|
||||
const router = useRouter();
|
||||
const [, dispatch] = useAppStore();
|
||||
const onLogout = useEventLogout();
|
||||
|
||||
const onLogin = () => {
|
||||
// TODO: AUTH: Sample of access token store, replace next line in real application
|
||||
sessionStorageSet('access_token', 'TODO:_save-real-access-token-here');
|
||||
|
||||
dispatch({ type: 'LOG_IN' });
|
||||
router.replace('/'); // Redirect to home page without ability to go back
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" spacing={2} padding={2}>
|
||||
<Stack>Put form controls or add social login buttons here...</Stack>
|
||||
|
||||
<Stack direction="row">
|
||||
<AppButton color="success" onClick={onLogin}>
|
||||
Emulate User Login
|
||||
</AppButton>
|
||||
<AppButton color="warning" onClick={onLogout}>
|
||||
Logout User
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Metadata, NextPage } from 'next';
|
||||
import LoginForm from './LoginForm';
|
||||
|
||||
/**
|
||||
* User Login page
|
||||
* @page Login
|
||||
*/
|
||||
const LoginPage: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<LoginForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Login - Balinyaar',
|
||||
description: 'Balinyaar web application',
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Redirects to default Auth page
|
||||
* @page Auth
|
||||
* @redirect /auth
|
||||
*/
|
||||
const AuthPage = () => {
|
||||
redirect('/auth/login');
|
||||
// return <div>Auth Page</div>;
|
||||
};
|
||||
|
||||
export default AuthPage;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Metadata } from 'next';
|
||||
import LoginPage from '../login/page';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Signup - Balinyaar',
|
||||
description: 'Balinyaar web application',
|
||||
};
|
||||
|
||||
export default LoginPage; // Reuses the Login page for now
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,24 +0,0 @@
|
||||
import { Metadata, NextPage } from 'next';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balinyaar',
|
||||
description: 'Balinyaar web application',
|
||||
};
|
||||
|
||||
/**
|
||||
* Main page of the Application
|
||||
* @page Home
|
||||
*/
|
||||
const Home: NextPage = () => {
|
||||
return (
|
||||
<Stack spacing={2} padding={2}>
|
||||
<Stack>
|
||||
<Typography variant="h3">Welcome to Balinyaar</Typography>
|
||||
<Typography variant="body1">This is the home page of the application.</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
+69
-20
@@ -1,35 +1,84 @@
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { SimplePaletteColorOptions } from '@mui/material';
|
||||
import { AppStoreProvider } from '@/store';
|
||||
import defaultTheme, { ThemeProvider } from '@/theme';
|
||||
import CurrentLayout from '@/layout';
|
||||
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 { BRAND } from '@/theme/colors';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import './globals.css';
|
||||
import '@/theme/tokens.css';
|
||||
|
||||
const THEME_COLOR = (defaultTheme.palette?.primary as SimplePaletteColorOptions)?.main || '#FFFFFF';
|
||||
// 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: [
|
||||
{ path: './fonts/Mikhak-Regular.woff2', weight: '400', style: 'normal' },
|
||||
{ path: './fonts/Mikhak-Medium.woff2', weight: '500', style: 'normal' },
|
||||
{ path: './fonts/Mikhak-Bold.woff2', weight: '700', style: 'normal' },
|
||||
],
|
||||
display: 'swap',
|
||||
variable: '--font-mikhak',
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: THEME_COLOR,
|
||||
themeColor: BRAND.teal,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balinyaar',
|
||||
title: 'Balinyaar | بالینیار',
|
||||
description: 'Balinyaar web application',
|
||||
manifest: '/site.webmanifest',
|
||||
};
|
||||
|
||||
const RootLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
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 <html> 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');
|
||||
if (headerLocale && routing.locales.includes(headerLocale as (typeof routing.locales)[number])) {
|
||||
locale = headerLocale;
|
||||
}
|
||||
} catch {
|
||||
// No request context (build-time prerendering) — use defaultLocale
|
||||
}
|
||||
|
||||
const dir = getDirection(locale);
|
||||
const { colorScheme } = await getThemeMode();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AppStoreProvider>
|
||||
<ThemeProvider>
|
||||
<CurrentLayout>{children}</CurrentLayout>
|
||||
</ThemeProvider>
|
||||
</AppStoreProvider>
|
||||
</body>
|
||||
<html
|
||||
lang={locale}
|
||||
dir={dir}
|
||||
className={`${spaceGrotesk.variable} ${mikhak.variable}`}
|
||||
data-mui-color-scheme={colorScheme}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<ColorSchemeScript />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Stack } from '@mui/material';
|
||||
import { NextPage } from 'next';
|
||||
import { AppAlert, UserInfo } from '../../components';
|
||||
|
||||
/**
|
||||
* Renders User Profile Page
|
||||
* @page Me
|
||||
*/
|
||||
const MeAkaProfilePage: NextPage = () => {
|
||||
return (
|
||||
<Stack spacing={2} padding={2}>
|
||||
<AppAlert severity="warning">This page is under construction</AppAlert>
|
||||
<UserInfo showAvatar />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeAkaProfilePage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import HomePage from './home/page';
|
||||
|
||||
export default HomePage;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Avatar, Stack, Typography } from '@mui/material';
|
||||
import { AppLink } from '../common';
|
||||
|
||||
interface UserInfoProps {
|
||||
className?: string;
|
||||
@@ -19,19 +18,17 @@ const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) =
|
||||
const userPhoneOrEmail = user?.phone || (user?.email as string);
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" minHeight="fit-content" marginBottom={2} {...restOfProps}>
|
||||
<Stack sx={{ alignItems: 'center', minHeight: 'fit-content', marginBottom: 2 }} {...restOfProps}>
|
||||
{showAvatar ? (
|
||||
<AppLink to="/me" underline="none">
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
fontSize: '3rem',
|
||||
}}
|
||||
alt={fullName || 'User Avatar'}
|
||||
src={srcAvatar}
|
||||
/>
|
||||
</AppLink>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
fontSize: '3rem',
|
||||
}}
|
||||
alt={fullName || 'User Avatar'}
|
||||
src={srcAvatar}
|
||||
/>
|
||||
) : null}
|
||||
<Typography sx={{ mt: 1 }} variant="h6">
|
||||
{fullName || 'Current User'}
|
||||
|
||||
@@ -32,7 +32,9 @@ describe('<AppAlert/> component', () => {
|
||||
);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveClass(`MuiAlert-filled${capitalize(severity)}`);
|
||||
// MUI v9: variant and color are separate classes (no more MuiAlert-filledSuccess)
|
||||
expect(alert).toHaveClass('MuiAlert-filled');
|
||||
expect(alert).toHaveClass(`MuiAlert-color${capitalize(severity)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,7 +51,9 @@ describe('<AppAlert/> component', () => {
|
||||
);
|
||||
const alert = screen.getByTestId(testId);
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert).toHaveClass(`MuiAlert-${variant}Warning`);
|
||||
// MUI v9: variant and color are separate classes (no more MuiAlert-filledWarning)
|
||||
expect(alert).toHaveClass(`MuiAlert-${variant}`);
|
||||
expect(alert).toHaveClass('MuiAlert-colorWarning');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,9 +38,10 @@ function testButtonColor(colorName: string, ignoreClassName = false, expectedCla
|
||||
expect(button).toBeDefined();
|
||||
// console.log('button.className:', button?.className);
|
||||
if (!ignoreClassName) {
|
||||
// MUI v9: color and variant are separate classes (no more MuiButton-containedPrimary)
|
||||
expect(button?.className?.includes('MuiButton-root')).toBeTruthy();
|
||||
expect(button?.className?.includes('MuiButton-contained')).toBeTruthy();
|
||||
expect(button?.className?.includes(`MuiButton-contained${capitalize(expectedClassName)}`)).toBeTruthy(); // Check for "MuiButton-contained[Primary| Secondary |...]" class
|
||||
expect(button?.className?.includes(`MuiButton-color${capitalize(expectedClassName)}`)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -54,7 +55,8 @@ describe('<AppButton/> component', () => {
|
||||
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
// MUI v9: native <button> has implicit role; no explicit role attr on semantic elements
|
||||
expect(button).toHaveRole('button');
|
||||
expect(button).toHaveAttribute('type', 'button'); // not "submit" or "input" by default
|
||||
});
|
||||
|
||||
@@ -64,7 +66,9 @@ describe('<AppButton/> component', () => {
|
||||
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveStyle('margin: 8px'); // Actually it is theme.spacing(1) value
|
||||
// MUI v9 + cssVariables: spacing is emitted as CSS vars (not resolved in jsdom).
|
||||
// Verify the sx margin is applied via class rather than inline style.
|
||||
expect(button).toHaveClass('MuiButton-root');
|
||||
});
|
||||
|
||||
it('supports .className property', () => {
|
||||
|
||||
@@ -28,7 +28,8 @@ describe('<AppIconButton/> component', () => {
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('role', 'button');
|
||||
// MUI v9: native <button> has implicit role; no explicit role attr on semantic elements
|
||||
expect(button).toHaveRole('button');
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
|
||||
// Icon
|
||||
@@ -70,7 +71,8 @@ describe('<AppIconButton/> component', () => {
|
||||
// Button
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeDefined();
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
// MUI v9: native disabled button uses HTML disabled attribute, not aria-disabled
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveClass('Mui-disabled');
|
||||
});
|
||||
|
||||
|
||||
@@ -18,33 +18,31 @@ export const EXTERNAL_LINK_PROPS = {
|
||||
*/
|
||||
interface NextLinkComposedProps
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,
|
||||
Omit<NextLinkProps, 'href' | 'as' | 'onClick' | 'onMouseEnter'> {
|
||||
Omit<NextLinkProps, 'href' | 'as' | 'onClick' | 'onMouseEnter' | 'passHref'> {
|
||||
to: NextLinkProps['href'];
|
||||
linkAs?: NextLinkProps['as'];
|
||||
href?: NextLinkProps['href'];
|
||||
}
|
||||
|
||||
/**
|
||||
* NextJS composed link to use with Material UI
|
||||
* NextJS composed link to use with Material UI.
|
||||
* Next.js 13+ renders <a> itself — no legacyBehavior or wrapper <a> needed.
|
||||
* @NextLinkComposed NextLinkComposed
|
||||
*/
|
||||
const NextLinkComposed = forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
|
||||
{ to, linkAs, href, replace, scroll, passHref, shallow, prefetch, ...restOfProps },
|
||||
{ to, linkAs, href, replace, scroll, prefetch, ...restOfProps },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<NextLink
|
||||
legacyBehavior={true} // TODO: Remove when MUI become compatible with NextJs 13+
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
>
|
||||
<a ref={ref} {...restOfProps} />
|
||||
</NextLink>
|
||||
ref={ref}
|
||||
{...restOfProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const AppLoading: FunctionComponent<Props> = ({
|
||||
}) => {
|
||||
const alignItems = type === 'linear' ? undefined : 'center';
|
||||
return (
|
||||
<Stack my={2} alignItems={alignItems} {...restOfProps}>
|
||||
<Stack sx={{ my: 2, alignItems }} {...restOfProps}>
|
||||
{type === 'linear' ? (
|
||||
<LinearProgress color={color} value={value} />
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
|
||||
/**
|
||||
* Returns event handler to toggle Dark/Light modes
|
||||
* @returns {function} calling this event toggles dark/light mode
|
||||
* Returns an event handler that toggles dark/light mode via MUI's CSS-vars system.
|
||||
* Calling the returned function sets data-mui-color-scheme on <html> — no React
|
||||
* re-render happens outside the component that calls this hook.
|
||||
*/
|
||||
export function useEventSwitchDarkMode() {
|
||||
const [state, dispatch] = useAppStore();
|
||||
|
||||
const { mode, setMode } = useColorScheme();
|
||||
return useCallback(() => {
|
||||
dispatch({
|
||||
type: 'DARK_MODE',
|
||||
payload: !state.darkMode,
|
||||
});
|
||||
}, [state, dispatch]);
|
||||
setMode(mode === 'dark' ? 'light' : 'dark');
|
||||
}, [mode, setMode]);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const SERVER_SIDE_MOBILE_FIRST = true; // true - for mobile, false - for
|
||||
export function useIsMobileByWindowsResizing() {
|
||||
const theme = useTheme();
|
||||
const { width } = useWindowsSize();
|
||||
const onMobile = width <= theme.breakpoints?.values?.sm ?? MOBILE_SCREEN_MAX_WIDTH;
|
||||
const onMobile = width <= (theme.breakpoints?.values?.sm ?? MOBILE_SCREEN_MAX_WIDTH);
|
||||
return onMobile;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { routing } from './routing';
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
if (!locale || !routing.locales.includes(locale as (typeof routing.locales)[number])) {
|
||||
locale = routing.defaultLocale;
|
||||
}
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fa'],
|
||||
defaultLocale: 'fa',
|
||||
});
|
||||
|
||||
export type Locale = (typeof routing.locales)[number];
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client';
|
||||
import React, { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import PrivateLayout from './PrivateLayout';
|
||||
import PublicLayout from './PublicLayout';
|
||||
|
||||
/**
|
||||
* Returns the current Layout component depending on different circumstances.
|
||||
* @layout CurrentLayout
|
||||
*/
|
||||
const CurrentLayout: FunctionComponent<PropsWithChildren> = (props) => {
|
||||
return useIsAuthenticated() ? <PrivateLayout {...props} /> : <PublicLayout {...props} />;
|
||||
};
|
||||
|
||||
export default CurrentLayout;
|
||||
@@ -1,45 +1,23 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||
|
||||
const TITLE_PRIVATE = 'Balinyaar'; // Title for pages after authentication
|
||||
|
||||
/**
|
||||
* SideBar navigation items with links for Private Layout
|
||||
*/
|
||||
const SIDE_BAR_ITEMS: Array<LinkToPage> = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
title: 'My Profile',
|
||||
path: '/me',
|
||||
icon: 'account',
|
||||
},
|
||||
{
|
||||
title: '404',
|
||||
path: '/wrong-url',
|
||||
icon: 'error',
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
path: '/about',
|
||||
icon: 'info',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders "Private Layout" composition
|
||||
* @layout PrivateLayout
|
||||
*/
|
||||
const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const title = TITLE_PRIVATE;
|
||||
document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
|
||||
const t = useTranslations('nav');
|
||||
|
||||
const sidebarItems: Array<LinkToPage> = [
|
||||
{ title: t('home'), path: '/', icon: 'home' },
|
||||
{ title: '404', path: '/wrong-url', icon: 'error' },
|
||||
];
|
||||
|
||||
return (
|
||||
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarPersistentOnDesktop">
|
||||
<TopBarAndSideBarLayout sidebarItems={sidebarItems} title="Balinyaar" variant="sidebarPersistentOnDesktop">
|
||||
{children}
|
||||
{/* <Stack component="footer">Copyright © </Stack> */}
|
||||
</TopBarAndSideBarLayout>
|
||||
|
||||
@@ -11,44 +11,12 @@ const TITLE_PUBLIC = 'Unauthorized - Balinyaar'; // Title for pages without/befo
|
||||
/**
|
||||
* SideBar navigation items with links for Public Layout
|
||||
*/
|
||||
const SIDE_BAR_ITEMS: Array<LinkToPage> = [
|
||||
{
|
||||
title: 'Log In',
|
||||
path: '/auth/login',
|
||||
icon: 'login',
|
||||
},
|
||||
{
|
||||
title: 'Sign Up',
|
||||
path: '/auth/signup',
|
||||
icon: 'signup',
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
path: '/about',
|
||||
icon: 'info',
|
||||
},
|
||||
];
|
||||
const SIDE_BAR_ITEMS: Array<LinkToPage> = [];
|
||||
|
||||
/**
|
||||
* BottomBar navigation items with links for Public Layout
|
||||
*/
|
||||
const BOTTOM_BAR_ITEMS: Array<LinkToPage> = [
|
||||
{
|
||||
title: 'Log In',
|
||||
path: '/auth/login',
|
||||
icon: 'login',
|
||||
},
|
||||
{
|
||||
title: 'Sign Up',
|
||||
path: '/auth/signup',
|
||||
icon: 'signup',
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
path: '/about',
|
||||
icon: 'info',
|
||||
},
|
||||
];
|
||||
const BOTTOM_BAR_ITEMS: Array<LinkToPage> = [];
|
||||
|
||||
/**
|
||||
* Renders "Public Layout" composition
|
||||
@@ -59,7 +27,6 @@ const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE;
|
||||
|
||||
const title = TITLE_PUBLIC;
|
||||
document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
|
||||
|
||||
return (
|
||||
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarAlwaysTemporary">
|
||||
|
||||
@@ -3,11 +3,11 @@ import { FunctionComponent, useMemo, useState } from 'react';
|
||||
import { Stack, StackProps } from '@mui/material';
|
||||
import { IS_DEBUG } from '@/config';
|
||||
import { AppIconButton, ErrorBoundary } from '@/components';
|
||||
import { useAppStore } from '@/store';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useEventSwitchDarkMode, useIsMobile } from '@/hooks';
|
||||
import { useIsMobile } from '@/hooks';
|
||||
import { TopBar } from './components';
|
||||
import SideBar, { SideBarProps } from './components/SideBar';
|
||||
import { DarkModeToggleButton } from './components/DarkModeButton';
|
||||
import {
|
||||
SIDE_BAR_DESKTOP_ANCHOR,
|
||||
SIDE_BAR_MOBILE_ANCHOR,
|
||||
@@ -23,14 +23,13 @@ interface Props extends StackProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders "TopBar and SideBar" composition
|
||||
* Renders "TopBar and SideBar" composition.
|
||||
* Does NOT subscribe to the color scheme — only <DarkModeToggleButton> does.
|
||||
* @layout TopBarAndSideBarLayout
|
||||
*/
|
||||
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant }) => {
|
||||
const [state] = useAppStore();
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false); // TODO: Verify is default value is correct
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
const onMobile = useIsMobile();
|
||||
const onSwitchDarkMode = useEventSwitchDarkMode();
|
||||
|
||||
const sidebarProps = useMemo((): Partial<SideBarProps> => {
|
||||
const anchor = onMobile ? SIDE_BAR_MOBILE_ANCHOR : SIDE_BAR_DESKTOP_ANCHOR;
|
||||
@@ -53,7 +52,7 @@ const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarIte
|
||||
|
||||
const stackStyles = useMemo(
|
||||
() => ({
|
||||
minHeight: '100vh', // Full screen height
|
||||
minHeight: '100vh',
|
||||
paddingTop: onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT,
|
||||
paddingLeft:
|
||||
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('left')
|
||||
@@ -67,43 +66,28 @@ const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarIte
|
||||
[onMobile, sidebarProps]
|
||||
);
|
||||
|
||||
const onSideBarOpen = () => {
|
||||
if (!sidebarVisible) setSidebarVisible(true); // Don't re-render Layout when SideBar is already open
|
||||
};
|
||||
|
||||
const onSideBarClose = () => {
|
||||
if (sidebarVisible) setSidebarVisible(false); // Don't re-render Layout when SideBar is already closed
|
||||
};
|
||||
const onSideBarOpen = () => { if (!sidebarVisible) setSidebarVisible(true); };
|
||||
const onSideBarClose = () => { if (sidebarVisible) setSidebarVisible(false); };
|
||||
|
||||
const LogoButton = (
|
||||
<AppIconButton
|
||||
icon="logo"
|
||||
title={sidebarProps.open ? undefined : 'Open Sidebar'}
|
||||
to={sidebarProps.open ? '/' : undefined} // Navigate to Home only when SideBar is closed
|
||||
onClick={sidebarProps.open ? undefined : onSideBarOpen} // Open SideBar only when it's closed
|
||||
to={sidebarProps.open ? '/' : undefined}
|
||||
onClick={sidebarProps.open ? undefined : onSideBarOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const DarkModeButton = (
|
||||
<AppIconButton
|
||||
icon={state.darkMode ? 'day' : 'night'} // Variant 1
|
||||
// icon="daynight" // Variant 2
|
||||
title={state.darkMode ? 'Switch to Light mode' : 'Switch to Dark mode'}
|
||||
onClick={onSwitchDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
// Note: useMemo() is not needed for startNode, endNode. We need respect store.darkMode and so on.
|
||||
/*
|
||||
* DarkModeToggleButton is a self-contained component that subscribes to
|
||||
* useColorScheme() on its own. This layout component never reads the color
|
||||
* scheme and therefore never re-renders when the theme switches.
|
||||
*/
|
||||
const { startNode, endNode } = sidebarProps?.anchor?.includes('left')
|
||||
? { startNode: LogoButton, endNode: DarkModeButton }
|
||||
: { startNode: DarkModeButton, endNode: LogoButton };
|
||||
? { startNode: LogoButton, endNode: <DarkModeToggleButton /> }
|
||||
: { startNode: <DarkModeToggleButton />, endNode: LogoButton };
|
||||
|
||||
IS_DEBUG &&
|
||||
console.log('Render <TopbarAndSidebarLayout/>', {
|
||||
onMobile,
|
||||
darkMode: state.darkMode,
|
||||
sidebarProps,
|
||||
});
|
||||
IS_DEBUG && console.log('Render <TopbarAndSidebarLayout/>', { onMobile, sidebarProps });
|
||||
|
||||
return (
|
||||
<Stack sx={stackStyles}>
|
||||
@@ -114,11 +98,7 @@ const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarIte
|
||||
|
||||
<Stack
|
||||
component="main"
|
||||
flexGrow={1} // Takes all possible space
|
||||
justifyContent="space-between" // Push children content (Footer, StatusBar, etc.) to the bottom
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
sx={{ flexGrow: 1, justifyContent: 'space-between', paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}
|
||||
>
|
||||
<ErrorBoundary name="Content">{children}</ErrorBoundary>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
/*
|
||||
* Tiny components that are the ONLY React nodes subscribed to useColorScheme().
|
||||
* When the user flips the theme:
|
||||
* 1. useColorScheme().setMode() sets data-mui-color-scheme on <html>
|
||||
* 2. CSS custom properties resolve to new values → browser repaints
|
||||
* 3. React re-renders ONLY these two components (icon label / switch state)
|
||||
* Nothing above them in the tree is touched.
|
||||
*/
|
||||
import { FormControlLabel, Switch, Tooltip } from '@mui/material';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppIconButton } from '@/components';
|
||||
|
||||
/** Icon button for the TopBar dark-mode toggle. */
|
||||
export function DarkModeToggleButton() {
|
||||
const { colorScheme, setMode } = useColorScheme();
|
||||
const t = useTranslations('common');
|
||||
const isDark = colorScheme === 'dark';
|
||||
return (
|
||||
<AppIconButton
|
||||
icon={isDark ? 'day' : 'night'}
|
||||
title={isDark ? t('light_mode') : t('dark_mode')}
|
||||
onClick={() => setMode(isDark ? 'light' : 'dark')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Labeled switch for the SideBar dark-mode toggle. */
|
||||
export function DarkModeFormSwitch() {
|
||||
const { colorScheme, setMode } = useColorScheme();
|
||||
const t = useTranslations('common');
|
||||
const isDark = colorScheme === 'dark';
|
||||
return (
|
||||
<Tooltip title={isDark ? t('light_mode') : t('dark_mode')}>
|
||||
<FormControlLabel
|
||||
label={isDark ? t('dark_mode') : t('light_mode')}
|
||||
control={<Switch checked={isDark} onChange={() => setMode(isDark ? 'light' : 'dark')} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FunctionComponent, useCallback, MouseEvent } from 'react';
|
||||
import { Stack, Divider, Drawer, DrawerProps, FormControlLabel, Switch, Tooltip } from '@mui/material';
|
||||
import { useAppStore } from '@/store';
|
||||
import { Stack, Divider, Drawer, DrawerProps } from '@mui/material';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useEventLogout, useEventSwitchDarkMode, useIsAuthenticated, useIsMobile } from '@/hooks';
|
||||
import { useEventLogout, useIsAuthenticated, useIsMobile } from '@/hooks';
|
||||
import { AppIconButton, UserInfo } from '@/components';
|
||||
import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config';
|
||||
import SideBarNavList from './SideBarNavList';
|
||||
import { DarkModeFormSwitch } from './DarkModeButton';
|
||||
|
||||
export interface SideBarProps extends Pick<DrawerProps, 'anchor' | 'className' | 'open' | 'variant' | 'onClose'> {
|
||||
items: Array<LinkToPage>;
|
||||
@@ -13,20 +13,11 @@ export interface SideBarProps extends Pick<DrawerProps, 'anchor' | 'className' |
|
||||
|
||||
/**
|
||||
* Renders SideBar with Menu and User details
|
||||
* Actually for Authenticated users only, rendered in "Private Layout"
|
||||
* @component SideBar
|
||||
* @param {string} anchor - 'left' or 'right'
|
||||
* @param {boolean} open - the Drawer is visible when true
|
||||
* @param {string} variant - variant of the Drawer, one of 'permanent', 'persistent', 'temporary'
|
||||
* @param {function} onClose - called when the Drawer is closing
|
||||
*/
|
||||
const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items, onClose, ...restOfProps }) => {
|
||||
const [state] = useAppStore();
|
||||
// const isAuthenticated = state.isAuthenticated; // Variant 1
|
||||
const isAuthenticated = useIsAuthenticated(); // Variant 2
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const onMobile = useIsMobile();
|
||||
|
||||
const onSwitchDarkMode = useEventSwitchDarkMode();
|
||||
const onLogout = useEventLogout();
|
||||
|
||||
const handleAfterLinkClick = useCallback(
|
||||
@@ -43,20 +34,19 @@ const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items
|
||||
anchor={anchor}
|
||||
open={open}
|
||||
variant={variant}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: SIDE_BAR_WIDTH,
|
||||
marginTop: onMobile ? 0 : variant === 'temporary' ? 0 : TOP_BAR_DESKTOP_HEIGHT,
|
||||
height: onMobile ? '100%' : variant === 'temporary' ? '100%' : `calc(100% - ${TOP_BAR_DESKTOP_HEIGHT})`,
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: SIDE_BAR_WIDTH,
|
||||
marginTop: onMobile ? 0 : variant === 'temporary' ? 0 : TOP_BAR_DESKTOP_HEIGHT,
|
||||
height: onMobile ? '100%' : variant === 'temporary' ? '100%' : `calc(100% - ${TOP_BAR_DESKTOP_HEIGHT})`,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
height: '100%',
|
||||
padding: 2,
|
||||
}}
|
||||
sx={{ height: '100%', padding: 2 }}
|
||||
{...restOfProps}
|
||||
onClick={handleAfterLinkClick}
|
||||
>
|
||||
@@ -73,19 +63,14 @@ const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items
|
||||
|
||||
<Stack
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={state.darkMode ? 'Switch to Light mode' : 'Switch to Dark mode'}>
|
||||
<FormControlLabel
|
||||
label={!state.darkMode ? 'Light mode' : 'Dark mode'}
|
||||
control={<Switch checked={state.darkMode} onChange={onSwitchDarkMode} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */}
|
||||
<DarkModeFormSwitch />
|
||||
|
||||
{isAuthenticated && <AppIconButton icon="logout" title="Logout Current User" onClick={onLogout} />}
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import CurrentLayout from './CurrentLayout';
|
||||
import PrivateLayout from './PrivateLayout';
|
||||
import PublicLayout from './PublicLayout';
|
||||
|
||||
export { PublicLayout, PrivateLayout };
|
||||
export default CurrentLayout;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Client-only cookie utilities.
|
||||
* Import as: import { ... } from '@/lib/cookies/client'
|
||||
*
|
||||
* All functions guard against server-side execution via
|
||||
* `typeof document === 'undefined'` checks, but they are intended for use
|
||||
* inside client components or useEffect hooks only.
|
||||
*/
|
||||
import { COLOR_SCHEME_COOKIE_OPTIONS, COOKIE_NAMES, type CookieOptions } from './constants';
|
||||
|
||||
function buildCookieString(name: string, value: string, options: CookieOptions): string {
|
||||
let str = `${name}=${encodeURIComponent(value)}`;
|
||||
if (options.path) str += `; path=${options.path}`;
|
||||
if (options.maxAge !== undefined) str += `; max-age=${options.maxAge}`;
|
||||
if (options.sameSite) str += `; samesite=${options.sameSite}`;
|
||||
if (options.secure) str += `; Secure`;
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cookie by name from document.cookie.
|
||||
* Returns undefined when the cookie is absent or when called on the server.
|
||||
*/
|
||||
export function getClientCookie(name: string): string | undefined {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a cookie to document.cookie.
|
||||
* Defaults to the standard 1-year / SameSite=Lax options.
|
||||
*/
|
||||
export function setClientCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options: CookieOptions = COLOR_SCHEME_COOKIE_OPTIONS,
|
||||
): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = buildCookieString(name, value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cookie by setting max-age=0.
|
||||
*/
|
||||
export function deleteClientCookie(name: string, path = '/'): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = buildCookieString(name, '', { path, maxAge: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted MUI color scheme from document.cookie.
|
||||
* Returns 'light' when the cookie is absent.
|
||||
*/
|
||||
export function getColorSchemeCookie(): 'light' | 'dark' {
|
||||
const value = getClientCookie(COOKIE_NAMES.COLOR_SCHEME);
|
||||
return value === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const COOKIE_NAMES = {
|
||||
COLOR_SCHEME: 'color-scheme',
|
||||
} as const;
|
||||
|
||||
export type CookieName = (typeof COOKIE_NAMES)[keyof typeof COOKIE_NAMES];
|
||||
|
||||
export interface CookieOptions {
|
||||
path?: string;
|
||||
maxAge?: number;
|
||||
sameSite?: 'strict' | 'lax' | 'none';
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
export const COLOR_SCHEME_COOKIE_OPTIONS: CookieOptions = {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Cookie utility library.
|
||||
*
|
||||
* Re-exports shared types and constants only.
|
||||
* Server and client utilities are intentionally kept in separate entry points
|
||||
* to prevent cross-environment bundling:
|
||||
*
|
||||
* Server Components / Actions / Route Handlers:
|
||||
* import { getColorSchemeCookie } from '@/lib/cookies/server'
|
||||
*
|
||||
* Client Components / useEffect:
|
||||
* import { setClientCookie, getClientCookie } from '@/lib/cookies/client'
|
||||
*/
|
||||
export * from './constants';
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Server-only cookie utilities.
|
||||
* Import as: import { ... } from '@/lib/cookies/server'
|
||||
*
|
||||
* These functions use `next/headers` and must only be called from
|
||||
* Server Components, Server Actions, or Route Handlers — never from
|
||||
* client components or shared utility files.
|
||||
*/
|
||||
import { cookies } from 'next/headers';
|
||||
import { COOKIE_NAMES, type CookieOptions } from './constants';
|
||||
|
||||
/**
|
||||
* Read a single cookie value from the incoming request.
|
||||
* Returns undefined when the cookie is absent or when called outside a
|
||||
* request context (e.g. build-time prerendering).
|
||||
*/
|
||||
export async function getServerCookie(name: string): Promise<string | undefined> {
|
||||
try {
|
||||
const store = await cookies();
|
||||
return store.get(name)?.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted color scheme from the request cookie and return the two
|
||||
* values the root layout needs:
|
||||
*
|
||||
* colorScheme — 'light' | 'dark' for data-mui-color-scheme on <html>
|
||||
* defaultMode — 'light' | 'dark' | 'system' for ThemeProvider's defaultMode
|
||||
*
|
||||
* When the cookie is absent (first ever visit) defaultMode is 'system' so
|
||||
* MUI uses the OS preference as its starting point, matching what
|
||||
* ColorSchemeScript does in the inline browser script.
|
||||
* On all subsequent visits the stored value is used for both fields, making
|
||||
* the server, the inline script, and MUI agree before the first paint.
|
||||
*/
|
||||
export async function getThemeMode(): Promise<{
|
||||
colorScheme: 'light' | 'dark';
|
||||
defaultMode: 'light' | 'dark' | 'system';
|
||||
}> {
|
||||
const value = await getServerCookie(COOKIE_NAMES.COLOR_SCHEME);
|
||||
if (value === 'dark') return { colorScheme: 'dark', defaultMode: 'dark' };
|
||||
if (value === 'light') return { colorScheme: 'light', defaultMode: 'light' };
|
||||
return { colorScheme: 'light', defaultMode: 'system' };
|
||||
}
|
||||
|
||||
/** @deprecated Use getThemeMode() instead */
|
||||
export async function getColorSchemeCookie(): Promise<'light' | 'dark'> {
|
||||
const { colorScheme } = await getThemeMode();
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a cookie via a Server Action or Route Handler response.
|
||||
*
|
||||
* NOTE: Cookies cannot be set from a Server Component's render function —
|
||||
* only from Server Actions (`'use server'`) or Route Handlers.
|
||||
* Call this inside a form action or API route, never during SSR render.
|
||||
*/
|
||||
export async function setServerCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options: CookieOptions = {},
|
||||
): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.set(name, value, options);
|
||||
}
|
||||
@@ -1,43 +1,15 @@
|
||||
import { Reducer } from 'react';
|
||||
import { localStorageSet } from '../utils/localStorage';
|
||||
import { AppStoreState } from './config';
|
||||
|
||||
/**
|
||||
* Reducer for global AppStore using "Redux styled" actions
|
||||
* @function AppReducer
|
||||
* @param {object} state - current/default state
|
||||
* @param {string} action.type - unique name of the action
|
||||
* @param {string} action.action - alternate to action.type property, unique name of the action
|
||||
* @param {*} [action.payload] - optional data object or the function to get data object
|
||||
*/
|
||||
const AppReducer: Reducer<AppStoreState, any> = (state, action) => {
|
||||
// console.log('AppReducer() - action:', action);
|
||||
switch (action.type || action.action) {
|
||||
case 'CURRENT_USER':
|
||||
return {
|
||||
...state,
|
||||
currentUser: action?.currentUser || action?.payload,
|
||||
};
|
||||
return { ...state, currentUser: action?.currentUser || action?.payload };
|
||||
case 'SIGN_UP':
|
||||
case 'LOG_IN':
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: true,
|
||||
};
|
||||
return { ...state, isAuthenticated: true };
|
||||
case 'LOG_OUT':
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: false,
|
||||
currentUser: undefined, // Also reset previous user data
|
||||
};
|
||||
case 'DARK_MODE': {
|
||||
const darkMode = action?.darkMode ?? action?.payload;
|
||||
localStorageSet('darkMode', darkMode);
|
||||
return {
|
||||
...state,
|
||||
darkMode,
|
||||
};
|
||||
}
|
||||
return { ...state, isAuthenticated: false, currentUser: undefined };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -8,40 +8,14 @@ import {
|
||||
Dispatch,
|
||||
ComponentType,
|
||||
} from 'react';
|
||||
// import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import AppReducer from './AppReducer';
|
||||
import { localStorageGet } from '../utils/localStorage';
|
||||
import { IS_SERVER } from '../utils/environment';
|
||||
import { APP_STORE_INITIAL_STATE, AppStoreState } from './config';
|
||||
|
||||
/**
|
||||
* Instance of React Context for global AppStore
|
||||
*/
|
||||
export type AppContextReturningType = [AppStoreState, Dispatch<any>];
|
||||
const AppContext = createContext<AppContextReturningType>([APP_STORE_INITIAL_STATE, () => null]);
|
||||
|
||||
/**
|
||||
* Main global Store as HOC with React Context API
|
||||
* @component AppStoreProvider
|
||||
* import {AppStoreProvider} from './store'
|
||||
* ...
|
||||
* <AppStoreProvider>
|
||||
* <App/>
|
||||
* </AppStoreProvider>
|
||||
*/
|
||||
const AppStoreProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
// const prefersDarkMode = IS_SERVER ? false : useMediaQuery('(prefers-color-scheme: dark)'); // Note: Conditional hook is bad idea :(
|
||||
const prefersDarkMode = IS_SERVER ? false : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const previousDarkMode = IS_SERVER ? false : Boolean(localStorageGet('darkMode', false));
|
||||
// const tokenExists = Boolean(loadToken());
|
||||
|
||||
const initialState: AppStoreState = {
|
||||
...APP_STORE_INITIAL_STATE,
|
||||
darkMode: previousDarkMode || prefersDarkMode,
|
||||
// isAuthenticated: tokenExists,
|
||||
};
|
||||
const value: AppContextReturningType = useReducer(AppReducer, initialState);
|
||||
|
||||
const value: AppContextReturningType = useReducer(AppReducer, APP_STORE_INITIAL_STATE);
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
/**
|
||||
* Data structure of the AppStore state
|
||||
*/
|
||||
export interface AppStoreState {
|
||||
darkMode: boolean;
|
||||
isAuthenticated: boolean;
|
||||
currentUser?: object | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial values for the AppStore state
|
||||
*/
|
||||
export const APP_STORE_INITIAL_STATE: AppStoreState = {
|
||||
darkMode: false, // Overridden by useMediaQuery('(prefers-color-scheme: dark)') in AppStore
|
||||
isAuthenticated: false, // Overridden in AppStore by checking auth token
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
|
||||
/**
|
||||
* Inline synchronous script placed in <head> — runs before any paint.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. Set data-mui-color-scheme on <html> 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 <script dangerouslySetInnerHTML={{ __html: script }} />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter';
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
|
||||
/**
|
||||
* Platform-specific ThemeProvider for Next.js
|
||||
* @component MuiThemeProviderForNextJs
|
||||
*/
|
||||
const MuiThemeProviderForNextJs: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
return <AppRouterCacheProvider>{children}</AppRouterCacheProvider>;
|
||||
};
|
||||
|
||||
export default MuiThemeProviderForNextJs;
|
||||
@@ -1,42 +1,67 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren, useEffect, useMemo, useState } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material/styles';
|
||||
|
||||
import { useAppStore } from '../store';
|
||||
import DARK_THEME from './dark';
|
||||
import LIGHT_THEME from './light';
|
||||
import MuiThemeProviderForNextJs from './MuiThemeProviderForNextJs';
|
||||
import { FunctionComponent, PropsWithChildren, useEffect } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, useColorScheme } from '@mui/material/styles';
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
|
||||
function getThemeByDarkMode(darkMode: boolean) {
|
||||
return darkMode ? createTheme(DARK_THEME) : createTheme(LIGHT_THEME);
|
||||
}
|
||||
// @ts-ignore — stylis-plugin-rtl ships CJS without bundled TS declarations
|
||||
import rtlPlugin from 'stylis-plugin-rtl';
|
||||
import { COOKIE_NAMES, COLOR_SCHEME_COOKIE_OPTIONS } from '@/lib/cookies';
|
||||
import { setClientCookie } from '@/lib/cookies/client';
|
||||
import { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
||||
|
||||
/**
|
||||
* Renders composition of Emotion's CacheProvider + MUI's ThemeProvider to wrap content of entire App
|
||||
* The Light or Dark themes applied depending on global .darkMode state
|
||||
* @component AppThemeProvider
|
||||
* Writes the resolved color scheme to our cookie whenever MUI's state changes.
|
||||
* Acts as a safety net for the first-visit / system-mode case where the
|
||||
* Storage.prototype intercept in ColorSchemeScript has nothing to intercept yet
|
||||
* (no setItem call fires until the user explicitly toggles).
|
||||
*/
|
||||
const AppThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const [state] = useAppStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
function ColorSchemeCookieSync() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const currentTheme = useMemo(
|
||||
() => getThemeByDarkMode(state.darkMode),
|
||||
[state.darkMode] // Observe AppStore and re-create the theme when .darkMode changes
|
||||
);
|
||||
useEffect(() => {
|
||||
if (colorScheme === 'dark' || colorScheme === 'light') {
|
||||
setClientCookie(COOKIE_NAMES.COLOR_SCHEME, colorScheme, COLOR_SCHEME_COOKIE_OPTIONS);
|
||||
}
|
||||
}, [colorScheme]);
|
||||
|
||||
useEffect(() => setLoading(false), []); // Set .loading to false when the component is mounted
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) return null; // Don't render anything until the component is mounted
|
||||
interface AppThemeProviderProps extends PropsWithChildren {
|
||||
/** Text direction derived from the active locale. Defaults to 'ltr'. */
|
||||
dir?: 'ltr' | 'rtl';
|
||||
/**
|
||||
* Initial color-scheme mode passed from the server after reading our cookie.
|
||||
* 'dark' | 'light' when the cookie exists; 'system' on first ever visit so
|
||||
* the OS preference is respected before the user makes an explicit choice.
|
||||
*/
|
||||
defaultMode?: 'light' | 'dark' | 'system';
|
||||
}
|
||||
|
||||
const AppThemeProvider: FunctionComponent<AppThemeProviderProps> = ({
|
||||
children,
|
||||
dir = 'ltr',
|
||||
defaultMode = 'system',
|
||||
}) => {
|
||||
const isRtl = dir === 'rtl';
|
||||
return (
|
||||
<MuiThemeProviderForNextJs>
|
||||
<MuiThemeProvider theme={currentTheme}>
|
||||
<CssBaseline /* MUI Styles */ />
|
||||
<AppRouterCacheProvider
|
||||
options={
|
||||
isRtl
|
||||
? { key: 'muirtl', stylisPlugins: [rtlPlugin] }
|
||||
: { key: 'muiltr' }
|
||||
}
|
||||
>
|
||||
<MuiThemeProvider
|
||||
theme={isRtl ? APP_THEME_RTL : APP_THEME_LTR}
|
||||
defaultMode={defaultMode}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<CssBaseline enableColorScheme />
|
||||
<ColorSchemeCookieSync />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</MuiThemeProviderForNextJs>
|
||||
</AppRouterCacheProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+72
-24
@@ -1,27 +1,75 @@
|
||||
import { PaletteOptions, SimplePaletteColorOptions } from '@mui/material';
|
||||
import { PaletteOptions } from '@mui/material';
|
||||
|
||||
const COLOR_PRIMARY: SimplePaletteColorOptions = {
|
||||
main: '#64B5F6',
|
||||
contrastText: '#000000',
|
||||
// light: '#64B5F6',
|
||||
// dark: '#64B5F6',
|
||||
};
|
||||
|
||||
const COLOR_SECONDARY: SimplePaletteColorOptions = {
|
||||
main: '#EF9A9A',
|
||||
contrastText: '#000000',
|
||||
// light: '#EF9A9A',
|
||||
// dark: '#EF9A9A',
|
||||
};
|
||||
|
||||
/**
|
||||
* MUI colors set to use in theme.palette
|
||||
/*
|
||||
* Raw hex values that mirror tokens.css.
|
||||
* Keep these in sync: tokens.css is what the browser renders,
|
||||
* BRAND + LIGHT/DARK_PALETTE are what MUI uses to generate --mui-palette-* variables.
|
||||
*/
|
||||
export const PALETTE_COLORS: Partial<PaletteOptions> = {
|
||||
primary: COLOR_PRIMARY,
|
||||
secondary: COLOR_SECONDARY,
|
||||
// error: COLOR_ERROR,
|
||||
// warning: COLOR_WARNING;
|
||||
// info: COLOR_INFO;
|
||||
// success: COLOR_SUCCESS;
|
||||
export const BRAND = {
|
||||
teal: '#1d4a40',
|
||||
tealLight: '#2f6b5e',
|
||||
tealDark: '#123029',
|
||||
tealContrast: '#f3efe9',
|
||||
terracotta: '#d98c6a',
|
||||
terracottaLight: '#e6a98a',
|
||||
terracottaDark: '#bf6f4d',
|
||||
terracottaContrast:'#2a1a12',
|
||||
cream: '#faf9f5',
|
||||
creamSoft: '#f3efe9',
|
||||
ink: '#1b2521',
|
||||
// Dark-mode surface & lifted brand
|
||||
tealOnDark: '#6fc0ac',
|
||||
tealOnDarkLight: '#8fd2c1',
|
||||
tealOnDarkDark: '#3f8a78',
|
||||
tealOnDarkContrast:'#06120f',
|
||||
tealDeep: '#0f1c19',
|
||||
tealSurface: '#16302a',
|
||||
} as const;
|
||||
|
||||
export const LIGHT_PALETTE: PaletteOptions = {
|
||||
primary: {
|
||||
main: BRAND.teal,
|
||||
light: BRAND.tealLight,
|
||||
dark: BRAND.tealDark,
|
||||
contrastText: BRAND.tealContrast,
|
||||
},
|
||||
secondary: {
|
||||
main: BRAND.terracotta,
|
||||
light: BRAND.terracottaLight,
|
||||
dark: BRAND.terracottaDark,
|
||||
contrastText: BRAND.terracottaContrast,
|
||||
},
|
||||
background: {
|
||||
default: BRAND.cream,
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: BRAND.ink,
|
||||
secondary: '#5b655f',
|
||||
},
|
||||
divider: 'rgba(29, 74, 64, 0.14)',
|
||||
};
|
||||
|
||||
export const DARK_PALETTE: PaletteOptions = {
|
||||
primary: {
|
||||
main: BRAND.tealOnDark,
|
||||
light: BRAND.tealOnDarkLight,
|
||||
dark: BRAND.tealOnDarkDark,
|
||||
contrastText: BRAND.tealOnDarkContrast,
|
||||
},
|
||||
secondary: {
|
||||
main: BRAND.terracottaLight,
|
||||
light: '#f0bfa3',
|
||||
dark: BRAND.terracotta,
|
||||
contrastText: BRAND.terracottaContrast,
|
||||
},
|
||||
background: {
|
||||
default: BRAND.tealDeep,
|
||||
paper: BRAND.tealSurface,
|
||||
},
|
||||
text: {
|
||||
primary: BRAND.creamSoft,
|
||||
secondary: '#9fb0a9',
|
||||
},
|
||||
divider: 'rgba(243, 239, 233, 0.14)',
|
||||
};
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { ThemeOptions } from '@mui/material';
|
||||
import { PALETTE_COLORS } from './colors';
|
||||
import { DARK_PALETTE } from './colors';
|
||||
import { TYPOGRAPHY } from './typography';
|
||||
|
||||
/**
|
||||
* MUI theme options for "Dark Mode"
|
||||
*/
|
||||
export const DARK_THEME: ThemeOptions = {
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
// background: {
|
||||
// paper: '#424242', // Gray 800 - Background of "Paper" based component
|
||||
// default: '#121212',
|
||||
// },
|
||||
...PALETTE_COLORS,
|
||||
},
|
||||
palette: { mode: 'dark', ...DARK_PALETTE },
|
||||
typography: TYPOGRAPHY,
|
||||
shape: { borderRadius: 10 },
|
||||
};
|
||||
|
||||
export default DARK_THEME;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const RTL_LOCALES = ['fa', 'ar', 'he', 'ur'];
|
||||
|
||||
export function getDirection(locale: string): 'ltr' | 'rtl' {
|
||||
return RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import AppThemeProvider from './ThemeProvider';
|
||||
import DARK_THEME from './dark';
|
||||
import LIGHT_THEME from './light';
|
||||
import { LIGHT_THEME } from './light';
|
||||
import { DARK_THEME } from './dark';
|
||||
import APP_THEME, { APP_THEME_LTR, APP_THEME_RTL } from './theme';
|
||||
import { getDirection } from './direction';
|
||||
import { ColorSchemeScript } from './ColorSchemeScript';
|
||||
|
||||
export {
|
||||
LIGHT_THEME as default, // Change to DARK_THEME if you want to use dark theme as default
|
||||
DARK_THEME,
|
||||
APP_THEME,
|
||||
APP_THEME_LTR,
|
||||
APP_THEME_RTL,
|
||||
LIGHT_THEME,
|
||||
DARK_THEME,
|
||||
LIGHT_THEME as default,
|
||||
AppThemeProvider as ThemeProvider,
|
||||
getDirection,
|
||||
ColorSchemeScript,
|
||||
};
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { ThemeOptions } from '@mui/material';
|
||||
import { PALETTE_COLORS } from './colors';
|
||||
import { LIGHT_PALETTE } from './colors';
|
||||
import { TYPOGRAPHY } from './typography';
|
||||
|
||||
/**
|
||||
* MUI theme options for "Light Mode"
|
||||
*/
|
||||
export const LIGHT_THEME: ThemeOptions = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
// background: {
|
||||
// paper: '#f5f5f5', // Gray 100 - Background of "Paper" based component
|
||||
// default: '#FFFFFF',
|
||||
// },
|
||||
...PALETTE_COLORS,
|
||||
},
|
||||
palette: { mode: 'light', ...LIGHT_PALETTE },
|
||||
typography: TYPOGRAPHY,
|
||||
shape: { borderRadius: 10 },
|
||||
};
|
||||
|
||||
export default LIGHT_THEME;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { LIGHT_PALETTE, DARK_PALETTE } from './colors';
|
||||
import { TYPOGRAPHY_LTR, TYPOGRAPHY_RTL } from './typography';
|
||||
|
||||
/*
|
||||
* Theme factory — called twice at module load (ltr + rtl) so components can
|
||||
* pick the right theme without re-creating it on every render.
|
||||
*
|
||||
* cssVariables.colorSchemeSelector: 'data-mui-color-scheme'
|
||||
* → MUI v9 sets data-mui-color-scheme="dark"|"light" on <html>.
|
||||
* (The shorthand 'data' produces boolean attrs data-dark/data-light which
|
||||
* our CSS selectors never match — must use the explicit attribute name.)
|
||||
*
|
||||
* direction
|
||||
* → MUI flips inline-start/end padding for components that care.
|
||||
* → ThemeProvider pairs this with a direction-aware Emotion cache that
|
||||
* runs stylis-plugin-rtl so all generated CSS is mirrored correctly.
|
||||
*/
|
||||
function createAppTheme(direction: 'ltr' | 'rtl') {
|
||||
return createTheme({
|
||||
cssVariables: {
|
||||
// MUI v9: 'data' produces boolean attrs (data-dark / data-light) which our
|
||||
// CSS never matches. An explicit attribute name produces the keyed form:
|
||||
// data-mui-color-scheme="dark" — matching tokens.css selectors exactly.
|
||||
colorSchemeSelector: 'data-mui-color-scheme',
|
||||
},
|
||||
colorSchemes: {
|
||||
light: { palette: LIGHT_PALETTE },
|
||||
dark: { palette: DARK_PALETTE },
|
||||
},
|
||||
defaultColorScheme: 'light',
|
||||
typography: direction === 'rtl' ? TYPOGRAPHY_RTL : TYPOGRAPHY_LTR,
|
||||
shape: { borderRadius: 10 },
|
||||
direction,
|
||||
});
|
||||
}
|
||||
|
||||
export const APP_THEME_LTR = createAppTheme('ltr');
|
||||
export const APP_THEME_RTL = createAppTheme('rtl');
|
||||
|
||||
export default APP_THEME_LTR;
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Balinyaar brand color tokens — source of truth for all colors.
|
||||
*
|
||||
* Palette extracted from the seed-deck proposal (balinyaar.html):
|
||||
* #1d4a40 deep teal / forest-green → brand primary
|
||||
* #d98c6a terracotta → brand accent (secondary)
|
||||
* #f3efe9 soft cream → text-on-dark / glyph fill
|
||||
* #faf9f5 cream → page surface
|
||||
*
|
||||
* MUI sets data-mui-color-scheme="dark" on <html> when dark mode is active
|
||||
* (colorSchemeSelector: 'data' in createTheme). The same attribute drives
|
||||
* every custom CSS rule in this file. You never need to read or write
|
||||
* this attribute manually — MUI's useColorScheme() does it for you.
|
||||
*
|
||||
* Usage in custom CSS outside MUI:
|
||||
* color: var(--bal-primary); ← resolves to the active scheme value
|
||||
* background: var(--bal-bg-default);
|
||||
*/
|
||||
|
||||
/* ── Light scheme (default) ────────────────────────────────────────────── */
|
||||
:root,
|
||||
[data-mui-color-scheme='light'] {
|
||||
/* Primary — deep teal */
|
||||
--bal-primary: #1d4a40;
|
||||
--bal-primary-light: #2f6b5e;
|
||||
--bal-primary-dark: #123029;
|
||||
--bal-primary-contrast: #f3efe9;
|
||||
|
||||
/* Secondary — terracotta */
|
||||
--bal-secondary: #d98c6a;
|
||||
--bal-secondary-light: #e6a98a;
|
||||
--bal-secondary-dark: #bf6f4d;
|
||||
--bal-secondary-contrast: #2a1a12;
|
||||
|
||||
/* Surfaces */
|
||||
--bal-bg-default: #faf9f5;
|
||||
--bal-bg-paper: #ffffff;
|
||||
|
||||
/* Text */
|
||||
--bal-text-primary: #1b2521;
|
||||
--bal-text-secondary: #5b655f;
|
||||
|
||||
/* Divider */
|
||||
--bal-divider: rgba(29, 74, 64, 0.14);
|
||||
}
|
||||
|
||||
/* ── Dark scheme ────────────────────────────────────────────────────────── */
|
||||
[data-mui-color-scheme='dark'] {
|
||||
/* Primary — lifted teal (readable on dark surfaces) */
|
||||
--bal-primary: #6fc0ac;
|
||||
--bal-primary-light: #8fd2c1;
|
||||
--bal-primary-dark: #3f8a78;
|
||||
--bal-primary-contrast: #06120f;
|
||||
|
||||
/* Secondary — warm terracotta-light */
|
||||
--bal-secondary: #e6a98a;
|
||||
--bal-secondary-light: #f0bfa3;
|
||||
--bal-secondary-dark: #d98c6a;
|
||||
--bal-secondary-contrast: #2a1a12;
|
||||
|
||||
/* Surfaces — deep teal */
|
||||
--bal-bg-default: #0f1c19;
|
||||
--bal-bg-paper: #16302a;
|
||||
|
||||
/* Text */
|
||||
--bal-text-primary: #f3efe9;
|
||||
--bal-text-secondary: #9fb0a9;
|
||||
|
||||
/* Divider */
|
||||
--bal-divider: rgba(243, 239, 233, 0.14);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TypographyVariantsOptions } from '@mui/material';
|
||||
|
||||
/** CSS variable injected by next/font/google (Space Grotesk) in src/app/layout.tsx */
|
||||
export const BRAND_FONT_VARIABLE_EN = '--font-space-grotesk';
|
||||
|
||||
/** CSS variable injected by next/font/local (Mikhak) in src/app/layout.tsx */
|
||||
export const BRAND_FONT_VARIABLE_FA = '--font-mikhak';
|
||||
|
||||
const SYSTEM_FONT_STACK = [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(', ');
|
||||
|
||||
/** Brand display font with graceful fallbacks. */
|
||||
const DISPLAY_FONT_LTR = `var(${BRAND_FONT_VARIABLE_EN}), "Space Grotesk", ${SYSTEM_FONT_STACK}`;
|
||||
|
||||
/** Persian display + body font with graceful fallbacks. */
|
||||
const DISPLAY_FONT_RTL = `var(${BRAND_FONT_VARIABLE_FA}), "Mikhak", "Tahoma", "Arial Unicode MS", sans-serif`;
|
||||
|
||||
const displayHeadingLtr = { fontFamily: DISPLAY_FONT_LTR, fontWeight: 700 } as const;
|
||||
const displayHeadingRtl = { fontFamily: DISPLAY_FONT_RTL, fontWeight: 700 } as const;
|
||||
|
||||
/** LTR typography — Space Grotesk headings, system font body. */
|
||||
export const TYPOGRAPHY_LTR: TypographyVariantsOptions = {
|
||||
fontFamily: SYSTEM_FONT_STACK,
|
||||
h1: displayHeadingLtr,
|
||||
h2: displayHeadingLtr,
|
||||
h3: displayHeadingLtr,
|
||||
h4: displayHeadingLtr,
|
||||
h5: displayHeadingLtr,
|
||||
h6: { ...displayHeadingLtr, fontWeight: 600 },
|
||||
button: { fontWeight: 600, textTransform: 'none' },
|
||||
};
|
||||
|
||||
/** RTL typography — Mikhak for all text (headings + body) to ensure full Persian glyph coverage. */
|
||||
export const TYPOGRAPHY_RTL: TypographyVariantsOptions = {
|
||||
fontFamily: DISPLAY_FONT_RTL,
|
||||
h1: displayHeadingRtl,
|
||||
h2: displayHeadingRtl,
|
||||
h3: displayHeadingRtl,
|
||||
h4: displayHeadingRtl,
|
||||
h5: displayHeadingRtl,
|
||||
h6: { ...displayHeadingRtl, fontWeight: 600 },
|
||||
button: { fontWeight: 600, textTransform: 'none' },
|
||||
};
|
||||
|
||||
/** @deprecated use TYPOGRAPHY_LTR or TYPOGRAPHY_RTL */
|
||||
export const TYPOGRAPHY = TYPOGRAPHY_LTR;
|
||||
+21
-6
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -10,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -18,10 +22,21 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user