another step constructing base project

This commit is contained in:
hamid
2026-06-18 01:19:23 +03:30
parent 5388bea320
commit e135b0b919
27 changed files with 1022 additions and 355 deletions
+9 -1
View File
@@ -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>
+3
View File
@@ -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,
});
+1
View File
@@ -0,0 +1 @@
export * from './routes';
+7
View File
@@ -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
View File
@@ -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]);
}
+11 -1
View File
@@ -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' },
+80
View File
@@ -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);
}
+10
View File
@@ -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';
}
}
+60
View File
@@ -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);
}
+20 -17
View File
@@ -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 });
}
/**
+16
View File
@@ -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,
};
+17
View File
@@ -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>
);
}
+27
View File
@@ -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;
}
+31
View File
@@ -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;
}
+11
View File
@@ -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 } }));
}
+2
View File
@@ -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}`);
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export { useLogin } from './hooks/useLogin';
export { useLogout } from './hooks/useLogout';
export { useCurrentUser } from './hooks/useCurrentUser';
+4
View File
@@ -0,0 +1,4 @@
export const authKeys = {
all: ['auth'] as const,
currentUser: () => [...authKeys.all, 'me'] as const,
};
+14
View File
@@ -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;
}