another step constructing base project
This commit is contained in:
+122
-1
@@ -39,11 +39,27 @@ client/
|
||||
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
|
||||
│ └── index.tsx
|
||||
├── lib/
|
||||
│ ├── api/
|
||||
│ │ ├── client.ts # clientFetch<T> — throws ApiError on error; use in hooks/client components
|
||||
│ │ ├── server.ts # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
|
||||
│ │ └── errors.ts # ApiError class (status, message, code)
|
||||
│ ├── query/
|
||||
│ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton
|
||||
│ │ └── QueryProvider.tsx # 'use client' — QueryClientProvider + ReactQueryDevtools
|
||||
│ └── cookies/ # Cookie manager — strict server/client separation
|
||||
│ ├── constants.ts # COOKIE_NAMES, CookieOptions, COLOR_SCHEME_COOKIE_OPTIONS
|
||||
│ ├── constants.ts # COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS
|
||||
│ ├── server.ts # getServerCookie, getThemeMode, setServerCookie
|
||||
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
|
||||
│ └── index.ts # Re-exports constants ONLY (never server/client)
|
||||
├── services/ # Domain services — no top-level barrel; import directly from the file
|
||||
│ └── {domain}/
|
||||
│ ├── types.ts # Request/response types for this domain
|
||||
│ ├── keys.ts # React Query key factory
|
||||
│ ├── apis/
|
||||
│ │ ├── clientApi.ts # Namespace object wrapping clientFetch calls
|
||||
│ │ └── serverApi.ts # Namespace object wrapping serverFetch calls (only when needed)
|
||||
│ └── hooks/
|
||||
│ └── use{Action}.ts # One hook per file — useQuery or useMutation
|
||||
├── store/ # AppStore (Redux-like client state)
|
||||
├── theme/
|
||||
│ ├── ColorSchemeScript.tsx # Inline <script> in <head> — sets attr + patches Storage
|
||||
@@ -284,4 +300,109 @@ Enforcement: before removing or renaming a shared component, check whether `src/
|
||||
- **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** call `fetch()` directly in components or services — use `serverFetch` (RSC/Server Actions) or `clientFetch` (hooks/Client Components) from `@/lib/api`.
|
||||
- **Do not** create a top-level barrel at `src/services/index.ts` — imports should make the domain origin clear (e.g. `import { useLogin } from '@/services/auth'`, not `import { useLogin } from '@/services'`).
|
||||
- Each domain **does** have an `index.ts` that re-exports its hooks (e.g. `src/services/auth/index.ts`). Do not export `types`, `keys`, or `apis/*` from this barrel — only hooks.
|
||||
- **Do not** mix `clientFetch` and `serverFetch` in the same file — keep `clientApi.ts` and `serverApi.ts` separate; Next.js enforces the environment boundary at build time.
|
||||
- **Do not** toast inside hooks for 401/403/5xx — those are already toasted by `clientFetch`. Only toast in `onError` for domain-specific 4xx messages.
|
||||
- **Do not** call `js-cookie` (`Cookies.*`) directly — use the central client cookie manager (`@/lib/cookies/client`).
|
||||
- **Do not** read or write `document.cookie` directly — use the central client cookie manager.
|
||||
- **Do not** store auth tokens in `sessionStorage` or `localStorage` — use cookies via `@/lib/cookies/client`.
|
||||
|
||||
---
|
||||
|
||||
## API Fetch Services
|
||||
|
||||
Central fetch primitives live in `src/lib/api/`:
|
||||
|
||||
| File | Use from | Purpose |
|
||||
|------|----------|---------|
|
||||
| `client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error |
|
||||
| `server.ts` | RSCs, Server Actions only | `serverFetch<T>` — throws `ApiError` on error |
|
||||
| `errors.ts` | anywhere | `ApiError` class (`status`, `message`, `code`) |
|
||||
|
||||
**Error contract — `clientFetch`:**
|
||||
- **401** — toast "session expired", clear cookies, redirect to login (no throw; page navigates away)
|
||||
- **403** — toast "forbidden", throw `ApiError`
|
||||
- **5xx** — toast "server error", throw `ApiError`
|
||||
- **Other 4xx** — throw `ApiError`, no toast; the calling hook owns the user-facing message
|
||||
- **Network failure** — toast "network error", throw `ApiError`
|
||||
|
||||
**Error contract — `serverFetch`:**
|
||||
- All errors throw `ApiError` (no toast — server can't fire browser events)
|
||||
- RSC callers decide whether to `notFound()`, `redirect()`, or let the error propagate to an error boundary
|
||||
|
||||
**Domain API calls** live in `src/services/{domain}/apis/clientApi.ts` (or `serverApi.ts`). Never call raw `fetch()` directly.
|
||||
|
||||
---
|
||||
|
||||
## Auth Cookies
|
||||
|
||||
| Cookie | Constant | TTL | Set by |
|
||||
|--------|----------|-----|--------|
|
||||
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `loginUser()` in `src/services/auth.ts` |
|
||||
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | `loginUser()` in `src/services/auth.ts` |
|
||||
|
||||
Both are regular (non-httpOnly) cookies so they are readable by both server and client.
|
||||
|
||||
**Lifecycle:**
|
||||
- Written after a successful login via `setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS` / `AUTH_REFRESH_COOKIE_OPTIONS`
|
||||
- Deleted by `logoutUser()` and automatically by `clientFetch` on 401
|
||||
- Read by `serverFetch` via `getServerCookie(COOKIE_NAMES.ACCESS_TOKEN)`
|
||||
- Read by `clientFetch` via `getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)`
|
||||
|
||||
**Middleware:** validates the `exp` claim of the access token locally (base64-decode the JWT payload, no signature check) before rendering any private page. An absent or expired token redirects to `/{locale}/login`.
|
||||
|
||||
**`useIsAuthenticated()` hook:** SSR-safe — initialises `false`, sets the real value in `useEffect` by reading the cookie. This prevents hydration mismatches.
|
||||
|
||||
---
|
||||
|
||||
## Toast Notifications (notistack)
|
||||
|
||||
`<SnackbarProvider>` wraps all children inside `ThemeProvider` in `src/app/[locale]/layout.tsx`.
|
||||
|
||||
**In React components/hooks** — use notistack directly:
|
||||
```tsx
|
||||
import { useSnackbar } from 'notistack'
|
||||
const { enqueueSnackbar } = useSnackbar()
|
||||
enqueueSnackbar('Saved!', { variant: 'success' })
|
||||
```
|
||||
|
||||
**Outside React** (plain functions, fetch services) — use the event bridge:
|
||||
```ts
|
||||
import { dispatchToast } from '@/lib/toast'
|
||||
dispatchToast('Something went wrong', 'error')
|
||||
```
|
||||
`dispatchToast` fires a `window` CustomEvent (`app:toast`). `ToastBridge` (a zero-UI `'use client'` component inside `SnackbarProvider`) listens and calls `enqueueSnackbar`.
|
||||
|
||||
`ToastBridge` is already rendered in `[locale]/layout.tsx` — do not add another instance.
|
||||
|
||||
---
|
||||
|
||||
## Route Constants
|
||||
|
||||
Named path constants live in `src/constants/routes.ts`:
|
||||
```ts
|
||||
ROUTES.LOGIN = '/login'
|
||||
ROUTES.HOME = '/'
|
||||
PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check
|
||||
```
|
||||
Import from the barrel: `import { ROUTES, PUBLIC_PATHS } from '@/constants'`.
|
||||
|
||||
To add a new public route, append it to `PUBLIC_PATHS` — the middleware picks it up automatically.
|
||||
|
||||
---
|
||||
|
||||
## Client Cookie Manager (js-cookie)
|
||||
|
||||
`src/lib/cookies/client.ts` uses `js-cookie` internally. The exported API is unchanged:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `getClientCookie(name)` | Read a cookie by name |
|
||||
| `setClientCookie(name, value, options?)` | Write a cookie; `options` is `CookieOptions` with `maxAge` in **seconds** |
|
||||
| `deleteClientCookie(name, path?)` | Delete a cookie |
|
||||
| `getColorSchemeCookie()` | Typed helper for the theme cookie |
|
||||
|
||||
`CookieOptions` type is defined in `src/lib/cookies/constants.ts` — `maxAge` is in seconds (converted to `expires: Date` internally when calling js-cookie).
|
||||
- **Do not** `document.title = title` in the render body of any component — it causes `ReferenceError: document is not defined` during build-time prerendering.
|
||||
|
||||
+40
-1
@@ -1,7 +1,46 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { routing } from './src/i18n/routing';
|
||||
import { COOKIE_NAMES } from './src/lib/cookies';
|
||||
import { PUBLIC_PATHS, ROUTES } from './src/constants';
|
||||
|
||||
export default createMiddleware(routing);
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
function isTokenAlive(token?: string): boolean {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const i18nResponse = intlMiddleware(request);
|
||||
|
||||
// If next-intl is issuing a locale normalization redirect, let it through immediately
|
||||
if (i18nResponse.status === 307 || i18nResponse.status === 308) {
|
||||
return i18nResponse;
|
||||
}
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Strip the locale segment to get the actual path (e.g. /fa/login → /login)
|
||||
const pathWithoutLocale = '/' + pathname.split('/').slice(2).join('/');
|
||||
|
||||
const isPublic = PUBLIC_PATHS.some((p) => pathWithoutLocale.startsWith(p));
|
||||
|
||||
if (!isPublic) {
|
||||
const token = request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value;
|
||||
if (!isTokenAlive(token)) {
|
||||
const locale = request.cookies.get('NEXT_LOCALE')?.value ?? routing.defaultLocale;
|
||||
return NextResponse.redirect(new URL(`/${locale}${ROUTES.LOGIN}`, request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return i18nResponse;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Match all pathnames except internal Next.js paths, API routes, and static files
|
||||
|
||||
Generated
+432
-319
File diff suppressed because it is too large
Load Diff
@@ -21,11 +21,15 @@
|
||||
"@mui/icons-material": "^9.1.1",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@mui/material-nextjs": "^9.1.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"clsx": "latest",
|
||||
"copy-to-clipboard": "latest",
|
||||
"js-cookie": "^3.0.8",
|
||||
"next": "^16.2.9",
|
||||
"next-intl": "^4.13.0",
|
||||
"notistack": "^3.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"stylis-plugin-rtl": "^2.1.1"
|
||||
@@ -35,6 +39,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { setRequestLocale, getMessages } from 'next-intl/server';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { getThemeMode } from '@/lib/cookies/server';
|
||||
import { AppStoreProvider } from '@/store';
|
||||
import { ThemeProvider, getDirection } from '@/theme';
|
||||
import { ToastBridge } from '@/lib/toast';
|
||||
import { QueryProvider } from '@/lib/query/QueryProvider';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
/*
|
||||
@@ -42,7 +45,12 @@ export default async function LocaleLayout({
|
||||
<NextIntlClientProvider locale={safeLocale} messages={messages}>
|
||||
<AppStoreProvider>
|
||||
<ThemeProvider dir={dir} defaultMode={defaultMode}>
|
||||
{children}
|
||||
<QueryProvider>
|
||||
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<ToastBridge />
|
||||
{children}
|
||||
</SnackbarProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</AppStoreProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -7,9 +7,12 @@ export const IS_PRODUCTION = getCurrentEnvironment() === 'production'; // Enable
|
||||
// export const PUBLIC_URL = envRequired(process.env.NEXT_PUBLIC_PUBLIC_URL); // Variant 1: .env variable is required
|
||||
export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env variable is optional
|
||||
|
||||
export const API_URL = envRequired(process.env.NEXT_PUBLIC_API_URL);
|
||||
|
||||
IS_DEBUG &&
|
||||
console.log('@/config', {
|
||||
IS_DEBUG,
|
||||
IS_PRODUCTION,
|
||||
PUBLIC_URL,
|
||||
API_URL,
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './routes';
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ROUTES = {
|
||||
LOGIN: '/login',
|
||||
HOME: '/',
|
||||
} as const;
|
||||
|
||||
/** Paths (without locale prefix) that bypass auth in middleware. */
|
||||
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
|
||||
+22
-15
@@ -1,32 +1,39 @@
|
||||
import { useCallback } from 'react';
|
||||
import { sessionStorageGet, sessionStorageDelete } from '@/utils/sessionStorage';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { deleteClientCookie, getClientCookie } from '@/lib/cookies/client';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
/**
|
||||
* Hook to detect is current user authenticated or not
|
||||
* @returns {boolean} true if user is authenticated, false otherwise
|
||||
* Returns true when an access_token cookie is present on the client.
|
||||
* Initialises as false (SSR-safe) and updates after mount to avoid hydration mismatches.
|
||||
*/
|
||||
export function useIsAuthenticated() {
|
||||
const [state] = useAppStore();
|
||||
let result = state.isAuthenticated;
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// TODO: AUTH: replace next line with access token verification
|
||||
result = Boolean(sessionStorageGet('access_token', ''));
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(Boolean(getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)));
|
||||
}, []);
|
||||
|
||||
return result;
|
||||
return isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns event handler to Logout current user
|
||||
* @returns {function} calling this event logs out current user
|
||||
* Returns a handler that logs the current user out:
|
||||
* clears auth cookies, resets AppStore, and redirects to the login page.
|
||||
*/
|
||||
export function useEventLogout() {
|
||||
const [, dispatch] = useAppStore();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
return useCallback(() => {
|
||||
// TODO: AUTH: replace next line with access token saving
|
||||
sessionStorageDelete('access_token');
|
||||
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
dispatch({ type: 'LOG_OUT' });
|
||||
}, [dispatch]);
|
||||
router.replace(`/${locale}${ROUTES.LOGIN}`);
|
||||
}, [dispatch, router, locale]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { FunctionComponent, PropsWithChildren, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { getClientCookie } from '@/lib/cookies/client';
|
||||
import { useAppStore } from '@/store';
|
||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||
|
||||
/**
|
||||
@@ -10,6 +13,13 @@ import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||
*/
|
||||
const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const t = useTranslations('nav');
|
||||
const [, dispatch] = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)) {
|
||||
dispatch({ type: 'LOG_IN' });
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const sidebarItems: Array<LinkToPage> = [
|
||||
{ title: t('home'), path: '/', icon: 'home' },
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Client-only fetch service.
|
||||
* Import as: import { clientFetch } from '@/lib/api/client'
|
||||
*
|
||||
* Use this in Client Components and service hooks — never in Server Components or Server Actions.
|
||||
*
|
||||
* Error behaviour:
|
||||
* - 401: clears auth cookies, toasts "session expired", redirects to login (no throw)
|
||||
* - 403: toasts "forbidden", throws ApiError
|
||||
* - 5xx: toasts "server error", throws ApiError
|
||||
* - Other 4xx: throws ApiError without toasting — the calling hook owns the message
|
||||
* - Network failure: toasts "network error", throws ApiError
|
||||
*/
|
||||
import { API_URL } from '@/config';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { deleteClientCookie, getClientCookie } from '@/lib/cookies/client';
|
||||
import { dispatchToast } from '@/lib/toast/dispatchToast';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from './errors';
|
||||
|
||||
async function parseBody(response: Response): Promise<{ message?: string; code?: string }> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function clientFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = getClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
const locale = window.location.pathname.split('/')[1] || 'fa';
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': locale,
|
||||
};
|
||||
if (token) {
|
||||
reqHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers: { ...reqHeaders, ...(options?.headers as Record<string, string>) },
|
||||
});
|
||||
} catch {
|
||||
dispatchToast('Network error. Please check your connection.', 'error');
|
||||
throw new ApiError(0, 'Network error');
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const body = await parseBody(response);
|
||||
const message = body.message ?? response.statusText;
|
||||
const { code } = body;
|
||||
|
||||
if (response.status === 401) {
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
dispatchToast('Session expired. Please log in again.', 'error');
|
||||
window.location.replace(`/${locale}${ROUTES.LOGIN}`);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
dispatchToast('You do not have permission to perform this action.', 'error');
|
||||
throw new ApiError(403, message, code);
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
dispatchToast('Server error. Please try again later.', 'error');
|
||||
throw new ApiError(response.status, message, code);
|
||||
}
|
||||
|
||||
throw new ApiError(response.status, message, code);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Server-only fetch service.
|
||||
* Import as: import { serverFetch } from '@/lib/api/server'
|
||||
*
|
||||
* Use this in Server Components and Server Actions — never in client components.
|
||||
* All errors throw ApiError; callers decide whether to notFound(), redirect(), or surface an error boundary.
|
||||
*/
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { API_URL } from '@/config';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { getServerCookie } from '@/lib/cookies/server';
|
||||
import { ApiError } from './errors';
|
||||
|
||||
async function parseBody(response: Response): Promise<{ message?: string; code?: string }> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function serverFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = await getServerCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
|
||||
let locale = 'fa';
|
||||
try {
|
||||
const reqHeaders = await headers();
|
||||
locale = reqHeaders.get('x-next-intl-locale') ?? 'fa';
|
||||
} catch {
|
||||
// Outside request context (build-time prerendering) — use default locale
|
||||
}
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': locale,
|
||||
};
|
||||
if (token) {
|
||||
reqHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_URL}${path}`, {
|
||||
cache: 'no-store',
|
||||
...options,
|
||||
headers: { ...reqHeaders, ...(options?.headers as Record<string, string>) },
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, 'Network error');
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const body = await parseBody(response);
|
||||
throw new ApiError(response.status, body.message ?? response.statusText, body.code);
|
||||
}
|
||||
@@ -2,33 +2,36 @@
|
||||
* 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.
|
||||
* Uses js-cookie under the hood — never call js-cookie (Cookies.*) or
|
||||
* document.cookie directly; always go through these functions.
|
||||
*
|
||||
* All functions are client-only: they guard against server-side execution and
|
||||
* must only be called inside client components or useEffect hooks.
|
||||
*/
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
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;
|
||||
function toJsCookieAttributes(options: CookieOptions): Cookies.CookieAttributes {
|
||||
const attrs: Cookies.CookieAttributes = {};
|
||||
if (options.path !== undefined) attrs.path = options.path;
|
||||
if (options.maxAge !== undefined) attrs.expires = new Date(Date.now() + options.maxAge * 1000);
|
||||
if (options.sameSite !== undefined) attrs.sameSite = options.sameSite;
|
||||
if (options.secure !== undefined) attrs.secure = options.secure;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cookie by name from document.cookie.
|
||||
* Read a cookie by name.
|
||||
* 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;
|
||||
return Cookies.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a cookie to document.cookie.
|
||||
* Write a cookie.
|
||||
* Defaults to the standard 1-year / SameSite=Lax options.
|
||||
*/
|
||||
export function setClientCookie(
|
||||
@@ -37,15 +40,15 @@ export function setClientCookie(
|
||||
options: CookieOptions = COLOR_SCHEME_COOKIE_OPTIONS,
|
||||
): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = buildCookieString(name, value, options);
|
||||
Cookies.set(name, value, toJsCookieAttributes(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cookie by setting max-age=0.
|
||||
* Delete a cookie.
|
||||
*/
|
||||
export function deleteClientCookie(name: string, path = '/'): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = buildCookieString(name, '', { path, maxAge: 0 });
|
||||
Cookies.remove(name, { path });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const COOKIE_NAMES = {
|
||||
COLOR_SCHEME: 'color-scheme',
|
||||
ACCESS_TOKEN: 'access_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
} as const;
|
||||
|
||||
export type CookieName = (typeof COOKIE_NAMES)[keyof typeof COOKIE_NAMES];
|
||||
@@ -16,3 +18,17 @@ export const COLOR_SCHEME_COOKIE_OPTIONS: CookieOptions = {
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
};
|
||||
|
||||
export const AUTH_ACCESS_COOKIE_OPTIONS: CookieOptions = {
|
||||
path: '/',
|
||||
maxAge: 900, // 15 minutes
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
};
|
||||
|
||||
export const AUTH_REFRESH_COOKIE_OPTIONS: CookieOptions = {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { getQueryClient } from './queryClient';
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(() => getQueryClient());
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { QueryClient, isServer } from '@tanstack/react-query';
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined;
|
||||
|
||||
export function getQueryClient() {
|
||||
if (isServer) {
|
||||
return makeQueryClient();
|
||||
}
|
||||
if (!browserQueryClient) {
|
||||
browserQueryClient = makeQueryClient();
|
||||
}
|
||||
return browserQueryClient;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
import type { ToastSeverity } from './dispatchToast';
|
||||
|
||||
interface ToastEventDetail {
|
||||
message: string;
|
||||
severity: ToastSeverity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-UI component that bridges window 'app:toast' events to notistack.
|
||||
* Must be rendered inside <SnackbarProvider>.
|
||||
* This lets non-React code (e.g. clientFetch) fire toasts via dispatchToast().
|
||||
*/
|
||||
export function ToastBridge() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
useEffect(() => {
|
||||
function handler(event: Event) {
|
||||
const { message, severity } = (event as CustomEvent<ToastEventDetail>).detail;
|
||||
enqueueSnackbar(message, { variant: severity });
|
||||
}
|
||||
window.addEventListener('app:toast', handler);
|
||||
return () => window.removeEventListener('app:toast', handler);
|
||||
}, [enqueueSnackbar]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type ToastSeverity = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
/**
|
||||
* Fire a toast notification from anywhere — including non-React code like fetch services.
|
||||
* The ToastBridge component (inside SnackbarProvider) picks up this event and calls
|
||||
* notistack's enqueueSnackbar.
|
||||
*/
|
||||
export function dispatchToast(message: string, severity: ToastSeverity): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message, severity } }));
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToastBridge } from './ToastBridge';
|
||||
export { dispatchToast, type ToastSeverity } from './dispatchToast';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import type { AuthTokens, LoginDto, User } from '../types';
|
||||
|
||||
export const AuthClientApi = {
|
||||
login: (dto: LoginDto) =>
|
||||
clientFetch<AuthTokens>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
}),
|
||||
|
||||
logout: () =>
|
||||
clientFetch<void>('/auth/logout', { method: 'POST' }),
|
||||
|
||||
getCurrentUser: () =>
|
||||
clientFetch<User>('/auth/me'),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
import { authKeys } from '../keys';
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: authKeys.currentUser(),
|
||||
queryFn: () => AuthClientApi.getCurrentUser(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { AUTH_ACCESS_COOKIE_OPTIONS, AUTH_REFRESH_COOKIE_OPTIONS, COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { setClientCookie } from '@/lib/cookies/client';
|
||||
import { dispatchToast } from '@/lib/toast/dispatchToast';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
import type { LoginDto } from '../types';
|
||||
|
||||
export function useLogin() {
|
||||
return useMutation({
|
||||
mutationFn: (dto: LoginDto) => AuthClientApi.login(dto),
|
||||
onSuccess: (data) => {
|
||||
setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, data.accessToken, AUTH_ACCESS_COOKIE_OPTIONS);
|
||||
setClientCookie(COOKIE_NAMES.REFRESH_TOKEN, data.refreshToken, AUTH_REFRESH_COOKIE_OPTIONS);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
dispatchToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { deleteClientCookie } from '@/lib/cookies/client';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
|
||||
export function useLogout() {
|
||||
const [, dispatch] = useAppStore();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => AuthClientApi.logout(),
|
||||
onSettled: () => {
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
dispatch({ type: 'LOG_OUT' });
|
||||
router.replace(`/${locale}${ROUTES.LOGIN}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useLogin } from './hooks/useLogin';
|
||||
export { useLogout } from './hooks/useLogout';
|
||||
export { useCurrentUser } from './hooks/useCurrentUser';
|
||||
@@ -0,0 +1,4 @@
|
||||
export const authKeys = {
|
||||
all: ['auth'] as const,
|
||||
currentUser: () => [...authKeys.all, 'me'] as const,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
Reference in New Issue
Block a user