From 51f161eb3f0c62b46ea566264384cb8b4e70d5ad Mon Sep 17 00:00:00 2001 From: Mo Pazooki Date: Tue, 7 Apr 2026 07:21:01 +0100 Subject: [PATCH 1/4] refactor(admin): improve React patterns, SOLID compliance, and Next.js 16 conventions - Extract getAuthOptions to shared lib/auth/auth-options.ts (Dependency Inversion) - Convert book detail/edit [id] pages to Server Components with async params (Next.js 16) - Extract useBookMutation hook from BookForm (Single Responsibility) - Memoize LanguageContext and SidebarContext values with useMemo/useCallback - Fix LanguageContext setState-in-effect lint error with lazy state initializer - Add loading.tsx and error.tsx boundaries for book route segment - Fix hardcoded TailAdmin branding in not-found.tsx - Stabilize useGoBack hook with useCallback - Improve copilot-instructions.md with deeper codebase conventions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 71 +++++++--- .../(admin)/book/detail/[id]/PageContent.tsx | 78 +++++++++++ .../src/app/(admin)/book/detail/[id]/page.tsx | 85 ++---------- .../src/app/(admin)/book/edit/[id]/page.tsx | 28 ++-- .../admin/src/app/(admin)/book/error.tsx | 22 ++++ .../admin/src/app/(admin)/book/list/page.tsx | 2 +- .../admin/src/app/(admin)/book/loading.tsx | 7 + .../src/app/api/auth/[...nextauth]/route.ts | 88 +------------ .../admin/src/app/not-found.tsx | 2 +- .../admin/src/components/book/BookForm.tsx | 107 +-------------- .../admin/src/context/LanguageContext.tsx | 35 +++-- .../admin/src/context/SidebarContext.tsx | 45 ++++--- .../admin/src/hooks/useBookMutation.ts | 122 ++++++++++++++++++ .../admin/src/hooks/useGoBack.ts | 9 +- .../admin/src/lib/auth/auth-options.ts | 84 ++++++++++++ .../admin/src/lib/orval-fetch.ts | 2 +- 16 files changed, 459 insertions(+), 328 deletions(-) create mode 100644 CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/PageContent.tsx create mode 100644 CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx create mode 100644 CleanArchitecture.Presentation/admin/src/app/(admin)/book/loading.tsx create mode 100644 CleanArchitecture.Presentation/admin/src/hooks/useBookMutation.ts create mode 100644 CleanArchitecture.Presentation/admin/src/lib/auth/auth-options.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 694f356..0ec0cd4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,10 +7,10 @@ apply: always ## Build, test, and lint commands ### Backend and solution -- Install the Aspire workload before using the AppHost: `dotnet workload install` +- Install the Aspire workload before using the AppHost: `dotnet workload install aspire` - Restore packages: `dotnet restore` - Build the solution: `dotnet build --configuration Release` -- Use `dotnet build` as the .NET lint gate. `Directory.Build.props` enables analyzers, enforces code style in build, and treats warnings as errors across all `.csproj` files. +- Use `dotnet build` as the .NET lint gate. `Directory.Build.props` enables `AnalysisMode=All`, `EnforceCodeStyleInBuild`, `TreatWarningsAsErrors`, and SonarAnalyzer across all `.csproj` files. - Run all tests: `dotnet test --configuration Release` - Run one test project: `dotnet test Tests\Domain.UnitTests\Domain.UnitTests.csproj --configuration Release` - Run a single test class or method: `dotnet test Tests\Domain.UnitTests\Domain.UnitTests.csproj --configuration Release --filter "FullyQualifiedName~Domain.UnitTests.BookTests"` @@ -25,30 +25,65 @@ apply: always - Install dependencies: `pnpm install` - Start the admin app: `pnpm dev` - Build the admin app: `pnpm build` -- Lint the admin app: `pnpm lint` -- Regenerate API hooks and Zod schemas: `pnpm generate` +- Lint the admin app: `pnpm lint` (eslint with `--max-warnings=0`) +- Regenerate API hooks and Zod schemas: `pnpm generate` (runs orval against `http://localhost:5049/openapi/v1.json`) ## High-level architecture - `CleanArchitecture.sln` is split into Core (`CleanArchitecture.Domain`, `CleanArchitecture.Application`), Infrastructure (`CleanArchitecture.Infrastructure`, `CleanArchitecture.Infrastructure.Persistence`), Presentation (`CleanArchitecture.Presentation\API`, `CleanArchitecture.Presentation\admin`), Aspire (`CleanArchitecture.AppHost`, `CleanArchitecture.ServiceDefaults`), and four test projects. - `CleanArchitecture.Presentation\API\Program.cs` is the backend composition root. It adds Aspire service defaults first, then wires the Application, Infrastructure, Infrastructure.Persistence, and Presentation layers through their `Add*Services()` extension methods. - The API is a versioned Minimal API. Endpoints are grouped under `/api/v{version:apiVersion}` and then mapped by feature extension classes such as `BookEndpoints`. -- `CleanArchitecture.Infrastructure.Persistence` owns EF Core concerns: `ApplicationDbContext`, entity configurations, migrations, SaveChanges interceptors, and the unit-of-work implementation. -- `CleanArchitecture.Aspire\CleanArchitecture.AppHost\AppHost.cs` is the real local-dev entry point. In Development it starts Postgres + PgAdmin, Keycloak with realm import, the API, and the Next.js admin app. In Testing it starts only the API and an ephemeral Postgres database, which is why integration tests do not need a separately managed database or Keycloak instance. +- `CleanArchitecture.Infrastructure.Persistence` owns EF Core concerns: `ApplicationDbContext`, entity configurations, migrations, SaveChanges interceptors (`AuditableEntityInterceptor` for timestamps, `DispatchDomainEventsInterceptor` for publishing domain events after save), and the unit-of-work implementation. +- `CleanArchitecture.Aspire\CleanArchitecture.AppHost\AppHost.cs` is the real local-dev entry point. It has three environment modes: **Development** starts Postgres + PgAdmin, Keycloak with realm import, the API, and the Next.js admin app. **Testing** starts only the API and an ephemeral Postgres database (no Keycloak; `TestAuthHandler` grants all roles). **Production** targets Azure Container Apps, PostgreSQL Flexible Server, Key Vault, and Application Insights. - `CleanArchitecture.Aspire\CleanArchitecture.ServiceDefaults\Extensions.cs` adds the shared cross-cutting runtime behavior: Serilog, OpenTelemetry, service discovery, resilience handlers, and health endpoints. -- `CleanArchitecture.Presentation\admin` is a Next.js 16 App Router admin app. It uses NextAuth with Keycloak, TanStack Query for API hooks, and generated API clients/Zod schemas under `src\lib\api` and `src\lib\api\zod`. -- The Next.js frontend acts as a Backend for Frontend (BFF), with only the frontend and Keycloak admin UI publicly exposed. The .NET API remains private, allowing internal HTTP communication between the frontend, API, and Keycloak. +- `CleanArchitecture.Presentation\admin` is a Next.js 16 App Router admin app acting as a **Backend for Frontend (BFF)**. Only the frontend and Keycloak admin UI are publicly exposed; the .NET API remains private. The admin uses NextAuth with Keycloak, TanStack Query for server state, and orval-generated API clients/Zod schemas under `src\lib\api` and `src\lib\api\zod`. ## Key conventions -- Preserve the layer boundaries enforced by `Tests\Architecture.UnitTests\ArchitectureTests.cs`. Domain must not reference Application, Infrastructure, or Presentation; Application must not reference Infrastructure or Presentation; Infrastructure projects must not reference Presentation. -- Backend use cases follow CQRS with Mediator source generation and FluentValidation auto-registration from `CleanArchitecture.Application\DependencyInjection.cs`. New features should follow the existing `Entities\\Commands` and `Entities\\Queries` layout. -- Application and domain code return `Result` or `Result`. Minimal API endpoints translate those results to HTTP responses through `CleanArchitecture.Presentation\API\Extensions\ResultExtensions.cs` instead of building ad hoc response shapes. -- Write endpoints use `SendWithRetryAsync()` from `CleanArchitecture.Presentation\API\Configuration\MediatorPollyExtensions.cs`, which wraps mediator calls with Polly retry, circuit-breaker, and fallback behavior. Read endpoints call `sender.Send()` directly. -- Non-production startup automatically applies EF Core migrations in `CleanArchitecture.Presentation\API\Configuration\WebApplicationExtensions.cs`. The EF CLI works because `CleanArchitecture.Infrastructure.Persistence\Data\ApplicationDbContextFactory.cs` supplies a design-time Npgsql connection when Aspire has not injected `ConnectionStrings:postgresdb`. -- Authentication is environment-sensitive. Normal environments use Keycloak JWT bearer auth with named policies from `CleanArchitecture.Infrastructure\Security`; the `Testing` environment swaps in `TestAuthHandler`, which grants the integration test client all required roles. +### Architecture and layer boundaries +- Preserve the layer boundaries enforced by `Tests\Architecture.UnitTests\ArchitectureTests.cs`. Domain → no project references. Application → Domain only. Infrastructure → Application (+ Domain). Presentation → Application + Infrastructure. +- Backend use cases follow CQRS with the **Mediator** source-generator package (not MediatR) and FluentValidation auto-registration from `CleanArchitecture.Application\DependencyInjection.cs`. New features should follow the existing `Entities\\Commands\\` and `Entities\\Queries\\` layout, each containing a command/query record, handler, and validator. + +### Domain model +- Domain entities inherit from `Entity` → `AggregateRoot` (or their auditable variants `EntityAuditable` → `AggregateRootAuditable` which add `CreatedDate`/`UpdatedDate`). `Entity` provides identity-based equality and a `DomainEvents` collection. +- Aggregates expose static factory methods (e.g., `Book.Create(...)`) that perform domain validation and return `Result`. Use `DomainError` records with `ErrorType` (Validation, NotFound, Conflict, Failure) for domain-level errors, defined in a per-aggregate `*Errors` class. +- Domain events are raised via `AddDomainEvent()` on the entity and dispatched automatically by `DispatchDomainEventsInterceptor` after `SaveChangesAsync`. Handlers live in `Application\Entities\\EventHandlers\`. + +### Application layer patterns +- Command/query handlers extend `BaseRequestHandler`, which runs FluentValidation before delegating to the `HandleRequest` override. This means validation is automatic — add a `*Validator.cs` and it's picked up. +- `LoggingBehaviour<,>` is registered as a pipeline behavior. It logs all request start/end and flags slow requests (>1 second) with a warning. +- Application and domain code return `Result` or `Result` (from the `DomainValidation` package). Never throw exceptions for expected failures. + +### Minimal API endpoints +- Endpoints are static methods in feature-scoped classes (e.g., `BookEndpoints`). Each class exposes a `Map*Endpoints(this RouteGroupBuilder)` extension. +- Translate `Result` to HTTP via `ResultExtensions`: `ToCreatedResponse`, `ToProblemDetails`, `ToNoContentResponse`. Error types map to: Validation → 422, NotFound → 404, Conflict → 409, other → 400/500. Responses use RFC 9110 ProblemDetails with error codes in `extensions.errors`. +- Write endpoints use `sender.SendWithRetryAsync()` (Polly: retry 3× exponential, circuit breaker, fallback). Read endpoints use `sender.Send()` directly. +- Read endpoints use `CacheOutput` with tag-based invalidation. After a successful write, call `cacheStore.EvictByTagAsync("tag")`. +- Authorization uses named policies: `ViewerPolicy`, `EditorPolicy`, `AdminPolicy`. Permission roles from Keycloak: `view`, `create`, `edit`, `delete`. + +### Authentication +- Authentication is environment-sensitive. Normal environments use Keycloak JWT bearer auth via `AddKeycloakJwtBearer`; the `Testing` environment swaps in `TestAuthHandler`, which grants the integration test client all required roles. - OpenAPI and Scalar are only mapped in Development when the backend user secret `ScalarApi:ClientSecret` is set. This matters for `pnpm generate`, because `orval.config.ts` reads `http://localhost:5049/openapi/v1.json`. -- Treat `CleanArchitecture.Presentation\admin\src\lib\api` and `CleanArchitecture.Presentation\admin\src\lib\api\zod` as generated output. Update the backend contract and rerun `pnpm generate` instead of hand-editing those files. The repo also relies on `scripts\fix-zod-schemas.js` after generation. -- Use `pnpm` for the admin app. The repo commits `pnpm-lock.yaml`, the AppHost starts the frontend with `.WithPnpm()`, and `.aiassistant\rules\instructions.md` assumes pnpm-based frontend workflows. -- The admin app behaves like a thin BFF. `next.config.ts` rewrites browser calls from `/api/v1/*` to `API_BASE_URL`, while `src\lib\orval-fetch.ts` attaches the NextAuth access token for both server-side and client-side requests. -- Local secrets live in two places: backend user secrets for `ScalarApi:ClientSecret`, and `CleanArchitecture.Presentation\admin\.env.local` for `API_BASE_URL`, `NEXTAUTH_*`, and `KEYCLOAK_*`. + +### Database and migrations +- Non-production startup automatically applies EF Core migrations in `WebApplicationExtensions.cs`. The EF CLI works because `ApplicationDbContextFactory.cs` supplies a design-time Npgsql connection when Aspire has not injected `ConnectionStrings:postgresdb`. +- Entity configurations use `IEntityTypeConfiguration` in `Infrastructure.Persistence`, auto-discovered via `ApplyConfigurationsFromAssembly`. + +### Testing patterns +- Integration tests use `DistributedApplicationTestingBuilder` to spin up the full Aspire stack with `--environment=Testing`. Tests share a fixture via `[Collection("DistributedApplication collection")]` and create `HttpClient` per class via `App.CreateHttpClient("cleanarchitecture-api")`. +- Unit tests use xUnit + Moq. Domain and Application behavior are tested in `Tests\Domain.UnitTests` and `Tests\Application.UnitTests`. +- CI workflow (`.github\workflows\dotnet.yml`) builds with `--configuration Release`, runs all tests, and sets `ASPIRE_HOSTING_TESTING_DISABLE_DASHBOARD=true`. + +### Admin app (Next.js) +- Use `pnpm` exclusively. The repo commits `pnpm-lock.yaml` and the AppHost starts the frontend with `.WithPnpm()`. +- Treat `src\lib\api` and `src\lib\api\zod` as **generated output**. Update the backend contract and rerun `pnpm generate` instead of hand-editing those files. +- The custom fetcher `src\lib\orval-fetch.ts` detects server vs. client context: server-side prepends `API_BASE_URL` and uses `getServerSession()`; client-side sends relative URLs and uses `getSession()`. Both inject `Authorization: Bearer` headers. On 401, it triggers Keycloak re-authentication. +- Routes are organized under `src\app\(admin)\` (protected layout with sidebar) and unauthenticated routes at root (`/signin`). An API proxy at `src\app\api\v1\[...path]\route.ts` forwards browser calls to the backend. +- Frontend has i18n support (en, fa, ar) with RTL layout via `LanguageContext`. Theme supports dark/light mode via `ThemeContext`. +- Permission-based UI: `useUserPermissions()` hook reads roles from the NextAuth session (populated from Keycloak JWT) to conditionally render create/edit/delete actions. +- Forms use React Hook Form + Zod validation + a `fieldErrorMap` pattern that maps API `ProblemDetails` error codes to form field names for inline error display. +- Context provider hierarchy in root layout: `AuthProvider` → `QueryProvider` (TanStack Query, 60s stale time) → `LanguageProvider` → `ThemeProvider` → `SidebarProvider`. +- Local secrets: backend user secrets for `ScalarApi:ClientSecret`, and `CleanArchitecture.Presentation\admin\.env.local` for `API_BASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`, `KEYCLOAK_ISSUER`. + +### Commits and PRs +- Use conventional commits: `feat:`, `fix:`, `chore:`, `refactor:`, `test:`, `docs:`. diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/PageContent.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/PageContent.tsx new file mode 100644 index 0000000..2adde07 --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/PageContent.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useGetApiV1BooksId } from "@/lib/api/books/books"; +import { + extractApiErrors, + formatErrorMessages, +} from "@/lib/utils/error-handler"; +import ComponentCard from "@/components/common/ComponentCard"; +import { useUserPermissions } from "@/hooks/useUserPermissions"; +import { getGenreLabel } from "@/lib/books/genre"; +import { useLanguage } from "@/context/LanguageContext"; + +interface PageContentProps { + id: string; +} + +export default function PageContent({ id }: PageContentProps) { + const router = useRouter(); + const { canEdit } = useUserPermissions(); + const { t } = useLanguage(); + + const { data: response, error, isLoading } = useGetApiV1BooksId(id, { + query: { + enabled: Boolean(id), + }, + }); + + if (isLoading) { + return
{t("loading_book_details")}
; + } + + if (error) { + return ( +
+ {formatErrorMessages(extractApiErrors(error))} +
+ ); + } + + const book = response?.status === 200 && response.data.isSuccess ? response.data.value : null; + if (!book) { + return
{t("book_not_found")}
; + } + + return ( + +
+
+

{t("title")}

+

{book.title}

+
+
+

{t("genre")}

+

{getGenreLabel(book.genre)}

+
+
+ {canEdit ? ( + + {t("edit_book")} + + ) : null} + +
+
+
+ ); +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx index 96e8875..8d30126 100644 --- a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/detail/[id]/page.tsx @@ -1,82 +1,25 @@ -"use client"; - -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useGetApiV1BooksId } from "@/lib/api/books/books"; -import { - extractApiErrors, - formatErrorMessages, -} from "@/lib/utils/error-handler"; -import ComponentCard from "@/components/common/ComponentCard"; +import type { Metadata } from "next"; import PageBreadcrumb from "@/components/common/PageBreadCrumb"; -import { useUserPermissions } from "@/hooks/useUserPermissions"; -import { getGenreLabel } from "@/lib/books/genre"; -import { useLanguage } from "@/context/LanguageContext"; - -export default function BookDetailPage() { - const params = useParams<{ id: string }>(); - const bookId = Array.isArray(params.id) ? params.id[0] : params.id; - const router = useRouter(); - const { canEdit } = useUserPermissions(); - const { t } = useLanguage(); - - const { data: response, error, isLoading } = useGetApiV1BooksId(bookId ?? "", { - query: { - enabled: Boolean(bookId), - }, - }); +import PageContent from "./PageContent"; - if (isLoading) { - return
{t("loading_book_details")}
; - } +interface BookDetailPageProps { + params: Promise<{ id: string }>; +} - if (error) { - return ( -
- {formatErrorMessages(extractApiErrors(error))} -
- ); - } +export const metadata: Metadata = { + title: "Book Details | Clean Architecture Admin", + description: "View book details", +}; - const book = response?.status === 200 && response.data.isSuccess ? response.data.value : null; - if (!book) { - return
{t("book_not_found")}
; - } +export default async function BookDetailPage({ params }: BookDetailPageProps) { + const { id } = await params; return (
- +
- -
-
-

{t("title")}

-

{book.title}

-
-
-

{t("genre")}

-

{getGenreLabel(book.genre)}

-
-
- {canEdit ? ( - - {t("edit_book")} - - ) : null} - -
-
-
+
); -} +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx index 8a515c5..82f7e0c 100644 --- a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/edit/[id]/page.tsx @@ -1,21 +1,25 @@ -"use client"; - -import { useParams } from "next/navigation"; -import BookForm from "@/components/book/BookForm"; +import type { Metadata } from "next"; import PageBreadcrumb from "@/components/common/PageBreadCrumb"; -import { useLanguage } from "@/context/LanguageContext"; +import BookForm from "@/components/book/BookForm"; -export default function EditBookPage() { - const params = useParams<{ id: string }>(); - const bookId = Array.isArray(params.id) ? params.id[0] : params.id; - const { t } = useLanguage(); +interface EditBookPageProps { + params: Promise<{ id: string }>; +} + +export const metadata: Metadata = { + title: "Edit Book | Clean Architecture Admin", + description: "Edit an existing book", +}; + +export default async function EditBookPage({ params }: EditBookPageProps) { + const { id } = await params; return (
- +
- +
); -} +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx new file mode 100644 index 0000000..0beb6f0 --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx @@ -0,0 +1,22 @@ +"use client"; + +interface BookErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function BookError({ error, reset }: BookErrorProps) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx index 632dfb1..b1b2b6b 100644 --- a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/list/page.tsx @@ -1,4 +1,4 @@ -import { getAuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAuthOptions } from "@/lib/auth/auth-options"; import { Metadata } from "next"; import { getServerSession } from "next-auth"; import { hasRole } from "@/lib/auth/permissions"; diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/loading.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/loading.tsx new file mode 100644 index 0000000..8050d62 --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/loading.tsx @@ -0,0 +1,7 @@ +export default function BookLoading() { + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts b/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts index afb2ffb..0a8eb5c 100644 --- a/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts +++ b/CleanArchitecture.Presentation/admin/src/app/api/auth/[...nextauth]/route.ts @@ -1,93 +1,11 @@ import type { NextRequest } from "next/server"; -import NextAuth, { type NextAuthOptions } from "next-auth"; -import KeycloakProvider from "next-auth/providers/keycloak"; -import { getEnvVars } from "@/config/env-vars"; +import NextAuth from "next-auth"; +import { getAuthOptions } from "@/lib/auth/auth-options"; type RouteHandlerContext = { params: Promise<{ nextauth: string[] }>; }; -type JwtPayload = { - roles?: string[]; - realm_access?: { - roles?: string[]; - }; -}; - -function parseJwtPayload(token: string): JwtPayload | null { - try { - const payload = token.split(".")[1]; - - if (!payload) { - return null; - } - - return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as JwtPayload; - } catch { - return null; - } -} - -function extractRoles(accessToken: string): string[] { - const payload = parseJwtPayload(accessToken); - const roles = payload?.roles ?? payload?.realm_access?.roles ?? []; - - return Array.isArray(roles) ? roles : []; -} - -export function getAuthOptions(): NextAuthOptions { - const { KEYCLOAK_ISSUER, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, NEXTAUTH_SECRET } = getEnvVars(); - const keycloakIssuer = KEYCLOAK_ISSUER.replace(/\/+$/, ""); - - return { - providers: [ - KeycloakProvider({ - clientId: KEYCLOAK_CLIENT_ID, - clientSecret: KEYCLOAK_CLIENT_SECRET, - issuer: keycloakIssuer, - authorization: { - params: { - scope: "openid profile email permissions", - }, - }, - }), - ], - pages: { - signIn: "/signin", - }, - secret: NEXTAUTH_SECRET, - session: { - strategy: "jwt", - }, - callbacks: { - async jwt({ token, account }) { - if (account?.access_token) { - token.accessToken = account.access_token; - token.roles = extractRoles(account.access_token); - } - - if (account?.id_token) { - token.idToken = account.id_token; - } - - return token; - }, - async session({ session, token }) { - if (session.user && token.sub) { - session.user.id = token.sub; - session.user.roles = Array.isArray(token.roles) ? token.roles : []; - } - - if (typeof token.accessToken === "string") { - session.accessToken = token.accessToken; - } - - return session; - }, - }, - } satisfies NextAuthOptions; -} - async function handleAuth(request: NextRequest, context: RouteHandlerContext) { return NextAuth(request, context, getAuthOptions()); } @@ -98,4 +16,4 @@ export function GET(request: NextRequest, context: RouteHandlerContext) { export function POST(request: NextRequest, context: RouteHandlerContext) { return handleAuth(request, context); -} +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/app/not-found.tsx b/CleanArchitecture.Presentation/admin/src/app/not-found.tsx index 5494db8..73eea8c 100644 --- a/CleanArchitecture.Presentation/admin/src/app/not-found.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/not-found.tsx @@ -40,7 +40,7 @@ export default function NotFound() {
{/* */}

- © {new Date().getFullYear()} - TailAdmin + © {new Date().getFullYear()} - Clean Architecture

); diff --git a/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx b/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx index 164d4cc..1cead4a 100644 --- a/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx +++ b/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx @@ -1,23 +1,12 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; -import { - getGetApiV1BooksIdQueryKey, - getGetApiV1BooksQueryKey, - useGetApiV1BooksId, - usePostApiV1Books, - usePutApiV1BooksId, -} from "@/lib/api/books/books"; -import type { CreateBookCommand, UpdateBookCommand } from "@/lib/api/model"; -import { - extractApiErrors, - getFieldErrors, - type DomainError, -} from "@/lib/utils/error-handler"; +import { useGetApiV1BooksId } from "@/lib/api/books/books"; +import { extractApiErrors } from "@/lib/utils/error-handler"; +import { useBookMutation } from "@/hooks/useBookMutation"; import ComponentCard from "../common/ComponentCard"; import Label from "../form/Label"; import Input from "../form/input/InputField"; @@ -26,11 +15,6 @@ import { bookSchema, type BookFormValues } from "@/lib/validations/book"; import { genreOptions } from "@/lib/books/genre"; import { useLanguage } from "@/context/LanguageContext"; -const fieldErrorMap = { - Title: "title", - Genre: "genre", -} satisfies Record; - interface BookFormProps { id?: string; } @@ -39,8 +23,6 @@ export default function BookForm({ id }: BookFormProps) { const isEditMode = Boolean(id); const bookId = id ?? ""; const router = useRouter(); - const queryClient = useQueryClient(); - const [serverErrors, setServerErrors] = useState([]); const { t } = useLanguage(); const { @@ -58,6 +40,8 @@ export default function BookForm({ id }: BookFormProps) { }, }); + const { submit, isSaving, serverErrors } = useBookMutation({ id, setError }); + const { data: bookResponse, error: bookError, @@ -84,92 +68,15 @@ export default function BookForm({ id }: BookFormProps) { }); }, [bookResponse, isEditMode, reset]); - async function invalidateBookQueries(): Promise { - await queryClient.invalidateQueries({ - queryKey: getGetApiV1BooksQueryKey(), - }); - - if (isEditMode) { - await queryClient.invalidateQueries({ - queryKey: getGetApiV1BooksIdQueryKey(bookId), - }); - } - } - - function applyDomainErrors(domainErrors: DomainError[]): void { - const fieldErrors = getFieldErrors(domainErrors, fieldErrorMap); - - (Object.keys(fieldErrors) as Array).forEach((fieldName) => { - const message = fieldErrors[fieldName]; - if (message) { - setError(fieldName, { - type: "server", - message, - }); - } - }); - } - - function handleMutationError(error: unknown): void { - const domainErrors = extractApiErrors(error); - setServerErrors(domainErrors); - applyDomainErrors(domainErrors); - } - - const createMutation = usePostApiV1Books({ - mutation: { - onSuccess: async (response) => { - if (response.status === 201) { - await invalidateBookQueries(); - router.push("/book/list"); - } - }, - onError: handleMutationError, - }, - }); - - const updateMutation = usePutApiV1BooksId({ - mutation: { - onSuccess: async (response) => { - if (response.status === 204) { - await invalidateBookQueries(); - router.push("/book/list"); - } - }, - onError: handleMutationError, - }, - }); - - const isSaving = createMutation.isPending || updateMutation.isPending; const displayedServerErrors = bookError ? extractApiErrors(bookError) : serverErrors; - function onSubmit(formData: BookFormValues): void { - setServerErrors([]); - - if (isEditMode) { - const updateCommand: UpdateBookCommand = { - id: bookId, - title: formData.title, - genre: formData.genre, - }; - - updateMutation.mutate({ - id: bookId, - data: updateCommand, - }); - return; - } - - createMutation.mutate({ data: formData as CreateBookCommand }); - } - if (isFetchingBook) { return
{t("loading_book_data")}
; } return ( -
+ {displayedServerErrors.length > 0 ? (

{t("validation_errors")}

diff --git a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx index cff6140..485927d 100644 --- a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx +++ b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useEffect, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Locale, defaultLocale, isRtl, dictionaries } from "@/i18n"; type LanguageContextType = { @@ -13,24 +13,26 @@ type LanguageContextType = { const LanguageContext = createContext(undefined); export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [locale, setLocaleState] = useState(defaultLocale); + const [locale, setLocaleState] = useState(() => { + if (typeof window === "undefined") { + return defaultLocale; + } - useEffect(() => { - // Try to load locale from localStorage on mount const savedLocale = localStorage.getItem("locale") as Locale; - if (savedLocale && dictionaries[savedLocale]) { - setLocaleState(savedLocale); - } - }, []); + return savedLocale && dictionaries[savedLocale] ? savedLocale : defaultLocale; + }); - const setLocale = (newLocale: Locale) => { + const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); localStorage.setItem("locale", newLocale); - }; + }, []); - const t = (key: keyof typeof dictionaries["en"]) => { - return dictionaries[locale][key] || dictionaries[defaultLocale][key] || key; - }; + const t = useCallback( + (key: keyof typeof dictionaries["en"]) => { + return dictionaries[locale][key] || dictionaries[defaultLocale][key] || key; + }, + [locale], + ); const rtl = isRtl(locale); @@ -39,8 +41,13 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil document.documentElement.lang = locale; }, [locale, rtl]); + const value = useMemo( + () => ({ locale, setLocale, t, isRtl: rtl }), + [locale, setLocale, t, rtl], + ); + return ( - + {children} ); diff --git a/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx b/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx index c377119..83ca275 100644 --- a/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx +++ b/CleanArchitecture.Presentation/admin/src/context/SidebarContext.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useState, useEffect } from "react"; type SidebarContextType = { isExpanded: boolean; @@ -51,33 +51,36 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ }; }, []); - const toggleSidebar = () => { + const toggleSidebar = useCallback(() => { setIsExpanded((prev) => !prev); - }; + }, []); - const toggleMobileSidebar = () => { + const toggleMobileSidebar = useCallback(() => { setIsMobileOpen((prev) => !prev); - }; + }, []); - const toggleSubmenu = (item: string) => { + const toggleSubmenu = useCallback((item: string) => { setOpenSubmenu((prev) => (prev === item ? null : item)); - }; + }, []); + + const value = useMemo( + () => ({ + isExpanded: isMobile ? false : isExpanded, + isMobileOpen, + isHovered, + activeItem, + openSubmenu, + toggleSidebar, + toggleMobileSidebar, + setIsHovered, + setActiveItem, + toggleSubmenu, + }), + [isMobile, isExpanded, isMobileOpen, isHovered, activeItem, openSubmenu, toggleSidebar, toggleMobileSidebar, toggleSubmenu], + ); return ( - + {children} ); diff --git a/CleanArchitecture.Presentation/admin/src/hooks/useBookMutation.ts b/CleanArchitecture.Presentation/admin/src/hooks/useBookMutation.ts new file mode 100644 index 0000000..88c8c8a --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/hooks/useBookMutation.ts @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import type { UseFormSetError } from "react-hook-form"; +import { + getGetApiV1BooksIdQueryKey, + getGetApiV1BooksQueryKey, + usePostApiV1Books, + usePutApiV1BooksId, +} from "@/lib/api/books/books"; +import type { CreateBookCommand, UpdateBookCommand } from "@/lib/api/model"; +import { + extractApiErrors, + getFieldErrors, + type DomainError, +} from "@/lib/utils/error-handler"; +import type { BookFormValues } from "@/lib/validations/book"; + +const fieldErrorMap = { + Title: "title", + Genre: "genre", +} satisfies Record; + +interface UseBookMutationOptions { + id?: string; + setError: UseFormSetError; +} + +export function useBookMutation({ id, setError }: UseBookMutationOptions) { + const isEditMode = Boolean(id); + const bookId = id ?? ""; + const router = useRouter(); + const queryClient = useQueryClient(); + const [serverErrors, setServerErrors] = useState([]); + + async function invalidateBookQueries(): Promise { + await queryClient.invalidateQueries({ + queryKey: getGetApiV1BooksQueryKey(), + }); + + if (isEditMode) { + await queryClient.invalidateQueries({ + queryKey: getGetApiV1BooksIdQueryKey(bookId), + }); + } + } + + function applyDomainErrors(domainErrors: DomainError[]): void { + const fieldErrors = getFieldErrors(domainErrors, fieldErrorMap); + + (Object.keys(fieldErrors) as Array).forEach((fieldName) => { + const message = fieldErrors[fieldName]; + if (message) { + setError(fieldName, { + type: "server", + message, + }); + } + }); + } + + function handleMutationError(error: unknown): void { + const domainErrors = extractApiErrors(error); + setServerErrors(domainErrors); + applyDomainErrors(domainErrors); + } + + const createMutation = usePostApiV1Books({ + mutation: { + onSuccess: async (response) => { + if (response.status === 201) { + await invalidateBookQueries(); + router.push("/book/list"); + } + }, + onError: handleMutationError, + }, + }); + + const updateMutation = usePutApiV1BooksId({ + mutation: { + onSuccess: async (response) => { + if (response.status === 204) { + await invalidateBookQueries(); + router.push("/book/list"); + } + }, + onError: handleMutationError, + }, + }); + + const isSaving = createMutation.isPending || updateMutation.isPending; + + function submit(formData: BookFormValues): void { + setServerErrors([]); + + if (isEditMode) { + const updateCommand: UpdateBookCommand = { + id: bookId, + title: formData.title, + genre: formData.genre, + }; + + updateMutation.mutate({ + id: bookId, + data: updateCommand, + }); + return; + } + + createMutation.mutate({ data: formData as CreateBookCommand }); + } + + return { + submit, + isSaving, + serverErrors, + setServerErrors, + }; +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts b/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts index 87c628f..61eb706 100644 --- a/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts +++ b/CleanArchitecture.Presentation/admin/src/hooks/useGoBack.ts @@ -1,15 +1,16 @@ +import { useCallback } from "react"; import { useRouter } from "next/navigation"; const useGoBack = () => { const router = useRouter(); - const goBack = () => { + const goBack = useCallback(() => { if (window.history.length > 1) { - router.back(); // Navigate to the previous route + router.back(); } else { - router.push("/"); // Redirect to home if no history exists + router.push("/"); } - }; + }, [router]); return goBack; }; diff --git a/CleanArchitecture.Presentation/admin/src/lib/auth/auth-options.ts b/CleanArchitecture.Presentation/admin/src/lib/auth/auth-options.ts new file mode 100644 index 0000000..d248f4a --- /dev/null +++ b/CleanArchitecture.Presentation/admin/src/lib/auth/auth-options.ts @@ -0,0 +1,84 @@ +import type { NextAuthOptions } from "next-auth"; +import KeycloakProvider from "next-auth/providers/keycloak"; +import { getEnvVars } from "@/config/env-vars"; + +type JwtPayload = { + roles?: string[]; + realm_access?: { + roles?: string[]; + }; +}; + +function parseJwtPayload(token: string): JwtPayload | null { + try { + const payload = token.split(".")[1]; + + if (!payload) { + return null; + } + + return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as JwtPayload; + } catch { + return null; + } +} + +function extractRoles(accessToken: string): string[] { + const payload = parseJwtPayload(accessToken); + const roles = payload?.roles ?? payload?.realm_access?.roles ?? []; + + return Array.isArray(roles) ? roles : []; +} + +export function getAuthOptions(): NextAuthOptions { + const { KEYCLOAK_ISSUER, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, NEXTAUTH_SECRET } = getEnvVars(); + const keycloakIssuer = KEYCLOAK_ISSUER.replace(/\/+$/, ""); + + return { + providers: [ + KeycloakProvider({ + clientId: KEYCLOAK_CLIENT_ID, + clientSecret: KEYCLOAK_CLIENT_SECRET, + issuer: keycloakIssuer, + authorization: { + params: { + scope: "openid profile email permissions", + }, + }, + }), + ], + pages: { + signIn: "/signin", + }, + secret: NEXTAUTH_SECRET, + session: { + strategy: "jwt", + }, + callbacks: { + async jwt({ token, account }) { + if (account?.access_token) { + token.accessToken = account.access_token; + token.roles = extractRoles(account.access_token); + } + + if (account?.id_token) { + token.idToken = account.id_token; + } + + return token; + }, + async session({ session, token }) { + if (session.user && token.sub) { + session.user.id = token.sub; + session.user.roles = Array.isArray(token.roles) ? token.roles : []; + } + + if (typeof token.accessToken === "string") { + session.accessToken = token.accessToken; + } + + return session; + }, + }, + } satisfies NextAuthOptions; +} \ No newline at end of file diff --git a/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts b/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts index 2d5b37c..95b58fc 100644 --- a/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts +++ b/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts @@ -1,6 +1,6 @@ import { getServerSession } from "next-auth"; import { getSession, signIn } from "next-auth/react"; -import { getAuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAuthOptions } from "@/lib/auth/auth-options"; import { ApiError } from "@/lib/utils/api-error"; import { getEnvVars } from "@/config/env-vars"; From b3df6b3057f3ff74eb0837fc7b08a4f316f2e273 Mon Sep 17 00:00:00 2001 From: Mo Pazooki Date: Wed, 8 Apr 2026 06:59:25 +0100 Subject: [PATCH 2/4] feat: internationalize validation errors and error boundaries - Replaced hardcoded English validation messages in `bookSchema` with translation keys. - Updated `BookForm` and `book/error.tsx` to dynamically translate error messages using the `useLanguage` hook. - Added new translation keys for validation errors, demographics description, and generic error states across English, Arabic, and Persian locale files (`en.json`, `ar.json`, `fa.json`). - Applied a minor formatting adjustment to `AppSidebar.tsx`. --- .../admin/src/app/(admin)/book/error.tsx | 10 +++++++--- .../admin/src/components/book/BookForm.tsx | 4 ++-- .../admin/src/i18n/locales/ar.json | 10 +++++++++- .../admin/src/i18n/locales/en.json | 10 +++++++++- .../admin/src/i18n/locales/fa.json | 10 +++++++++- .../admin/src/layout/AppSidebar.tsx | 3 ++- .../admin/src/lib/validations/book.ts | 10 +++++----- 7 files changed, 43 insertions(+), 14 deletions(-) diff --git a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx index 0beb6f0..686ee1f 100644 --- a/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx +++ b/CleanArchitecture.Presentation/admin/src/app/(admin)/book/error.tsx @@ -1,22 +1,26 @@ "use client"; +import { useLanguage } from "@/context/LanguageContext"; + interface BookErrorProps { error: Error & { digest?: string }; reset: () => void; } export default function BookError({ error, reset }: BookErrorProps) { + const { t } = useLanguage(); + return (
-

Something went wrong

+

{t("something_went_wrong")}

{error.message}

); -} \ No newline at end of file +} diff --git a/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx b/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx index 1cead4a..2986563 100644 --- a/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx +++ b/CleanArchitecture.Presentation/admin/src/components/book/BookForm.tsx @@ -103,7 +103,7 @@ export default function BookForm({ id }: BookFormProps) { {...register("title")} /> {errors.title ? ( -

{errors.title.message}

+

{t(errors.title.message as any)}

) : null}
@@ -126,7 +126,7 @@ export default function BookForm({ id }: BookFormProps) { ))} {errors.genre ? ( -

{errors.genre.message}

+

{t(errors.genre.message as any)}

) : null}

{t("genre_help_text")} diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json index 2b1e82e..60ee83f 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json @@ -86,5 +86,13 @@ "postal_code": "الرمز البريدي", "tax_id": "الرقم الضريبي", "edit_address": "تعديل العنوان", - "united_kingdom": "المملكة المتحدة" + "united_kingdom": "المملكة المتحدة", + "customers_demographic_desc": "عدد العملاء حسب البلد", + "something_went_wrong": "حدث خطأ ما", + "try_again": "حاول مرة أخرى", + "title_required": "العنوان مطلوب.", + "title_min_length": "يجب أن يتكون العنوان من 3 أحرف على الأقل.", + "title_max_length": "لا يمكن أن يتجاوز العنوان 200 حرف.", + "genre_required": "النوع مطلوب.", + "genre_max_length": "لا يمكن أن يتجاوز النوع 50 حرفًا." } diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json index 58d5b53..856efdf 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json @@ -86,5 +86,13 @@ "postal_code": "Postal Code", "tax_id": "TAX ID", "edit_address": "Edit Address", - "united_kingdom": "United Kingdom" + "united_kingdom": "United Kingdom", + "customers_demographic_desc": "Number of customers based on country", + "something_went_wrong": "Something went wrong", + "try_again": "Try again", + "title_required": "Title is required.", + "title_min_length": "Title must be at least 3 characters long.", + "title_max_length": "Title cannot exceed 200 characters.", + "genre_required": "Genre is required.", + "genre_max_length": "Genre cannot exceed 50 characters." } diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json index 2d1f881..d5e5221 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json @@ -86,5 +86,13 @@ "postal_code": "کد پستی", "tax_id": "شناسه مالیاتی", "edit_address": "ویرایش آدرس", - "united_kingdom": "انگلستان" + "united_kingdom": "انگلستان", + "customers_demographic_desc": "تعداد مشتریان بر اساس کشور", + "something_went_wrong": "مشکلی پیش آمد", + "try_again": "دوباره تلاش کنید", + "title_required": "عنوان الزامی است.", + "title_min_length": "عنوان باید حداقل ۳ کاراکتر باشد.", + "title_max_length": "عنوان نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد.", + "genre_required": "ژانر الزامی است.", + "genre_max_length": "ژانر نمی‌تواند بیشتر از ۵۰ کاراکتر باشد." } diff --git a/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx b/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx index da6ebee..c8cdf68 100644 --- a/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx +++ b/CleanArchitecture.Presentation/admin/src/layout/AppSidebar.tsx @@ -1,4 +1,5 @@ "use client"; + import React, {useEffect, useRef, useState, useMemo, useCallback} from "react"; import Link from "next/link"; import Image from "next/image"; @@ -48,7 +49,7 @@ const AppSidebar: React.FC = () => { {} ); const subMenuRefs = useRef>({}); - + const isActive = useCallback((path: string) => path === pathname, [pathname]); type OpenSubmenu = { diff --git a/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts b/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts index d216a1a..fae2079 100644 --- a/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts +++ b/CleanArchitecture.Presentation/admin/src/lib/validations/book.ts @@ -3,13 +3,13 @@ import { z } from "zod"; export const bookSchema = z.object({ title: z .string() - .min(1, { message: "Title is required." }) - .min(3, { message: "Title must be at least 3 characters long." }) - .max(200, { message: "Title cannot exceed 200 characters." }), + .min(1, { message: "title_required" }) + .min(3, { message: "title_min_length" }) + .max(200, { message: "title_max_length" }), genre: z .string() - .min(1, { message: "Genre is required." }) - .max(50, { message: "Genre cannot exceed 50 characters." }), + .min(1, { message: "genre_required" }) + .max(50, { message: "genre_max_length" }), }); export type BookFormValues = z.infer; From 4253182477c3eb6b474c7dfb249a6b5be5bca4f3 Mon Sep 17 00:00:00 2001 From: Mo Pazooki Date: Wed, 8 Apr 2026 08:57:15 +0100 Subject: [PATCH 3/4] feat: add i18n support to NotificationDropdown and fix LanguageContext hydration - Integrated the `useLanguage` hook into `NotificationDropdown` to replace hardcoded text with localized string keys. - Added corresponding translation keys (`notification`, `requests_permission_to_change`, `project`, `min_ago`, `hr_ago`, `view_all_notifications`, etc.) to English, Arabic, and Persian locale files. - Resolved a Next.js SSR hydration mismatch in `LanguageContext` by initializing state with the default locale and retrieving the saved locale from `localStorage` within a `useEffect` hook. --- .../header/NotificationDropdown.tsx | 72 ++++++++++--------- .../admin/src/context/LanguageContext.tsx | 12 ++-- .../admin/src/i18n/locales/ar.json | 9 ++- .../admin/src/i18n/locales/en.json | 9 ++- .../admin/src/i18n/locales/fa.json | 9 ++- 5 files changed, 67 insertions(+), 44 deletions(-) diff --git a/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx b/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx index 2485f43..aed179e 100644 --- a/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx +++ b/CleanArchitecture.Presentation/admin/src/components/header/NotificationDropdown.tsx @@ -4,10 +4,12 @@ import Link from "next/link"; import React, { useState } from "react"; import { Dropdown } from "../ui/dropdown/Dropdown"; import { DropdownItem } from "../ui/dropdown/DropdownItem"; +import { useLanguage } from "@/context/LanguageContext"; export default function NotificationDropdown() { const [isOpen, setIsOpen] = useState(false); const [notifying, setNotifying] = useState(true); + const { t } = useLanguage(); function toggleDropdown() { setIsOpen(!isOpen); @@ -52,11 +54,11 @@ export default function NotificationDropdown() {

- Notification + {t("notification") as React.ReactNode}
diff --git a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx index 485927d..401e567 100644 --- a/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx +++ b/CleanArchitecture.Presentation/admin/src/context/LanguageContext.tsx @@ -13,14 +13,14 @@ type LanguageContextType = { const LanguageContext = createContext(undefined); export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [locale, setLocaleState] = useState(() => { - if (typeof window === "undefined") { - return defaultLocale; - } + const [locale, setLocaleState] = useState(defaultLocale); + useEffect(() => { const savedLocale = localStorage.getItem("locale") as Locale; - return savedLocale && dictionaries[savedLocale] ? savedLocale : defaultLocale; - }); + if (savedLocale && dictionaries[savedLocale]) { + setLocaleState(savedLocale); + } + }, []); const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json index 60ee83f..e8b5321 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/ar.json @@ -94,5 +94,12 @@ "title_min_length": "يجب أن يتكون العنوان من 3 أحرف على الأقل.", "title_max_length": "لا يمكن أن يتجاوز العنوان 200 حرف.", "genre_required": "النوع مطلوب.", - "genre_max_length": "لا يمكن أن يتجاوز النوع 50 حرفًا." + "genre_max_length": "لا يمكن أن يتجاوز النوع 50 حرفًا.", + "notification": "إشعار", + "requests_permission_to_change": "يطلب إذنًا للتغيير", + "project_nganter_app": "مشروع - Nganter App", + "project": "مشروع", + "min_ago": "منذ دقيقة", + "hr_ago": "منذ ساعة", + "view_all_notifications": "عرض كل الإشعارات" } diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json index 856efdf..73a1363 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/en.json @@ -94,5 +94,12 @@ "title_min_length": "Title must be at least 3 characters long.", "title_max_length": "Title cannot exceed 200 characters.", "genre_required": "Genre is required.", - "genre_max_length": "Genre cannot exceed 50 characters." + "genre_max_length": "Genre cannot exceed 50 characters.", + "notification": "Notification", + "requests_permission_to_change": "requests permission to change", + "project_nganter_app": "Project - Nganter App", + "project": "Project", + "min_ago": "min ago", + "hr_ago": "hr ago", + "view_all_notifications": "View All Notifications" } diff --git a/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json b/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json index d5e5221..be96897 100644 --- a/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json +++ b/CleanArchitecture.Presentation/admin/src/i18n/locales/fa.json @@ -94,5 +94,12 @@ "title_min_length": "عنوان باید حداقل ۳ کاراکتر باشد.", "title_max_length": "عنوان نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد.", "genre_required": "ژانر الزامی است.", - "genre_max_length": "ژانر نمی‌تواند بیشتر از ۵۰ کاراکتر باشد." + "genre_max_length": "ژانر نمی‌تواند بیشتر از ۵۰ کاراکتر باشد.", + "notification": "اعلان", + "requests_permission_to_change": "درخواست اجازه برای تغییر", + "project_nganter_app": "پروژه - Nganter App", + "project": "پروژه", + "min_ago": "دقیقه پیش", + "hr_ago": "ساعت پیش", + "view_all_notifications": "مشاهده همه اعلان‌ها" } From 6ad789d02a296023a9583c4b35f10ee1706b3998 Mon Sep 17 00:00:00 2001 From: Mo Pazooki Date: Thu, 9 Apr 2026 08:26:42 +0100 Subject: [PATCH 4/4] Here is a suggested commit message summarizing the provided diffs: Introduce multi-language localization and refine error handling - Added Persian (`fa`) and Arabic (`ar`) resource files for `BookErrors` in the Domain layer. - Configured request localization middleware in the API project (`Program.cs`) to support English, Persian, and Arabic cultures. - Converted static readonly fields in `BookErrors.cs` to expression-bodied properties to allow dynamic evaluation of localized resources per request. - Updated the frontend fetch utility (`orval-fetch.ts`) to automatically inject the `Accept-Language` header based on the user's active locale. - Enhanced the frontend error handler (`error-handler.ts`) to attempt JSON parsing for `ProblemDetails` when API errors are returned as raw strings. --- .../Resources/BookErrors.ar.resx | 64 +++++++++++++++++++ .../Resources/BookErrors.fa.resx | 64 +++++++++++++++++++ .../Validations/Book/BookErrors.cs | 14 ++-- CleanArchitecture.Presentation/API/Program.cs | 13 +++- .../admin/src/lib/orval-fetch.ts | 7 ++ .../admin/src/lib/utils/error-handler.ts | 8 +++ 6 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 CleanArchitecture.Domain/Resources/BookErrors.ar.resx create mode 100644 CleanArchitecture.Domain/Resources/BookErrors.fa.resx diff --git a/CleanArchitecture.Domain/Resources/BookErrors.ar.resx b/CleanArchitecture.Domain/Resources/BookErrors.ar.resx new file mode 100644 index 0000000..2206ed1 --- /dev/null +++ b/CleanArchitecture.Domain/Resources/BookErrors.ar.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + العنوان مطلوب ولا يمكن أن يكون فارغًا. + + + لا يمكن أن يتجاوز العنوان 200 حرف. + + + النوع المحدد غير مدعوم. + + + لم يتم العثور على الكتاب بالمعرف المحدد. + + + يجب أن يتكون العنوان من 3 أحرف على الأقل. + + + النوع مطلوب ولا يمكن أن يكون فارغًا. + + + يجب أن يكون طول النوع بين 1 و 2 حرف. + + diff --git a/CleanArchitecture.Domain/Resources/BookErrors.fa.resx b/CleanArchitecture.Domain/Resources/BookErrors.fa.resx new file mode 100644 index 0000000..e14f4d1 --- /dev/null +++ b/CleanArchitecture.Domain/Resources/BookErrors.fa.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + عنوان الزامی است و نمی‌تواند خالی باشد. + + + عنوان نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد. + + + ژانر مشخص شده پشتیبانی نمی‌شود. + + + کتاب با شناسه مشخص شده یافت نشد. + + + عنوان باید حداقل ۳ کاراکتر باشد. + + + ژانر الزامی است و نمی‌تواند خالی باشد. + + + طول ژانر باید بین ۱ تا ۲ کاراکتر باشد. + + diff --git a/CleanArchitecture.Domain/Validations/Book/BookErrors.cs b/CleanArchitecture.Domain/Validations/Book/BookErrors.cs index 313e474..2f654df 100644 --- a/CleanArchitecture.Domain/Validations/Book/BookErrors.cs +++ b/CleanArchitecture.Domain/Validations/Book/BookErrors.cs @@ -7,31 +7,31 @@ namespace CleanArchitecture.Domain.Validations.Book; /// public static class BookErrors { - public static readonly DomainError TitleIsRequired = DomainError.Validation( + public static DomainError TitleIsRequired => DomainError.Validation( "Book.TitleIsRequired", Resources.BookErrors.Book_TitleIsRequired); - public static readonly DomainError TitleTooLong = DomainError.Validation( + public static DomainError TitleTooLong => DomainError.Validation( "Book.TitleTooLong", Resources.BookErrors.Book_TitleTooLong); - public static readonly DomainError InvalidGenre = DomainError.Validation( + public static DomainError InvalidGenre => DomainError.Validation( "Book.InvalidGenre", Resources.BookErrors.Book_InvalidGenre); - public static readonly DomainError BookNotFound = DomainError.NotFound( + public static DomainError BookNotFound => DomainError.NotFound( "Book.NotFound", Resources.BookErrors.Book_NotFound); - public static readonly DomainError TitleIsTooShort = DomainError.Validation( + public static DomainError TitleIsTooShort => DomainError.Validation( "Book.TitleIsTooShort", Resources.BookErrors.Book_TitleIsTooShort); - public static readonly DomainError GenreIsRequired = DomainError.Validation( + public static DomainError GenreIsRequired => DomainError.Validation( "Book.GenreIsRequired", Resources.BookErrors.Book_GenreIsRequired); - public static readonly DomainError GenreIsInvalidLength = DomainError.Validation( + public static DomainError GenreIsInvalidLength => DomainError.Validation( "Book.GenreIsInvalidLength", Resources.BookErrors.Book_GenreIsInvalidLength); } diff --git a/CleanArchitecture.Presentation/API/Program.cs b/CleanArchitecture.Presentation/API/Program.cs index e3994a6..be81a70 100644 --- a/CleanArchitecture.Presentation/API/Program.cs +++ b/CleanArchitecture.Presentation/API/Program.cs @@ -13,6 +13,8 @@ .AddInfrastructurePersistenceServices(builder.Configuration) .AddPresentationServices(builder); +builder.Services.AddLocalization(); + builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; @@ -36,6 +38,15 @@ app.UseResponseCompression(); +string[] supportedCultures = ["en", "fa", "ar"]; + +app.UseRequestLocalization(options => +{ + options.SetDefaultCulture(supportedCultures[0]) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures); +}); + app.UseAuthentication(); app.UseAuthorization(); @@ -57,4 +68,4 @@ versionedApi.MapBookEndpoints(); -await app.RunAsync().ConfigureAwait(false); \ No newline at end of file +await app.RunAsync().ConfigureAwait(false); diff --git a/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts b/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts index 95b58fc..328c5ee 100644 --- a/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts +++ b/CleanArchitecture.Presentation/admin/src/lib/orval-fetch.ts @@ -84,6 +84,13 @@ export const orvalFetch = async (url: string, options: RequestInit = {}): Pro headers.set("Authorization", `Bearer ${accessToken}`); } + if (typeof window !== "undefined") { + const locale = localStorage.getItem("locale") || document.documentElement.lang; + if (locale) { + headers.set("Accept-Language", locale); + } + } + const response = await fetch(resolveRequestUrl(url), { ...options, headers, diff --git a/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts b/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts index 8abe920..c5edab2 100644 --- a/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts +++ b/CleanArchitecture.Presentation/admin/src/lib/utils/error-handler.ts @@ -80,6 +80,14 @@ export function extractApiErrors(error: unknown): DomainError[] { } if (typeof error.data === "string" && error.data.length > 0) { + try { + const parsedData = JSON.parse(error.data); + if (isProblemDetails(parsedData)) { + return extractErrors(parsedData); + } + } catch (e) { + // Not a JSON string, fall through to return the raw string. + } return [ { code: "RequestError",