another step for constructing base project

This commit is contained in:
hamid
2026-06-17 22:53:49 +03:30
parent 5b4c0d183f
commit 5388bea320
76 changed files with 3836 additions and 1961 deletions
+30 -2
View File
@@ -5,7 +5,35 @@
"Bash(rm -f server/CleanArcTemplate.nuspec)",
"Bash(rm -f server/.github/workflows/package.yml)",
"Bash(rmdir server/.github/workflows)",
"Bash(rmdir server/.github)"
]
"Bash(rmdir server/.github)",
"Bash(npm -v)",
"Bash(dotnet --version)",
"Bash(npm install *)",
"Bash(node -e \"const p=require\\('./node_modules/next/package.json'\\).version; const m=require\\('./node_modules/@mui/material/package.json'\\).version; const r=require\\('./node_modules/react/package.json'\\).version; const e=require\\('./node_modules/eslint/package.json'\\).version; const mn=require\\('./node_modules/@mui/material-nextjs/package.json'\\).version; console.log\\('next',p\\);console.log\\('@mui/material',m\\);console.log\\('@mui/material-nextjs',mn\\);console.log\\('react',r\\);console.log\\('eslint',e\\);\")",
"Bash(node -e ' *)",
"Bash(npx tsc *)",
"Bash(echo \"TYPE_EXIT=$?\")",
"Bash(npm run *)",
"PowerShell(ls \"c:\\\\Users\\\\Lenovo\\\\Desktop\\\\balinyaar\\\\client\\\\node_modules\\\\@mui\\\\material\" | Select-Object Name | Where-Object { $_.Name -match \"Color|Init|Css\" })",
"PowerShell(node -e \"const v = require\\('c:/Users/Lenovo/Desktop/balinyaar/client/node_modules/@testing-library/jest-dom/package.json'\\).version; console.log\\('@testing-library/jest-dom', v\\)\")",
"WebFetch(domain:www.nuget.org)",
"Bash(xargs grep -l \"Route\\\\|ApiVersion\\\\|Controller\")",
"PowerShell(cd \"c:\\\\Users\\\\Lenovo\\\\Desktop\\\\balinyaar\\\\server\"; dotnet build 2>&1 | Select-String -Pattern \"error|warning|Build succeeded|Build FAILED\" | Where-Object { $_ -notmatch \"NETSDK1057\" })",
"Read(//c/Users/Lenovo/Desktop/balinyaar/server/src/API/Baya.Web.Api/Controllers/V1/**)",
"Bash(cd /c/Users/Lenovo/Desktop/balinyaar/client && find . -type f -name \"*.ts\" -o -name \"*.tsx\" -o -name \"*.json\" -o -name \"*.mjs\" | head -50)",
"Read(//c/c/Users/Lenovo/Desktop/balinyaar/**)",
"Bash(xargs grep -l \"getLocale\\\\|setRequestLocale\\\\|requestLocale\")",
"Bash(node -e \"const p=require\\('c:/Users/Lenovo/Desktop/balinyaar/client/package.json'\\); console.log\\(JSON.stringify\\({scripts:p.scripts,next:p.dependencies?.next},null,2\\)\\)\")",
"Bash(node -e \"const p=require\\('c:/Users/Lenovo/Desktop/balinyaar/client/node_modules/next/package.json'\\); console.log\\(p.version\\)\")",
"Bash(node -e \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\)\\); const keys=Object.keys\\(d.exports||{}\\).filter\\(k=>k.includes\\('middleware'\\)||k.includes\\('server'\\)\\); console.log\\(keys.slice\\(0,20\\)\\)\")",
"Bash(xargs grep -l \"middleware\")",
"Bash(node -e \"const p=require\\('c:/Users/Lenovo/Desktop/balinyaar/client/node_modules/next-intl/package.json'\\); const exp=p.exports; const keys=Object.keys\\(exp||{}\\).filter\\(k=>k.includes\\('middleware'\\)\\); keys.forEach\\(k=>console.log\\(k,JSON.stringify\\(exp[k]\\)\\)\\)\")",
"PowerShell(Select-String -Path \"c:\\\\Users\\\\Lenovo\\\\Desktop\\\\balinyaar\\\\client\\\\node_modules\\\\next\\\\dist\\\\lib\\\\constants.js\" -Pattern \"MIDDLEWARE_FILENAME|MIDDLEWARE_LOCATION\" | ForEach-Object { $_.Line.Substring\\(0, [Math]::Min\\(300, $_.Line.Length\\)\\) })",
"Bash(node -e \"const p = require\\('c:/Users/Lenovo/Desktop/balinyaar/client/node_modules/next-intl/package.json'\\); console.log\\(JSON.stringify\\(p.exports['./plugin'] || p.exports, null, 2\\).substring\\(0, 1000\\)\\);\")",
"Bash(xargs grep -l \"HEADER_LOCALE_NAME\\\\|X-NEXT-INTL\")",
"Bash(xargs ls)",
"Bash(xargs grep \"AppStoreProvider\")"
],
"defaultMode": "bypassPermissions"
}
}
+287
View File
@@ -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.
+16
View File
@@ -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(),
}),
});
+12
View File
@@ -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"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"nav": {
"home": "خانه",
"login": "ورود"
},
"common": {
"dark_mode": "حالت تاریک",
"light_mode": "حالت روشن",
"direction_ltr": "تغییر به چپ‌به‌راست",
"direction_rtl": "تغییر به راست‌به‌چپ"
}
}
+9
View File
@@ -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
View File
@@ -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);
+1924 -986
View File
File diff suppressed because it is too large Load Diff
+21 -18
View File
@@ -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>;
}
+54
View File
@@ -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 }));
}
-19
View File
@@ -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;
-42
View File
@@ -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;
-21
View File
@@ -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;
-13
View File
@@ -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;
-9
View File
@@ -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.
-24
View File
@@ -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
View File
@@ -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;
}
-18
View File
@@ -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;
-3
View File
@@ -1,3 +0,0 @@
import HomePage from './home/page';
export default HomePage;
+10 -13
View File
@@ -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} />
) : (
+7 -10
View File
@@ -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]);
}
+1 -1
View File
@@ -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;
}
+13
View File
@@ -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,
};
});
+8
View File
@@ -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];
-15
View File
@@ -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;
+9 -31
View File
@@ -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 &copy; </Stack> */}
</TopBarAndSideBarLayout>
+2 -35
View File
@@ -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">
+19 -39
View File
@@ -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>
);
}
+14 -29
View File
@@ -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>
-2
View File
@@ -1,6 +1,4 @@
import CurrentLayout from './CurrentLayout';
import PrivateLayout from './PrivateLayout';
import PublicLayout from './PublicLayout';
export { PublicLayout, PrivateLayout };
export default CurrentLayout;
+58
View File
@@ -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';
}
+18
View File
@@ -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',
};
+14
View File
@@ -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';
+69
View File
@@ -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);
}
+3 -31
View File
@@ -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;
}
+1 -27
View File
@@ -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 -9
View File
@@ -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,
};
+52
View File
@@ -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;
+52 -27
View File
@@ -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
View File
@@ -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)',
};
+5 -12
View File
@@ -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;
+5
View File
@@ -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';
}
+12 -4
View File
@@ -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,
};
+5 -12
View File
@@ -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;
+41
View File
@@ -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;
+71
View File
@@ -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);
}
+53
View File
@@ -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
View File
@@ -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"
]
}
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>nursing — Seed Deck</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #faf9f5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
#__bundler_loading {
position: fixed;
bottom: 20px;
right: 20px;
font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif;
color: #666;
background: #fff;
padding: 8px 14px;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
z-index: 10000;
}
#__bundler_thumbnail {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #faf9f5;
z-index: 9999;
}
#__bundler_thumbnail svg {
width: 100%;
height: 100%;
object-fit: contain;
}
#__bundler_placeholder {
color: #999;
font-size: 14px;
}
</style>
<noscript>
<style>
#__bundler_loading {
display: none;
}
</style>
<div
style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
This page requires JavaScript to display.
</div>
</noscript>
</head>
<body>
<div id="__bundler_thumbnail">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#1d4a40"></rect>
<text x="30" y="58" font-family="Space Grotesk, sans-serif" font-size="34" font-weight="700"
fill="#f3efe9">n</text>
<circle cx="62" cy="52" r="6" fill="#d98c6a"></circle>
</svg>
</div>
<div id="__bundler_loading">Unpacking...</div>
</body>
</html>
+9 -5
View File
@@ -1,13 +1,17 @@
# make theme css variable based for mui in client
# configure theme base on the colors of proposal
# update packages in client for the last version and update documents and agents
# upgrade server packages to the last stable one and for those packages which their last version is not free anymore use the last possible one ( like automapper and masstransit that their last versions is paid but they have versions that are compatible with dotnet 10 )
# fix issue with locale detection
# cleanup layout
# add rules and conventions and structure again for both projects
# inspect the authorization flow and check both for client and server checking in the client project
# make sure there is a proper central service for all server and client ( one for server and one for client) fetchs which handles headers based on the browser cookies or cookies api in next.js
# ensure there is react query configured for server state management in the client project
# ensure proper error handling and toast for api errors in the client project
# add proper skills and instruction in each project to help the quaility of code written by agents
# add proper lint and type rules in client project and add rules for agents to do not violate those rules
+5
View File
@@ -0,0 +1,5 @@
ticket page,
backoffice,
verify the registration code
rate limit
workbox for cache ( maybe )
+114 -62
View File
@@ -1,109 +1,161 @@
# AGENTS.md — Balinyaar Server
Agent-oriented guide to the backend. For human setup/run instructions see [README.md](README.md).
> **Coding rules** are in [CONVENTIONS.md](CONVENTIONS.md) — read it before writing any server code.
---
## Role
You are a **senior .NET software engineer** working on this codebase. That means:
- You write production-quality code, not demo code. Every file you touch should look like it was written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it requires touching a contract that other layers depend on, think about the downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should be able to read your code without a guide.
- You never leave the codebase in a worse state than you found it. If you touch a file, leave it at least as clean as it was.
---
## Stack
- **ASP.NET Core / .NET 10** (`net10.0`), Web API
- **Clean Architecture** (Domain → Application → Infrastructure → API)
- **CQRS** with **MediatR** (source-generated `Mediator`)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work)
- **ASP.NET Core Identity** with **JWE** (signed + AES-encrypted JWT), **OTP**, and **dynamic permission** authorization
- **Mapster** (mapping), **FluentValidation** (validation), **Serilog** (logging), **OpenTelemetry** + **prometheus-net** (observability), **NSwag/Swagger** (OpenAPI), **Asp.Versioning** (API versioning)
- **xUnit** + **NSubstitute** (tests)
- Centralized NuGet versions in `Directory.Packages.props`
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, not MediatR)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **xUnit** + **NSubstitute** for tests
- All NuGet versions centrally pinned in `Directory.Packages.props`
---
## Commands (run from `server/`)
| Task | Command |
| ---------- | --------------------------------------------------------------------------------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Task | Command |
| ----------------- | ------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Startup project:** `src/API/Baya.Web.Api`. Default URL `https://localhost:5002`, Swagger at `/swagger`. On boot the app applies EF migrations and seeds default users (`Program.cs``ApplyMigrationsAsync()` / `SeedDefaultUsersAsync()`), so a reachable DB is required.
**Default URL:** `https://localhost:5002` Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL Server is required to start.
## Projects by layer
---
## Quality gates — run these before declaring work done
1. `dotnet build Baya.sln` — zero new warnings introduced.
2. `dotnet test Baya.sln` — all tests pass.
3. Read your own diff as if reviewing a PR. Ask: would a senior engineer approve this without comment?
---
## Project map
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Order, Role...), BaseEntity, IEntity, ITimeModification
│ └── Baya.Application CQRS Features/, Contracts/ (interfaces), Models/, MediatR pipeline (Common/)
│ ├── Baya.Domain Entities (User, Order, Role), BaseEntity, IEntity, ITimeModification
│ └── Baya.Application Features/ (Commands & Queries), Contracts/, Models/, pipeline behaviors (Common/)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Configuration/, Repositories/, Migrations/
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed), ServiceConfiguration/
│ ├── Baya.Infrastructure.CrossCutting Logging (Serilog)
│ └── Baya.Infrastructure.Monitoring HealthCheck / OpenTelemetry / Prometheus configs
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/, Migrations/
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/, appsettings*.json
│ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Attributes/
│ └── Plugins/Baya.Web.Plugins.Grpc GrpcPluginStartup, Services/, ProtoModels/
├── Shared/Baya.SharedKernel Extensions + validation base used by all layers
└── Tests/ Baya.Tests.Setup + Baya.Test.Infrastructure.Identity
│ ├── Baya.Web.Api Program.cs, Controllers/V1/, appsettings*.json
│ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup)
└── Baya.Test.Infrastructure.Identity xUnit identity tests
```
Dependency direction points **inward**: Domain depends on nothing; Application depends on Domain; Infrastructure and API implement/consume Application's contracts. Never make Domain or Application reference Infrastructure or the API.
**Dependency direction points inward.** Domain has no dependencies. Application depends only on Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or Application reference Infrastructure or the API — this is a hard rule.
## Startup wiring — `src/API/Baya.Web.Api/Program.cs`
---
Service registration is composed from per-layer extension methods (in each project's `ServiceConfiguration`):
## Startup wiring
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
```
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // MediatR + validators + pipeline behaviors
AddApplicationServices() // Mediator + validators + pipeline behaviors
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies
AddPersistenceServices(...) // DbContext, UnitOfWork, repositories
AddWebFrameworkServices() // API versioning
AddSwagger("v1","v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices() // gRPC plugin
AddWebFrameworkServices() // API versioning + snake_case routing
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices()
```
Pipeline order: exception handling → Swagger → routing → **authentication → authorization** → controllers → metrics → health checks → `ConfigureGrpcPipeline()`.
Pipeline order: exception handler → Swagger → routing → **authentication → authorization** → controllers → metrics → health checks → gRPC.
When adding infrastructure, expose it as an extension method and call it here rather than inlining into `Program.cs`.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` — never inline registrations there directly.
---
## CQRS — how a feature is shaped
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`. A query example (`Features/Order/Queries/GetAllOrders/`):
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:
- `GetAllOrdersQuery.cs``record ... : IRequest<OperationResult<...>>`
- `GetAllOrdersQueryHandler.cs``internal` handler; depends on `IUnitOfWork`, `IMapper`; returns `OperationResult<T>`
- `GetAllOrdersQueryResult.cs` — the DTO returned
```
Features/Order/
├── Commands/CreateOrderCommand/
│ ├── CreateOrderCommand.cs record : ICommand<OperationResult<T>>
│ ├── CreateOrderCommand.Handler.cs internal sealed class : ICommandHandler<...>
│ └── CreateOrderCommand.Validator.cs
└── Queries/GetUserOrdersQuery/
├── GetUserOrdersQuery.cs
├── GetUserOrdersQuery.Handler.cs
└── GetUserOrdersQuery.Result.cs
```
Commands additionally implement `IValidatableModel<T>` and declare FluentValidation rules; the `ValidateCommandBehavior` MediatR pipeline (`Application/Common/`) runs validators before the handler and surfaces errors in `OperationResult`.
Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected failures — use `OperationResult` factory methods.
**To add a feature:** create the folder with the request + handler (+ result/validator), then call it from a controller via `_sender.Send(...)`. Contracts the handler needs go in `Application/Contracts/` and are implemented in Infrastructure.
**To add a feature:** create the folder, implement request + handler + (optional) validator, add any new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller action to `sender.Send(...)`.
## Controllers & results
- Controllers live in `Baya.Web.Api/Controllers/V1/` and inherit `BaseController` (`Baya.WebFramework/BaseController/BaseController.cs`), which exposes `UserId`/`UserName`/etc. from claims and maps `OperationResult<T>``IActionResult`.
- All responses are wrapped in `OperationResult<T>` (`Application/Models/Common/`): `Result`, `IsSuccess`, `ErrorMessages`, `IsNotFound`, `IsException`. Use the factory methods (`SuccessResult`, `FailureResult`, `NotFoundResult`).
- Protected endpoints use `[Authorize(ConstantPolicies.DynamicPermission)]`.
---
## Persistence
- `ApplicationDbContext` (`Baya.Infrastructure.Persistence/ApplicationDbContext.cs`) extends `IdentityDbContext<...>`; it auto-registers `IEntity` types and applies all `IEntityTypeConfiguration` from the assembly.
- Per-entity config in `Configuration/<Area>Config/`. Repositories in `Repositories/` derive from `BaseAsyncRepository<T>`; expose them through `IUnitOfWork` (interface in `Application/Contracts/Persistence/`). Commit via `unitOfWork.CommitAsync()`.
- Migrations in `Migrations/`. Add new ones with the `dotnet ef` command above.
- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
- Commit once per command via `unitOfWork.CommitAsync()`.
- Use `AsNoTracking()` on all read-only queries.
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
---
## Identity & auth
- Token service: `Baya.Infrastructure.Identity/Jwt/JwtService.cs` (`IJwtService`) — issues JWE (HMAC-SHA256 signed, AES-128 encrypted), refresh tokens, and OTP/phone-based tokens.
- Custom Identity managers/stores under `Identity/Manager/` and `Identity/Store/`.
- Dynamic permissions: `Identity/PermissionManager/` (`DynamicPermissionService`, `DynamicPermissionHandler`, `ConstantPolicies`).
- Settings from `appsettings.json``IdentitySettings` (`SecretKey`, `Encryptkey` = 16 chars, `Issuer`, `Audience`, lifetimes).
- JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`).
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route values and checks role claims. The key format is set at runtime — always use `[controller]`/`[action]` tokens (see CONVENTIONS.md Routing rule) so the keys are consistent.
- Settings bound from `appsettings.json``IdentitySettings`.
## gRPC plugin
---
`Plugins/Baya.Web.Plugins.Grpc` is a self-contained module mounted via Application Parts. `GrpcPluginStartup.cs` provides `ConfigureGrpcPluginServices()` / `ConfigureGrpcPipeline()` (called from `Program.cs`). Proto contracts in `ProtoModels/*.proto`, services in `Services/`. The host uses HTTP/2 (`Kestrel` config) for gRPC.
## Conventions (see [CONVENTIONS.md](CONVENTIONS.md) for the full rule set)
## Conventions
Quick reference:
- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
- Controllers inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
- Never call `Ok()` / `BadRequest()` directly in controllers.
- Handlers are `internal sealed`; never throw for expected failures.
- Mapster for mapping; FluentValidation for validation.
- Package versions only in `Directory.Packages.props`.
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.
- Add cross-layer wiring as `ServiceConfiguration` extension methods, not inline in `Program.cs`.
- Keep handlers `internal`; return `OperationResult<T>`; don't throw for expected failures (use `FailureResult`/`NotFoundResult`).
- Use Mapster for entity↔DTO mapping; FluentValidation for input validation.
- Centralize package versions in `Directory.Packages.props` (no inline `Version=` in `.csproj`).
- The `Baya*` namespace/`.sln` naming is internal project naming, **not** template branding — don't rename it without an explicit request (it touches every file and the EF migrations).
---
## Known build warnings (pre-existing — do not fix unless tasked)
| Warning | Project | Note |
| ------- | ------- | ---- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |
+396
View File
@@ -0,0 +1,396 @@
# Server Coding Conventions
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [AGENTS.md](AGENTS.md).
When in doubt, ask: _would a senior engineer approve this diff without comment?_
---
## 1. Routing
### Rule: all URL segments must be `snake_case`
`SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is registered globally via `RouteTokenTransformerConvention`. It converts `[controller]` and `[action]` tokens automatically.
```csharp
// ✅ transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public class MyFeatureController : BaseController
{
[HttpGet("[action]")]
public Task<IActionResult> GetBySlug(...) { }
}
// ❌ bypasses transformer — hardcoded segment escapes snake_case enforcement
[Route("api/v{version:apiVersion}/MyFeature")]
[HttpGet("GetBySlug")]
```
If a method name doesn't read cleanly as a URL, **rename the method** — don't hardcode the route string.
---
## 2. C# code quality
### Use the right type for the job
| Scenario | Use |
|---|---|
| Request/response/DTO | `record` (immutable, value semantics) |
| Domain entity | `class` (mutable state, encapsulated) |
| Shared small value | `readonly record struct` |
| Handler, service | `sealed class` |
### Language features — use them
```csharp
// ✅ primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<...> { }
// ✅ switch expression over if/else chains
var label = status switch
{
OrderStatus.Pending => "Pending",
OrderStatus.Shipped => "Shipped",
OrderStatus.Cancelled => "Cancelled",
_ => throw new ArgumentOutOfRangeException(nameof(status))
};
// ✅ pattern matching
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
// ✅ collection expressions (C# 12)
List<string> tags = ["new", "sale"];
```
### Immutability & safety
- Mark fields `readonly` unless mutation is genuinely needed.
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` over `List<T>` in signatures unless the caller needs to mutate.
- Never expose public setters on entities — use methods or constructors.
- Avoid `static` mutable state.
### Null handling
- Enable `<Nullable>enable</Nullable>` in any new project you create.
- Use guard clauses at the entry point; don't scatter null checks throughout.
- Prefer returning `OperationResult.NotFoundResult(...)` over returning `null` from handlers.
- Never use `null!` (null-forgiving) unless you can prove the value cannot be null and the compiler cannot.
### Naming
| Kind | Convention | Example |
|---|---|---|
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
| Method | PascalCase | `GetUserOrdersAsync` |
| Parameter, local variable | camelCase | `orderId`, `userEmail` |
| Private field | `_camelCase` | `_unitOfWork` |
| Constant | PascalCase | `MaxRetryCount` |
| Generic type param | `T` or descriptive `TEntity` | |
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`, `intCount`).
---
## 3. Async / await
```csharp
// ✅ always async all the way — no .Result or .Wait()
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
{
var entity = await _repository.GetAsync(request.Id, ct);
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
}
// ❌ blocks the thread, risks deadlock
var result = _repository.GetAsync(id).Result;
// ✅ pass CancellationToken through every async call
await _db.SaveChangesAsync(cancellationToken);
// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
```
- Every public async method must accept `CancellationToken` and pass it downstream.
- Use `ValueTask<T>` for hot paths (handlers, repositories). Use `Task<T>` for rarely-called or always-async methods.
- Never use `async void` — it swallows exceptions. Use `async Task` even for event-like callbacks.
- Do not add `.ConfigureAwait(false)` in this ASP.NET Core app — it's unnecessary and adds noise.
---
## 4. Controllers
Every controller must follow this skeleton:
```csharp
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<MyQueryResult>]
public async Task<IActionResult> GetSomething(CancellationToken ct)
=> OperationResult(await sender.Send(new MyQuery(), ct));
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
```
Rules:
- `sealed` — controllers are not designed for inheritance beyond `BaseController`.
- Inject `ISender` via primary constructor — not `IMediator`.
- **Never call `Ok()`, `BadRequest()`, `NotFound()` directly** — always `base.OperationResult(result)`.
- Keep controller methods thin: one `Send`, one `OperationResult`. No business logic in controllers.
- Use `[Display(Description = "...")]` so NSwag generates meaningful Swagger tags.
- Pass `CancellationToken` from the action into `sender.Send(...)`.
### Authorization levels — use the narrowest that fits
| Attribute | When |
|---|---|
| _(none)_ | Truly public (health check, metrics) |
| `[Authorize]` | Any authenticated user |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | Role/claim-gated admin action |
| `[RequireTokenWithoutAuthorization]` | Token must be present but may be expired (e.g. refresh) |
Apply at the **controller level** for uniform policy; override at the action level only for exceptions.
---
## 5. CQRS — feature structure
```
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <Name>Command.cs record Command(…) : IRequest<OperationResult<T>>
│ ├── <Name>Command.Handler.cs internal sealed class Handler : IRequestHandler<…>
│ └── <Name>Command.Validator.cs AbstractValidator<Command> (omit if no validation needed)
└── Queries/<VerbNoun>Query/
├── <Name>Query.cs record Query(…) : IRequest<OperationResult<T>>
├── <Name>Query.Handler.cs internal sealed class Handler : IRequestHandler<…>
└── <Name>Query.Result.cs record Result(…) ← the DTO returned
```
- Request types are `record` — immutable.
- Handlers are `internal sealed` — they are never used outside the Application layer.
- **Handlers must not throw for expected failures.** Use `OperationResult` factory methods:
- `OperationResult<T>.SuccessResult(value)` — happy path
- `OperationResult<T>.FailureResult(errors)` — validation / business rule failure
- `OperationResult<T>.NotFoundResult(message)` — entity not found
- Only one handler per request type — no conditional dispatch.
- Contracts the handler depends on go in `Application/Contracts/` as interfaces; implementations live in Infrastructure.
---
## 6. Persistence — EF Core rules
```csharp
// ✅ project to DTO in the query — never load full entity for read operations
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads entire entity graph then maps in memory — N+1 risk
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
var dtos = _mapper.Map<List<OrderResult>>(orders);
```
Rules:
- **Always use `AsNoTracking()`** on read-only queries.
- **Always project with `Select()`** in queries — never hydrate full entities just to map them.
- Never load more than you need. Pagination is mandatory for any unbounded list: `Skip` / `Take`.
- Use `Include` only in command handlers where you need to mutate the aggregate and need navigation properties loaded.
- Access the DB through `IUnitOfWork` in Application-layer handlers. `ApplicationDbContext` is only referenced directly inside Infrastructure.
- Commit once per command at the end: `await _unitOfWork.CommitAsync(ct)`.
- One `IEntityTypeConfiguration<T>` per entity, in `Persistence/Configuration/<Area>Config/`.
- Migrations command: `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api`
### Soft delete
Every entity that supports soft delete **must** declare a global EF query filter in its `IEntityTypeConfiguration<T>`:
```csharp
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasQueryFilter(o => !o.IsDeleted);
}
```
Without this filter, soft-deleted records appear in every query that doesn't explicitly filter them — a silent data leak. Never add `Where(x => !x.IsDeleted)` in individual queries; the filter makes it automatic and auditable.
### Entity audit fields
When designing or extending an entity, include audit fields alongside timestamps:
| Field | Type | Set by |
|---|---|---|
| `CreatedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Add) |
| `ModifiedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Update) |
| `CreatedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
| `ModifiedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
Wire `ICurrentUser` (HTTP context accessor wrapped in an interface, registered Scoped) into `ApplicationDbContext` so the context can stamp who made the change without handlers needing to pass it explicitly. Audit fields cannot be backfilled retroactively — design them in from the start.
---
## 7. Validation
- All commands that accept user input need a `FluentValidation` validator. The `ValidateCommandBehavior` pipeline behavior runs it automatically before the handler.
- Validators are registered automatically via `RegisterValidatorsAsServices()` in `Program.cs`.
- Validate at the boundary (command/query), not deep in the domain or repositories.
```csharp
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
```
---
## 8. Mapping — Mapster rules
- Use `IMapper` (injected via DI) for all entity↔DTO mapping in handlers.
- Register type adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`. Add new assemblies that contain mapping configs there.
- Never write manual mapping code when Mapster can infer it — only write custom `TypeAdapterConfig` when shapes diverge.
- Mapping happens **in the handler after the DB query**, not in the repository.
---
## 9. Error handling & logging
```csharp
// ✅ expected failure — use OperationResult, do not throw
if (user is null)
return OperationResult<T>.NotFoundResult("User not found.");
// ✅ unexpected failure — let it propagate; ExceptionHandler middleware catches it
// Log at the point you catch unexpected exceptions (ExceptionHandler logs automatically)
// ❌ swallowing exceptions
try { ... } catch { return OperationResult<T>.FailureResult(...); }
// ✅ structured logging — never interpolate sensitive data
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);
// ❌ logs PII / secrets
_logger.LogInformation($"Token for {user.Email}: {token}");
```
- Log at the correct level: `Debug` for trace info, `Information` for meaningful events, `Warning` for recoverable issues, `Error` for unexpected failures.
- Never log passwords, tokens, secrets, or full PII (email is borderline — use `userId` in logs instead).
- The global `ExceptionHandler` middleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.
---
## 10. Testing
### Arrange — Act — Assert, always
```csharp
[Fact]
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
{
// Arrange
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Result.Should().NotBeNull();
}
```
- Test the **handler directly** — not the controller. Controllers are thin wrappers.
- Use `NSubstitute` for mocking: `Substitute.For<IUnitOfWork>()`.
- Integration tests use `Baya.Tests.Setup` which provides an in-memory SQLite context — prefer this over mocking the DB for persistence tests.
- Name tests: `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
- One assertion concept per test. Multiple `.Should()` calls are fine if they all verify the same outcome.
- Do not test EF internals (entity tracking, migrations) — test behavior through the handler.
### Integration tests — HTTP pipeline coverage
Handler tests verify business logic but leave the entire HTTP stack (routing, auth pipeline, middleware, `OperationResult → IActionResult` translation) untested. Each feature area must have at least one `WebApplicationFactory<Program>`-based test covering:
1. Happy path — authenticated request returns 200 with correct body shape.
2. Unauthenticated request returns 401.
3. Validation failure returns 400 with field-level error detail.
```csharp
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetSomething_Authenticated_Returns200()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
var response = await client.GetAsync("/api/v1/my_feature/get_something");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
```
Place these tests in a dedicated `Baya.Test.Api` project so they can run against the full `Program.cs` wiring.
---
## 11. Security rules
- **Never hardcode secrets.** Keys, connection strings, and tokens come from `appsettings.*.json` / user-secrets / environment variables, bound to typed settings classes.
- `SecretKey` and `Encryptkey` (in `IdentitySettings`) must be set in environment-specific config, never in `appsettings.json` committed to the repo.
- Always validate all external input with FluentValidation before processing.
- EF Core parameterizes queries automatically — never concatenate raw SQL.
- If you must use raw SQL, use `FromSqlInterpolated` (parameterized), never `FromSqlRaw` with user data.
- Respect the principle of least privilege: grant `[Authorize(ConstantPolicies.DynamicPermission)]` to admin actions, not just `[Authorize]`.
- **Auth and OTP endpoints must be rate-limited.** Use ASP.NET Core's built-in `AddRateLimiter` (no extra NuGet package needed). Apply at minimum to: login, OTP request, and token refresh. A fixed window or token bucket policy per IP is the baseline. Register the limiter in a `ServiceConfiguration/` extension; add `app.UseRateLimiter()` before `app.UseAuthentication()` in `Program.cs`.
---
## 12. Service registration
- Every new infrastructure service gets an extension method in the project's `ServiceConfiguration/` folder.
- That extension is called from `Program.cs` — no inline DI registration in `Program.cs`.
- Register with the correct lifetime:
- **Singleton** — stateless, thread-safe services (e.g. `IHttpContextAccessor`)
- **Scoped** — per-request services (repositories, `DbContext`, handlers)
- **Transient** — lightweight, stateless (validators, transformers)
- All NuGet versions live in `Directory.Packages.props`. Never add `Version=` to a `<PackageReference>` in a `.csproj`.
---
## 13. Code organisation
- One type per file. File name matches the type name exactly.
- Handlers and validators go in the same feature folder — not in separate `Handlers/` or `Validators/` root folders.
- If a file exceeds ~150 lines, consider splitting it. Long files usually mean mixed concerns.
- Partial classes are only for generated code (source generators, EF scaffolding).
- Keep `Program.cs` as an orchestrator — extension method calls only, no logic.
+34 -34
View File
@@ -4,54 +4,54 @@
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Http" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="FluentValidation" Version="12.1.0" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageVersion Include="Grpc.AspNetCore.Server.Reflection" Version="2.71.0" />
<PackageVersion Include="Mapster" Version="7.4.0" />
<PackageVersion Include="Mapster.DependencyInjection" Version="1.0.1" />
<PackageVersion Include="Mediator.Abstractions" Version="3.0.1" />
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.80.0" />
<PackageVersion Include="Grpc.AspNetCore.Server.Reflection" Version="2.80.0" />
<PackageVersion Include="Mapster" Version="10.0.8" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.6.2" />
<PackageVersion Include="NuGet.Packaging" Version="7.0.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Pluralize.NET" Version="1.0.2" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="9.0.2" />
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
</Project>
@@ -1,37 +0,0 @@
using Asp.Versioning;
using Baya.Application.Features.Admin.Commands.AddAdminCommand;
using Baya.Application.Features.Admin.Queries.GetToken;
using Baya.Application.Models.Jwt;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/AdminManager")]
public class AdminManagerController(ISender sender) : BaseController
{
[HttpPost("Login")]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> AdminLogin(AdminGetTokenQuery model)
{
var query = await sender.Send(model);
return base.OperationResult(query);
}
[Authorize(Roles = "admin")]
[HttpPost("NewAdmin")]
[ProducesOkApiResponseType]
public async Task<IActionResult> AddNewAdmin(AddAdminCommand model)
{
var commandResult = await sender.Send(model);
return base.OperationResult(commandResult);
}
}
}
@@ -1,36 +0,0 @@
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Order.Queries.GetAllOrders;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/OrderManagement")]
[Display(Description= "Managing Users related Orders")]
[Authorize(ConstantPolicies.DynamicPermission)]
public class OrderManagementController : BaseController
{
private readonly ISender _sender;
public OrderManagementController(ISender sender)
{
_sender = sender;
}
[HttpGet("OrderList")]
[ProducesOkApiResponseType<List<GetAllOrdersQueryResult>>]
public async Task<IActionResult> GetOrders()
{
var queryResult = await _sender.Send(new GetAllOrdersQuery());
return base.OperationResult(queryResult);
}
}
}
@@ -1,67 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Role.Commands.AddRoleCommand;
using Baya.Application.Features.Role.Commands.UpdateRoleClaimsCommand;
using Baya.Application.Features.Role.Queries.GetAllRolesQuery;
using Baya.Application.Features.Role.Queries.GetAuthorizableRoutesQuery;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/RoleManager")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Managing Related Roles for the System")]
public class RoleManagerController(ISender sender) : BaseController
{
[HttpGet("Roles")]
[ProducesOkApiResponseType<List<GetAllRolesQueryResponse>>]
public async Task<IActionResult> GetRoles()
{
var queryResult = await sender.Send(new GetAllRolesQuery());
return base.OperationResult(queryResult);
}
[HttpGet("AuthRoutes")]
[ProducesOkApiResponseType<List<GetAuthorizableRoutesQueryResponse>>]
public async Task<IActionResult> GetAuthRoutes()
{
var queryModel = await sender.Send(new GetAuthorizableRoutesQuery());
return base.OperationResult(queryModel);
}
/// <summary>
/// Update a role permissions (claims) based on RouteKey received in AuthRoutes API
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[HttpPut("UpdateRolePermissions")]
[ProducesOkApiResponseType]
public async Task<IActionResult> UpdateRolePermissions(UpdateRoleClaimsCommand model)
{
var commandResult =
await sender.Send(new UpdateRoleClaimsCommand(model.RoleId, model.RoleClaimValue));
return base.OperationResult(commandResult);
}
[HttpPost("NewRole")]
[ProducesOkApiResponseType]
public async Task<IActionResult> AddRole(AddRoleCommand model)
{
var commandResult = await sender.Send(model);
return base.OperationResult(commandResult);
}
}
}
@@ -1,36 +0,0 @@
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Users.Queries.GetUsers;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/UserManagement")]
[Display(Description = "Managing API Users")]
[Authorize(ConstantPolicies.DynamicPermission)]
public class UserManagementController : BaseController
{
private readonly ISender _sender;
public UserManagementController(ISender sender)
{
_sender = sender;
}
[HttpGet("CurrentUsers")]
[ProducesOkApiResponseType<List<GetUsersQueryResponse>>]
public async Task<IActionResult> GetAllUsers()
{
var queryResult = await _sender.Send(new GetUsersQuery());
return base.OperationResult(queryResult);
}
}
}
@@ -1,52 +0,0 @@
using Asp.Versioning;
using Baya.Application.Features.Order.Commands;
using Baya.Application.Features.Order.Queries.GetUserOrders;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.Order;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/User")]
[Authorize]
public class OrderController(ISender sender) : BaseController
{
[HttpPost("CreateNewOrder")]
[ProducesOkApiResponseType]
public async Task<IActionResult> CreateNewOrder(AddOrderCommand model)
{
model.UserId = base.UserId;
var command = await sender.Send(model);
return base.OperationResult(command);
}
[HttpGet("GetUserOrders")]
[ProducesOkApiResponseType<List<GetUsersQueryResultModel>>]
public async Task<IActionResult> GetUserOrders()
{
var query = await sender.Send(new GetUserOrdersQueryModel(UserId));
return base.OperationResult(query);
}
[HttpPut("UpdateOrder")]
[ProducesOkApiResponseType]
public async Task<IActionResult> UpdateOrder(UpdateUserOrderCommand model)
{
model.UserId=base.UserId;
var command = await sender.Send(model);
return base.OperationResult(command);
}
[HttpDelete("DeleteAllUserOrders")]
[ProducesOkApiResponseType]
public async Task<IActionResult> DeleteAllUserOrders()
=> base.OperationResult(await sender.Send(new DeleteUserOrdersCommand(base.UserId)));
}
@@ -1,88 +0,0 @@
using Asp.Versioning;
using Baya.Application.Features.Users.Commands.Create;
using Baya.Application.Features.Users.Commands.RefreshUserTokenCommand;
using Baya.Application.Features.Users.Commands.RequestLogout;
using Baya.Application.Features.Users.Queries.GenerateUserToken;
using Baya.Application.Features.Users.Queries.TokenRequest;
using Baya.Application.Models.Jwt;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.Swagger;
using Mediator;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.UserManagement;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/User")]
public class UserController : BaseController
{
private readonly IMediator _mediator;
public UserController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost("Register")]
[ProducesOkApiResponseType<UserCreateCommandResult>]
public async Task<IActionResult> CreateUser(UserCreateCommand model)
{
var command = await _mediator.Send(model);
return base.OperationResult(command);
}
[HttpPost("TokenRequest")]
[ProducesOkApiResponseType<UserTokenRequestQueryResponse>]
public async Task<IActionResult> TokenRequest(UserTokenRequestQuery model)
{
var query = await _mediator.Send(model);
return base.OperationResult(query);
}
[HttpPost("LoginConfirmation")]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> ValidateUser(GenerateUserTokenQuery model)
{
var result = await _mediator.Send(model);
return base.OperationResult(result);
}
[HttpPost("RefreshSignIn")]
[RequireTokenWithoutAuthorization]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> RefreshUserToken(RefreshUserTokenCommand model)
{
var checkCurrentAccessTokenValidity =await HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
if (checkCurrentAccessTokenValidity.Succeeded)
return BadRequest("Current access token is valid. No need to refresh");
var newTokenResult = await _mediator.Send(model);
return base.OperationResult(newTokenResult);
}
[HttpPost("Logout")]
[Authorize]
[ProducesOkApiResponseType]
public async Task<IActionResult> RequestLogout()
{
var commandResult = await _mediator.Send(new RequestLogoutCommand(base.UserId));
return base.OperationResult(commandResult);
}
[HttpPost("PasswordTokenRequest")]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> PasswordTokenRequest(PasswordUserTokenRequestQuery model)
=> base.OperationResult(await _mediator.Send(model));
}
+3 -1
View File
@@ -11,14 +11,15 @@ using Baya.Infrastructure.Identity.ServiceConfiguration;
using Baya.Infrastructure.Monitoring.Configurations;
using Baya.Infrastructure.Persistence.ServiceConfiguration;
using Baya.SharedKernel.Extensions;
using Baya.Web.Api.Controllers.V1.UserManagement;
using Baya.Web.Plugins.Grpc;
using Baya.WebFramework.Filters;
using Baya.WebFramework.Middlewares;
using Baya.WebFramework.Routing;
using Baya.WebFramework.ServiceConfiguration;
using Baya.WebFramework.Swagger;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -39,6 +40,7 @@ var identitySettings = configuration.GetSection(nameof(IdentitySettings)).Get<Id
builder.Services.AddControllers(options =>
{
options.Conventions.Add(new RouteTokenTransformerConvention(new SnakeCaseParameterTransformer()));
options.Filters.Add(typeof(OkResultAttribute));
options.Filters.Add(typeof(NotFoundResultAttribute));
options.Filters.Add(typeof(ContentResultFilterAttribute));
@@ -0,0 +1,17 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Routing;
namespace Baya.WebFramework.Routing;
public sealed class SnakeCaseParameterTransformer : IOutboundParameterTransformer
{
private static readonly Regex _upperAfterLower = new(@"([a-z0-9])([A-Z])", RegexOptions.Compiled);
private static readonly Regex _consecutiveUpper = new(@"([A-Z]+)([A-Z][a-z])", RegexOptions.Compiled);
public string TransformOutbound(object value)
{
if (value is null) return null;
var s = _consecutiveUpper.Replace(value.ToString()!, "$1_$2");
return _upperAfterLower.Replace(s, "$1_$2").ToLowerInvariant();
}
}
@@ -23,6 +23,7 @@ public class UnitOfWork : IUnitOfWork
public ValueTask RollBackAsync()
{
return _db.DisposeAsync();
_db.ChangeTracker.Clear();
return ValueTask.CompletedTask;
}
}