feat: add logo image and RTL support styles

- Added logo image to assets.
- Implemented RTL support styles in rtlSupport.scss for various components including breadcrumb, datatable, and tree node toggle button.

chore: configure environment files for production, staging, and development

- Created environment.prod.ts for production settings.
- Created environment.staging.ts for staging settings.
- Created environment.ts for local development settings.

feat: define custom theme preset

- Added presets.ts to define a custom theme preset using PrimeUIX with specific component styles and semantic colors.

chore: set up proxy configuration for local development
This commit is contained in:
2025-12-04 21:07:18 +03:30
parent c58210cdbd
commit 07fec02ea1
164 changed files with 20175 additions and 735 deletions
@@ -0,0 +1,6 @@
import { IPaginatedQuery } from '../models/service.model';
export const PAGINATED_QUERY_DEFAULT_VALUES: IPaginatedQuery = {
lastSeen: '',
pageSize: 10,
};
+2
View File
@@ -0,0 +1,2 @@
export * from './defaultData.const';
export * from './roles.const';
+20
View File
@@ -0,0 +1,20 @@
import { TRoles } from '../models';
export default {
ADMIN: {
title: 'مدیر سیستم',
key: 'ADMIN',
},
SCHOOL: {
title: 'مرکز آموزشی',
key: 'SCHOOL',
},
TEACHER: {
title: 'دبیر',
key: 'TEACHER',
},
STUDENTS: {
title: 'دانش‌آموز',
key: 'STUDENTS',
},
} as Record<TRoles, { title: string; key: TRoles }>;
@@ -0,0 +1,26 @@
import { Directive, ElementRef, Renderer2 } from '@angular/core';
/**
* Prevent aggressive content-scripts and autofill engines from inspecting
* or modifying inputs by setting common attributes that signal disabled
* editing/grammar/autocomplete features.
*/
@Directive({ selector: '[appBlockContentScripts]', standalone: true })
export class BlockContentScriptsDirective {
constructor(
private el: ElementRef<HTMLElement>,
private r: Renderer2,
) {
const attrs: Record<string, string> = {
autocomplete: 'off',
spellcheck: 'false',
'data-gramm': 'false',
'data-gramm_editor': 'false',
autocorrect: 'off',
autocapitalize: 'off',
'aria-autocomplete': 'none',
};
Object.entries(attrs).forEach(([k, v]) => this.r.setAttribute(this.el.nativeElement, k, v));
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Injectable, inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { ToastService } from '../services/toast.service';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
private readonly toastService = inject(ToastService);
canActivate(
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot,
): Observable<boolean> | Promise<boolean> | boolean {
if (this.authService.isAuthenticated()) {
return true;
}
this.toastService.warn({
title: 'دسترسی غیرمجاز',
text: 'برای دسترسی به این بخش باید وارد شوید.',
});
this.router.navigate(['/auth']);
return false;
// return this.router.navigate(['/auth/login'], {
// queryParams: { returnUrl: state.url },
// });
// return true;
// return this.authService.currentUser$.pipe(
// map((user) => {
// if (user) {
// return true;
// } else {
// this.router.navigate(['/auth/login'], {
// queryParams: { returnUrl: state.url },
// });
// return false;
// }
// })
// );
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './auth.guard';
export * from './role.guard';
+39
View File
@@ -0,0 +1,39 @@
import { Injectable, inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
@Injectable({
providedIn: 'root',
})
export class RoleGuard implements CanActivate {
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> | Promise<boolean> | boolean {
return true;
// const requiredRoles = route.data['roles'] as UserRole[];
// const requiredPermissions = route.data['permissions'] as string[];
// return this.authService.currentUser$.pipe(
// map((user) => {
// if (!user) {
// this.router.navigate(['/auth/login']);
// return false;
// }
// const canAccess = this.authService.canAccess(requiredRoles, requiredPermissions);
// if (!canAccess) {
// this.router.navigate(['/unauthorized']);
// return false;
// }
// return true;
// })
// );
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './guards';
export * from './models';
export * from './services/auth.service';
@@ -0,0 +1,39 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
@Injectable()
export class ApiBaseUrlInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Prepare default headers but don't overwrite existing ones
const defaultHeaders: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
const headersToSet: Record<string, string> = {};
const isFormData = req.body instanceof FormData;
Object.keys(defaultHeaders).forEach((k) => {
if (k === 'Content-Type' && isFormData) return;
if (!req.headers.has(k)) {
headersToSet[k] = defaultHeaders[k];
}
});
// Only prepend base URL if the request URL is relative (does not start with http or https)
if (!/^https?:\/\//i.test(req.url)) {
const apiReq = req.clone({ url: environment.apiBaseUrl + req.url, setHeaders: headersToSet });
return next.handle(apiReq);
}
// If absolute URL, still ensure default headers are present
if (Object.keys(headersToSet).length > 0) {
const reqWithHeaders = req.clone({ setHeaders: headersToSet });
return next.handle(reqWithHeaders);
}
return next.handle(req);
}
}
@@ -0,0 +1,115 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, filter, switchMap, take } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
/**
* HTTP Interceptor for token management
* - Adds Authorization header with Bearer token to requests
* - Handles token refresh on 401 responses
* - Prevents duplicate refresh token requests
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
// Skip auth for certain endpoints
if (shouldSkipAuth(req.url)) {
return next(req);
}
const token = authService.getToken();
// Add token to request if available
const authReq = token
? req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
})
: req;
return next(authReq).pipe(
catchError((error) => {
// Handle 401 Unauthorized - token might be expired
if (error.status === 401 && !isRefreshTokenRequest(req.url)) {
return handleTokenRefresh(authService, req, next);
}
return throwError(() => error);
})
);
};
// Subject to track if refresh is in progress
const isRefreshing = new BehaviorSubject<boolean>(false);
/**
* Handle token refresh logic
*/
function handleTokenRefresh(authService: AuthService, req: any, next: any): Observable<any> {
if (!isRefreshing.value) {
isRefreshing.next(true);
const refreshToken = authService.getRefreshToken();
if (!refreshToken) {
isRefreshing.next(false);
authService.logout();
return throwError(() => new Error('توکنی یافت نشد'));
}
return authService.refreshToken().pipe(
switchMap((response) => {
isRefreshing.next(false);
// Retry original request with new token
const newAuthReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${response.token}`),
});
return next(newAuthReq);
}),
catchError((refreshError) => {
isRefreshing.next(false);
authService.logout();
return throwError(() => refreshError);
})
);
} else {
// Wait for refresh to complete, then retry request
return isRefreshing.pipe(
filter((refreshing) => !refreshing),
take(1),
switchMap(() => {
const token = authService.getToken();
const newAuthReq = token
? req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
})
: req;
return next(newAuthReq);
})
);
}
}
/**
* Check if the request should skip authentication
*/
function shouldSkipAuth(url: string): boolean {
const skipAuthUrls = [
'/api/auth/login',
'/api/auth/register',
'/api/auth/forgot-password',
'/api/auth/verify-email',
'/api/public/',
];
return skipAuthUrls.some((skipUrl) => url.includes(skipUrl));
}
/**
* Check if this is a refresh token request
*/
function isRefreshTokenRequest(url: string): boolean {
return url.includes('/api/auth/refresh');
}
@@ -0,0 +1,108 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, filter, switchMap, take } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
/**
* HTTP Interceptor for token management
* - Adds Authorization header with Bearer token to requests
* - Handles token refresh on 401 responses
* - Prevents duplicate refresh token requests
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
// Skip auth for certain endpoints
if (shouldSkipAuth(req.url)) {
return next(req);
}
const token = authService.getToken();
// Add token to request if available
const authReq = token
? req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
})
: req;
return next(authReq).pipe(
catchError((error) => {
// Handle 401 Unauthorized - token might be expired
if (error.status === 401 && !isRefreshTokenRequest(req.url)) {
return handleTokenRefresh(authService, req, next);
}
return throwError(() => error);
}),
);
};
// Subject to track if refresh is in progress
const isRefreshing = new BehaviorSubject<boolean>(false);
/**
* Handle token refresh logic
*/
function handleTokenRefresh(authService: AuthService, req: any, next: any): Observable<any> {
if (!isRefreshing.value) {
isRefreshing.next(true);
const refreshToken = authService.getRefreshToken();
if (!refreshToken) {
isRefreshing.next(false);
authService.logout();
return throwError(() => new Error('توکنی یافت نشد'));
}
return authService.refreshToken().pipe(
switchMap((response) => {
isRefreshing.next(false);
const newAuthReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${response.token}`),
});
return next(newAuthReq);
}),
catchError((refreshError) => {
isRefreshing.next(false);
authService.logout();
return throwError(() => refreshError);
}),
);
} else {
// Wait for refresh to complete, then retry request
return isRefreshing.pipe(
filter((refreshing) => !refreshing),
take(1),
switchMap(() => {
const token = authService.getToken();
const newAuthReq = token
? req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
})
: req;
return next(newAuthReq);
}),
);
}
}
/**
* Check if the request should skip authentication
*/
function shouldSkipAuth(url: string): boolean {
const skipAuthUrls = ['/captcha', '/api/v1/mdm', '/login'];
return skipAuthUrls.some((skipUrl) => url.includes(skipUrl));
}
/**
* Check if this is a refresh token request
*/
function isRefreshTokenRequest(url: string): boolean {
return url.includes('/api/auth/refresh');
}
@@ -0,0 +1,35 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { Observable } from 'rxjs';
import { finalize, shareReplay } from 'rxjs/operators';
const inFlightRequests = new Map<string, Observable<any>>();
function getRequestKey(req: any) {
// Use method + full URL (including query params) as key
return `${req.method}::${req.urlWithParams || req.url}`;
}
/**
* Deduplicate concurrent identical GET requests.
* If a GET with the same url+params is already in-flight, the same observable is returned.
*/
export const dedupInterceptor: HttpInterceptorFn = (req, next) => {
// Only deduplicate safe idempotent GET requests
// if (req.method !== 'GET') {
// return next(req);
// }
const key = getRequestKey(req);
const existing = inFlightRequests.get(key);
if (existing) return existing;
const shared$ = next(req).pipe(
// replay 1 value for any concurrent subscribers
shareReplay({ bufferSize: 1, refCount: false }),
// when finished (success or error) remove from map
finalize(() => inFlightRequests.delete(key)),
);
inFlightRequests.set(key, shared$);
return shared$;
};
@@ -0,0 +1,151 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorHandlerService } from '../services/error-handler.service';
/**
* Error handling interceptor
* - Handles HTTP errors globally
* - Shows user-friendly error messages
* - Redirects on authentication errors
* - Logs errors for debugging
*/
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const errorHandler = inject(ErrorHandlerService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
let errorMessage = 'خطای نامشخص رخ داده است';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = `خطای کلاینت: ${error.error.message}`;
console.error('Client-side error:', error.error.message);
} else {
// Server-side error
console.error(`Server error ${error.status}:`, error.error);
switch (error.status) {
case 400:
errorMessage = error.error?.message || 'درخواست نامعتبر است';
break;
case 401:
errorMessage = 'لطفاً مجدداً وارد شوید';
// Don't redirect here as auth interceptor handles it
break;
case 403:
errorMessage = 'شما دسترسی به این بخش ندارید';
break;
case 404:
errorMessage = 'منبع مورد نظر یافت نشد';
break;
case 412:
errorMessage = error.error?.detail || 'اطلاعات ارسالی نامعتبر است';
break;
case 422:
errorMessage = error.error?.message || 'اطلاعات ارسالی نامعتبر است';
break;
case 429:
errorMessage = 'تعداد درخواست‌ها بیش از حد مجاز است';
break;
case 500:
errorMessage = 'خطای داخلی سرور رخ داده است';
break;
case 502:
errorMessage = 'سرور در حال حاضر در دسترس نیست';
break;
case 503:
errorMessage = 'سرویس موقتاً خارج از دسترس است';
break;
default:
if (error.status === 0) {
errorMessage = 'اتصال به سرور برقرار نشد';
} else {
errorMessage = `خطای سرور (${error.status}): ${error.error?.message || error.message}`;
}
}
}
// Create enhanced error object
const enhancedError = {
...error,
userMessage: errorMessage,
timestamp: new Date().toISOString(),
url: req.url,
method: req.method,
};
// Log error details for debugging
console.group('🚨 HTTP Error Details');
console.error('URL:', req.url);
console.error('Method:', req.method);
console.error('Status:', error.status);
console.error('Message:', errorMessage);
console.error('Full Error:', error);
console.groupEnd();
// Handle error with notification service
errorHandler.handleError(enhancedError);
return throwError(() => enhancedError);
}),
);
};
/**
* Show error notification to user
* Enhanced with Material Design snackbar notifications
*/
function showErrorNotification(message: string, status: number): void {
// For now, we'll use console.error
console.error(`🔥 Error ${status}: ${message}`);
// You can also show browser notification for critical errors
if (status >= 500) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('خطای سرور', {
body: message,
icon: '/favicon.ico',
});
}
}
}
/**
* Error types for better error handling
*/
export enum ErrorType {
NETWORK = 'NETWORK',
SERVER = 'SERVER',
CLIENT = 'CLIENT',
VALIDATION = 'VALIDATION',
AUTHENTICATION = 'AUTHENTICATION',
AUTHORIZATION = 'AUTHORIZATION',
}
/**
* Helper function to determine error type
*/
export function getErrorType(error: HttpErrorResponse): ErrorType {
if (error.error instanceof ErrorEvent) {
return ErrorType.NETWORK;
}
switch (error.status) {
case 400:
case 412:
case 422:
return ErrorType.VALIDATION;
case 401:
return ErrorType.AUTHENTICATION;
case 403:
return ErrorType.AUTHORIZATION;
case 0:
return ErrorType.NETWORK;
default:
return error.status >= 500 ? ErrorType.SERVER : ErrorType.CLIENT;
}
}
+6
View File
@@ -0,0 +1,6 @@
// HTTP Interceptors
export { ApiBaseUrlInterceptor } from './api-base-url.interceptor';
export { authInterceptor } from './auth.interceptor';
export { dedupInterceptor } from './dedup.interceptor';
export { errorInterceptor, ErrorType, getErrorType } from './error.interceptor';
export { loggingInterceptor } from './logging.interceptor';
@@ -0,0 +1,65 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { tap } from 'rxjs/operators';
/**
* Logging interceptor for development
* - Logs all HTTP requests and responses
* - Measures request duration
* - Provides detailed debugging information
*/
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const startTime = Date.now();
// Only log in development mode
if (!isProductionMode()) {
// console.group(`🌐 HTTP ${req.method} ${req.url}`);
// console.log('Request:', {
// url: req.url,
// method: req.method,
// headers: req.headers.keys().reduce((acc, key) => {
// // Don't log sensitive headers
// if (!isSensitiveHeader(key)) {
// acc[key] = req.headers.get(key);
// }
// return acc;
// }, {} as any),
// body: req.body,
// });
}
return next(req).pipe(
tap({
next: (response) => {
if (!isProductionMode()) {
const duration = Date.now() - startTime;
// console.log(`✅ Response (${duration}ms):`, response);
console.groupEnd();
}
},
error: (error) => {
if (!isProductionMode()) {
const duration = Date.now() - startTime;
console.error(`❌ Error (${duration}ms):`, error);
console.groupEnd();
}
},
}),
);
};
/**
* Check if we're in production mode
*/
function isProductionMode(): boolean {
// You can also check environment variables
return false; // Set to true in production builds
}
/**
* Check if header contains sensitive information
*/
function isSensitiveHeader(headerName: string): boolean {
const sensitiveHeaders = ['authorization', 'cookie', 'x-api-key', 'x-auth-token'];
return sensitiveHeaders.includes(headerName.toLowerCase());
}
+5
View File
@@ -0,0 +1,5 @@
export * from './maybe.model';
export * from './namedRoutes.model';
export * from './navigation.model';
export * from './route-utils.model';
export * from './user.model';
+1
View File
@@ -0,0 +1 @@
export type Maybe<T> = T | null;
+28
View File
@@ -0,0 +1,28 @@
import { Route } from '@angular/router';
/**
* Generic type for creating strongly typed named routes
* @template T - Union of string literals representing route names
* @returns Record<T, Route> - Object with route names as keys and Route objects as values
*
* @example
* ```typescript
* type AuthRouteNames = 'auth' | 'login';
* const authRoutes: NamedRoutes<AuthRouteNames> = {
* auth: { path: 'auth', component: AuthComponent },
* login: { path: 'login', component: LoginComponent }
* };
* ```
*/
export interface RouteInfo {
meta: {
title: string;
icon?: string;
pagePath?: (params: any) => string;
};
}
export interface NamedRouteWithInfo extends Route, RouteInfo {}
export type NamedRoutes<T extends string> = Record<T, NamedRouteWithInfo>;
+14
View File
@@ -0,0 +1,14 @@
export interface MenuItem {
key: string;
title: string;
icon?: string;
routerLink?: string;
children?: MenuItem[];
permissions?: string[];
roles?: string[];
}
export interface NavigationConfig {
role: string;
menuItems: MenuItem[];
}
+56
View File
@@ -0,0 +1,56 @@
import { Routes } from '@angular/router';
import { NamedRoutes } from './namedRoutes.model';
/**
* Utility function to convert NamedRoutes to Routes array
* @param namedRoutes - Object containing named routes
* @returns Array of Route objects suitable for Angular router
*
* @example
* ```typescript
* const routes = routesToArray(studentNamedRoutes);
* // Returns: [Route, Route, Route, ...]
* ```
*/
export function routesToArray<T extends string>(namedRoutes: NamedRoutes<T>): Routes {
return Object.values(namedRoutes);
}
/**
* Utility function to get route path by name with type safety
* @param namedRoutes - Object containing named routes
* @param routeName - Name of the route to get path for
* @returns The path string for the specified route
*
* @example
* ```typescript
* const dashboardPath = getRoutePath(studentNamedRoutes, 'dashboard');
* // Returns: 'dashboard'
* ```
*/
export function getRoutePath<T extends string>(namedRoutes: NamedRoutes<T>, routeName: T): string {
return namedRoutes[routeName].path || '';
}
/**
* Utility function to build full route path with prefix
* @param prefix - Route prefix (e.g., '/student')
* @param namedRoutes - Object containing named routes
* @param routeName - Name of the route to build path for
* @returns Full route path
*
* @example
* ```typescript
* const fullPath = buildRoutePath('/student', studentNamedRoutes, 'dashboard');
* // Returns: '/student/dashboard'
* ```
*/
export function buildRoutePath<T extends string>(
prefix: string,
namedRoutes: NamedRoutes<T>,
routeName: T
): string {
const basePath = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
const routePath = getRoutePath(namedRoutes, routeName);
return `${basePath}/${routePath}`;
}
+10
View File
@@ -0,0 +1,10 @@
export interface IPaginatedQuery<LastSeen = string> {
lastSeen: LastSeen;
pageSize: number;
}
export interface IPaginatedResponse<T, LastSeen = string> {
lastSeen: LastSeen;
totalCount: number;
items: T[];
}
+9
View File
@@ -0,0 +1,9 @@
export type TSeverity =
| 'success'
| 'secondary'
| 'info'
| 'warn'
| 'danger'
| 'contrast'
| undefined
| null;
+5
View File
@@ -0,0 +1,5 @@
export interface defaultStoreState<T> {
loading: boolean;
error: string | null;
items: T;
}
+64
View File
@@ -0,0 +1,64 @@
export enum UserRole {
ADMIN = 'admin',
SCHOOL = 'school',
TEACHER = 'teacher',
STUDENTS = 'students',
GRADER = 'grader',
SUPERADMIN = 'superadmin',
}
export type TRoles = keyof typeof UserRole;
export interface User {
fullName: string;
role: TRoles;
// id: string;
// email: string;
// firstName: string;
// lastName: string;
// permissions: Permission[];
// isActive: boolean;
// lastLogin?: Date;
// createdAt: Date;
// updatedAt: Date;
}
export interface Permission {
id: string;
name: string;
resource: string;
action: string;
}
export interface IAuthResponse {
// user: User;
token: string;
fullName: string;
mustChangePassword: boolean;
role: string;
refreshToken: string;
expiresIn: number;
}
export interface LoginCredentials {
username: string;
password: string;
captcha: string;
}
export interface IUserLoginInfo {
username: string;
password: string;
mobile: string;
email: string;
}
export interface ISignupRequestPayload {
firstName: string;
lastName: string;
gender: boolean;
mobile: string;
email: string;
username: string;
password: string;
}
+344
View File
@@ -0,0 +1,344 @@
// import { ADMIN_API_ROUTES } from '@/modules/admin/constants';
// import { SCHOOLS_API_ROUTES } from '@/modules/schools/constants';
// import { ISchoolMeResponse } from '@/modules/schools/models';
// import { TEACHERS_API_ROUTES } from '@/modules/teachers/constants';
import { HttpClient } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { LOCAL_STORAGE_KEYS } from '../../../assets/constants';
import {
IAuthResponse,
ISignupRequestPayload,
IUserLoginInfo,
LoginCredentials,
Maybe,
TRoles,
User,
} from '../models';
import { CaptchaService } from './captcha.service';
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor() {
this.initializeAuth();
}
readonly modulesLoginRoutes = {
// ADMIN: ADMIN_API_ROUTES.login(),
// SCHOOL: SCHOOLS_API_ROUTES.login(),
// TEACHER: TEACHERS_API_ROUTES.login(),
} as Record<TRoles, string>;
readonly modulesSignupRoutes = {
// TEACHER: TEACHERS_API_ROUTES.signup(),
} as Record<TRoles, string>;
readonly modulesGetInfoRoutes = {
// SCHOOL: SCHOOLS_API_ROUTES.me(),
} as Record<TRoles, string>;
readonly modulesChangeInfoRoutes = {
// SCHOOL: SCHOOLS_API_ROUTES.editLoginInfo(),
} as Record<TRoles, string>;
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private captchaService = inject(CaptchaService);
private readonly currentUserSubject = new BehaviorSubject<Maybe<User>>(
null,
);
private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
readonly currentUser$ = this.currentUserSubject.asObservable();
readonly isLoading$ = this.isLoadingSubject.asObservable();
// Signals for reactive state management
// readonly currentUser = signal<Maybe<User>>(null);
readonly currentUser = signal<Maybe<User>>(null);
readonly isLoading = signal<boolean>(false);
readonly token = signal<Maybe<string>>(null);
readonly isAuthenticated = computed(() => {
return Boolean(this.token());
});
readonly userRole = computed(() => this.currentUser()?.role);
private initializeAuth(): void {
const token = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
const userData = localStorage.getItem(LOCAL_STORAGE_KEYS.USER_DATA);
if (token && userData) {
try {
this.token.set(token);
const user = JSON.parse(userData) as User;
this.setCurrentUser(user);
} catch (error) {
console.error('Error parsing stored user data:', error);
this.logout();
}
}
}
login(
credentials: LoginCredentials,
role: TRoles,
): Observable<IAuthResponse> {
const { username, password, captcha } = credentials;
this.isLoading.set(true);
this.isLoadingSubject.next(true);
const baseHeaders = this.captchaService.buildCaptchaHeaders(
{},
captcha,
);
return this.http
.post<IAuthResponse>(
this.modulesLoginRoutes[role],
{ username, password },
{
headers: baseHeaders,
},
)
.pipe(
tap((response) => {
this.handleAuthSuccess(response);
return response;
}),
catchError((error) => {
console.error('Login error:', error);
this.isLoadingSubject.next(false);
throw error;
}),
finalize(() => {
this.isLoading.set(false);
}),
);
}
signup(
credentials: ISignupRequestPayload,
role: TRoles,
): Observable<IAuthResponse> {
this.isLoading.set(true);
this.isLoadingSubject.next(true);
// const baseHeaders = this.captchaService.buildCaptchaHeaders({}, captcha);
return this.http
.post<IAuthResponse>(
this.modulesSignupRoutes[role],
credentials,
// {
// headers: baseHeaders,
// },
)
.pipe(
tap((response) => {
this.handleAuthSuccess(response);
return response;
}),
catchError((error) => {
console.error('Signup error:', error);
this.isLoadingSubject.next(false);
throw error;
}),
finalize(() => {
this.isLoading.set(false);
}),
);
}
logout(): void {
localStorage.removeItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
localStorage.removeItem(LOCAL_STORAGE_KEYS.USER_DATA);
localStorage.removeItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
this.setCurrentUser(null);
this.router.navigate(['/auth']);
}
refreshToken(): Observable<IAuthResponse> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
this.logout();
return throwError(() => new Error('No refresh token available'));
}
return this.http
.post<IAuthResponse>('/api/auth/refresh', { refreshToken })
.pipe(
tap((response) => {
this.handleAuthSuccess(response);
}),
catchError((error) => {
console.error('Token refresh error:', error);
this.logout();
return throwError(() => error);
}),
);
}
getInfo(role: TRoles): Observable<Partial<IUserLoginInfo>> {
// if (this.modulesGetInfoRoutes[role]) {
// return this.http.get<any>(this.modulesGetInfoRoutes[role]).pipe(
// map((data) => {
// switch (role) {
// case 'SCHOOL':
// return this.prepareUserInfoBasedOnRole(role, data as ISchoolMeResponse);
// default:
// return data as Partial<IUserLoginInfo>;
// }
// }),
// catchError((err) => {
// console.error('GetInfo error:', err);
// return throwError(() => err);
// }),
// );
// }
return throwError(() => new Error('متاسفانه مشکلی پیش آمده'));
}
changeInfo(
credentials: IUserLoginInfo,
role: TRoles,
): Observable<IAuthResponse> {
return this.http.post<IAuthResponse>(
this.modulesChangeInfoRoutes[role],
credentials,
);
}
// prepareUserInfoBasedOnRole(role: TRoles, info: ISchoolMeResponse): Partial<IUserLoginInfo> {
// if (role === 'SCHOOL') {
// return {
// username: info.username,
// email: info.managerInfo.email,
// mobile: info.managerInfo.mobile,
// };
// }
// return {};
// }
/**
* Get current access token
*/
getToken(): Maybe<string> {
return localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
}
/**
* Get current refresh token
*/
getRefreshToken(): Maybe<string> {
return localStorage.getItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
}
/**
* Check if token is expired (optional - requires JWT parsing)
*/
isTokenExpired(): boolean {
const token = this.getToken();
if (!token) return true;
try {
// Parse JWT token to check expiration
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp < currentTime;
} catch (error) {
console.error('Error parsing token:', error);
return true;
}
}
/**
* Check if refresh token is expired
*/
isRefreshTokenExpired(): boolean {
const refreshToken = this.getRefreshToken();
if (!refreshToken) return true;
try {
// Parse JWT refresh token to check expiration
const payload = JSON.parse(atob(refreshToken.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp < currentTime;
} catch (error) {
console.error('Error parsing refresh token:', error);
return true;
}
}
hasRole(role: TRoles): boolean {
return this.currentUser()?.role === role;
}
hasAnyRole(roles: TRoles[]): boolean {
const currentRole = this.currentUser()?.role;
return currentRole ? roles.includes(currentRole) : false;
}
hasPermission(permission: string): boolean {
const user = this.currentUser();
if (!user) return false;
return true;
// return user.permissions.some(
// (p) => p.name === permission || `${p.resource}:${p.action}` === permission,
// );
}
canAccess(
requiredRoles?: TRoles[],
requiredPermissions?: string[],
): boolean {
if (!this.isAuthenticated()) return false;
if (requiredRoles && requiredRoles.length > 0) {
if (!this.hasAnyRole(requiredRoles)) return false;
}
if (requiredPermissions && requiredPermissions.length > 0) {
return requiredPermissions.every((permission) =>
this.hasPermission(permission),
);
}
return true;
}
private handleAuthSuccess(response: IAuthResponse): void {
localStorage.setItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN, response.token);
this.token.set(response.token);
// localStorage.setItem('refresh_token', response.refreshToken);
const user = {
fullName: response.fullName,
role: response.role as TRoles,
};
localStorage.setItem(
LOCAL_STORAGE_KEYS.USER_DATA,
JSON.stringify(user),
);
this.setCurrentUser(user);
// this.isLoading.set(false);
this.currentUser.set({
fullName: response.fullName,
role: response.role as TRoles,
});
this.isLoadingSubject.next(false);
// Navigate based on user role
// this.navigateByRole(response.user.role);
}
private setCurrentUser(user: Maybe<User>): void {
this.currentUser.set(user);
this.currentUserSubject.next(user);
}
}
@@ -0,0 +1,31 @@
import { Injectable, inject, signal } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { MenuItem } from 'primeng/api';
import { filter } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class BreadcrumbService {
private readonly _items = signal<MenuItem[]>([]);
private router = inject(Router);
constructor() {
// Clear breadcrumb items on navigation start so previous page items don't persist.
// Components for the new route can set their own items in their lifecycle (e.g. ngOnInit).
this.router.events.pipe(filter((e) => e instanceof NavigationStart)).subscribe(() => {
this.clear();
});
}
get items() {
return this._items();
}
setItems(items: MenuItem[]) {
this._items.set(items);
}
clear() {
this._items.set([]);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { timeToSeconds } from '@/utils';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { computed, Injectable, signal, Signal } from '@angular/core';
import { Subscription, timer } from 'rxjs';
import { finalize, tap } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Maybe } from '../models';
interface ICaptchaResponse {
id: string;
expiry: string;
}
@Injectable({ providedIn: 'root' })
export class CaptchaService {
// Signals for reactive consumption in components
private _captchaId = signal<string | null>(null);
readonly captchaId: Signal<string | null> = this._captchaId;
private _loading = signal<boolean>(false);
readonly loading: Signal<boolean> = this._loading;
// default TTL 120 seconds
private ttlSeconds = signal<number>(120);
private ttlTimerSub: Subscription | null = null;
readonly captchaImageSrc = signal<Maybe<string>>(null);
constructor(private http: HttpClient) {}
readonly captchaIsExpired = computed(() => !this._captchaId());
/** request a new captcha from backend and start TTL countdown */
requestNewCaptcha(): void {
const url = `/captcha/new`;
this.getCaptcha(url);
}
private getCaptcha(url: string, headers?: HttpHeaders): void {
this._loading.set(true);
// Cancel any previous TTL timer
this.clearTtlTimer();
this.http
.get<ICaptchaResponse>(url, {
headers,
})
.pipe(
tap((res) => {
if (!res) {
throw new Error('Invalid captcha response');
}
this._captchaId.set(res.id);
this.ttlSeconds.set(timeToSeconds(res.expiry));
this.startTtlTimer();
this.setCaptchaImageSrc();
}),
finalize(() => this._loading.set(false)),
)
.subscribe({
next: () => {},
error: (err) => {
console.error('[CaptchaService] requestNewCaptcha error', err);
this._captchaId.set(null);
},
});
}
/** Manually clear captcha and cancel TTL */
clearCaptcha(): void {
this._captchaId.set(null);
this.clearTtlTimer();
}
setCaptchaImageSrc = () => {
const id = this._captchaId();
const base = environment.apiBaseUrl ? environment.apiBaseUrl.replace(/\/$/, '') : '';
this.captchaImageSrc.set(
id ? `${base}/captcha?Id=${encodeURIComponent(id)}&width=222&height=111` : '',
);
};
renewCaptcha = () => {
const url = `/captcha/new`;
const headers = new HttpHeaders({
'x-OldCaptchaId': this.captchaId()!,
});
this.getCaptcha(url, headers);
};
buildCaptchaHeaders(
existing: Record<string, string> = {},
captchaValue: string,
): Record<string, string> {
const id = this._captchaId();
if (!id) return { ...existing };
return {
...existing,
'x-CaptchaId': id,
'x-CaptchaValue': captchaValue,
};
}
/** internal TTL timer management */
private startTtlTimer() {
this.clearTtlTimer();
this.ttlTimerSub = timer(this.ttlSeconds() * 1000).subscribe(() => {
this.requestNewCaptcha();
this.ttlTimerSub = null;
});
}
private clearTtlTimer() {
if (this.ttlTimerSub) {
this.ttlTimerSub.unsubscribe();
this.ttlTimerSub = null;
}
}
}
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { ConfigService } from './config.service';
describe('ConfigService', () => {
let service: ConfigService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ConfigService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return API base URL', () => {
expect(service.apiBaseUrl).toBeDefined();
expect(typeof service.apiBaseUrl).toBe('string');
});
it('should return host', () => {
expect(service.host).toBeDefined();
expect(typeof service.host).toBe('string');
});
it('should return port', () => {
expect(service.port).toBeDefined();
expect(typeof service.port).toBe('number');
});
it('should construct full API URL', () => {
const path = '/test/endpoint';
const fullUrl = service.getApiUrl(path);
expect(fullUrl).toContain(service.apiBaseUrl);
expect(fullUrl).toContain(path);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
/**
* Configuration service to provide centralized access to environment variables
*/
@Injectable({
providedIn: 'root',
})
export class ConfigService {
/**
* Get the base URL for API calls
*/
get apiBaseUrl(): string {
return environment.apiBaseUrl;
}
/**
* Get the application host
*/
get host(): string {
return environment.host;
}
/**
* Get the application port
*/
get port(): number {
return environment.port;
}
/**
* Check if running in production mode
*/
get isProduction(): boolean {
return environment.production;
}
/**
* Check if logging is enabled
*/
get loggingEnabled(): boolean {
return environment.enableLogging;
}
/**
* Check if debug mode is enabled
*/
get debugEnabled(): boolean {
return environment.enableDebug;
}
/**
* Get the full API URL for a given path
* @param path The API path (should start with /)
*/
getApiUrl(path: string): string {
return `${this.apiBaseUrl}${path}`;
}
/**
* Log a message if logging is enabled
* @param message The message to log
* @param data Optional data to log
*/
log(message: string, ...data: any[]): void {
if (this.loggingEnabled) {
console.log(`[ConfigService] ${message}`, ...data);
}
}
}
@@ -0,0 +1,217 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { ErrorType, getErrorType } from '../interceptors/error.interceptor';
import { ToastService } from './toast.service';
@Injectable({
providedIn: 'root',
})
export class ErrorHandlerService {
private readonly toastService = inject(ToastService);
/**
* Handle HTTP errors with user-friendly notifications
*/
handleError(error: any): void {
this.handleHttpError(error);
// if (error instanceof HttpErrorResponse) {
// } else {
// this.handleGenericError(error);
// }
}
/**
* Handle HTTP errors specifically
*/
private handleHttpError(error: HttpErrorResponse): void {
const errorType = getErrorType(error);
const userMessage = this.getUserMessage(error);
if (userMessage) {
this.toastService.error({
title: error.error?.title || 'خطا',
text: userMessage,
});
} else {
switch (errorType) {
case ErrorType.NETWORK:
this.showNetworkError(userMessage);
break;
case ErrorType.AUTHENTICATION:
this.showAuthError(userMessage);
break;
case ErrorType.AUTHORIZATION:
this.showAuthorizationError(userMessage);
break;
case ErrorType.VALIDATION:
this.showValidationError(userMessage, error);
break;
case ErrorType.SERVER:
this.showServerError(userMessage);
break;
default:
this.showGenericError(userMessage);
}
}
}
/**
* Handle generic JavaScript errors
*/
private handleGenericError(error: any): void {
console.error('Generic error:', error);
this.toastService.error({
text: 'یک خطای غیرمنتظره رخ داده است. لطفاً صفحه را تازه‌سازی کنید.',
});
}
/**
* Show network error notification
*/
private showNetworkError(message: string): void {
this.toastService.error({
title: 'خطای اتصال',
text: message,
});
}
/**
* Show authentication error notification
*/
private showAuthError(message: string): void {
this.toastService.warn({
title: 'خطای احراز هویت',
text: message,
});
}
/**
* Show authorization error notification
*/
private showAuthorizationError(message: string): void {
this.toastService.warn({
title: 'عدم دسترسی',
text: message,
});
}
/**
* Show validation error notification
*/
private showValidationError(message: string, error: HttpErrorResponse): void {
// Handle field-specific validation errors
const validationErrors = this.extractValidationErrors(error);
if (validationErrors.length > 0) {
const errorList = validationErrors.join(', ');
this.toastService.error({
title: 'خطاهای اعتبارسنجی',
text: errorList,
});
} else {
this.toastService.error({
title: 'خطای اعتبارسنجی',
text: message,
});
}
}
/**
* Show server error notification
*/
private showServerError(message: string): void {
this.toastService.error({
title: 'خطای سرور',
text: message,
});
}
/**
* Show generic error notification
*/
private showGenericError(message: string): void {
this.toastService.error({
text: message,
});
}
/**
* Extract user-friendly message from error
*/
private getUserMessage(error: HttpErrorResponse): string {
if (error.error && typeof error.error === 'object') {
// Try different common message fields
return (
error.error.message ||
error.error.error ||
error.error.detail ||
error.statusText ||
'خطای نامشخص رخ داده است'
);
}
if (typeof error.error === 'string') {
return error.error;
}
return error.statusText || 'خطای نامشخص رخ داده است';
}
/**
* Extract validation errors from server response
*/
private extractValidationErrors(error: HttpErrorResponse): string[] {
const errors: string[] = [];
if (error.error && typeof error.error === 'object') {
if (error.error.errors) {
Object.keys(error.error.errors).forEach((field) => {
const fieldErrors = error.error.errors[field];
if (Array.isArray(fieldErrors)) {
errors.push(...fieldErrors);
} else {
errors.push(fieldErrors);
}
});
}
// Handle other validation error formats
if (error.error.validationErrors) {
errors.push(...error.error.validationErrors);
}
}
return errors;
}
/**
* Success notification helper
*/
showSuccess(title: string, message: string): void {
this.toastService.success({
title,
text: message,
});
}
/**
* Info notification helper
*/
showInfo(title: string, message: string): void {
this.toastService.info({
title,
text: message,
});
}
/**
* Warning notification helper
*/
showWarning(title: string, message: string): void {
this.toastService.warn({
title,
text: message,
});
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class FakeApiService {
/**
* Simulate a GET request
*/
get<T>(data: T, ms: number = 500): Observable<T> {
return of(data).pipe(delay(ms));
}
/**
* Simulate a POST request
*/
post<T>(data: T, ms: number = 500): Observable<T> {
return of(data).pipe(delay(ms));
}
/**
* Simulate an error response
*/
error<T>(message: string, ms: number = 500): Observable<T> {
return throwError(() => new Error(message)).pipe(delay(ms));
}
}
@@ -0,0 +1,95 @@
import { Injectable } from '@angular/core';
import { AbstractControl } from '@angular/forms';
export interface ValidationMessage {
key: string;
message: string;
}
@Injectable({ providedIn: 'root' })
export class FormErrorsService {
// return list of human friendly messages for a control
getErrors(control: AbstractControl | null | undefined, fieldLabel?: string): ValidationMessage[] {
if (!control || !control.errors) return [];
const errors = control.errors as Record<string, any>;
const label = fieldLabel ?? 'این فیلد';
const out: ValidationMessage[] = [];
if (errors['required']) {
out.push({ key: 'required', message: `${label} الزامی است.` });
}
if (errors['requiredTrue']) {
out.push({ key: 'requiredTrue', message: `${label} باید تایید شود.` });
}
if (errors['minlength']) {
const info = errors['minlength'];
out.push({
key: 'minlength',
message: `${label} باید حداقل ${info.requiredLength} کاراکتر داشته باشد (فعلی ${info.actualLength}).`,
});
}
if (errors['maxlength']) {
const info = errors['maxlength'];
out.push({
key: 'maxlength',
message: `${label} نباید بیشتر از ${info.requiredLength} کاراکتر باشد (فعلی ${info.actualLength}).`,
});
}
if (errors['min']) {
const info = errors['min'];
out.push({
key: 'min',
message: `${label} باید حداقل ${info.min} باشد (فعلی ${info.actual}).`,
});
}
if (errors['max']) {
const info = errors['max'];
out.push({
key: 'max',
message: `${label} باید حداکثر ${info.max} باشد (فعلی ${info.actual}).`,
});
}
if (errors['email']) {
out.push({ key: 'email', message: `${label} ایمیل معتبری نیست.` });
}
if (errors['pattern']) {
out.push({ key: 'pattern', message: `${label} فرمت معتبری ندارد.` });
}
if (errors['mismatch']) {
out.push({ key: 'mismatch', message: `${label} تکرار رمز با رمز وارد شده مطابقت ندارد.` });
}
if (errors['postalCode']) {
out.push({ key: 'postalCode', message: `${label} معتبر نیست.` });
}
if (errors['invalidMobile']) {
out.push({ key: 'invalidMobile', message: `${label} معتبر نیست.` });
}
// fallback: include any other error keys
Object.keys(errors).forEach((k) => {
if (
[
'required',
'requiredTrue',
'minlength',
'maxlength',
'min',
'max',
'email',
'pattern',
].indexOf(k) === -1
) {
try {
const val = errors[k];
out.push({
key: k,
message: `${label}: ${typeof val === 'string' ? val : JSON.stringify(val)}`,
});
} catch {
out.push({ key: k, message: `${label}: ${k}` });
}
}
});
return out;
}
}
+5
View File
@@ -0,0 +1,5 @@
// Core Services
export { AuthService } from './auth.service';
export { ErrorHandlerService } from './error-handler.service';
export { ConfigService } from './config.service';
export * from './breadcrumb.service';
+39
View File
@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { MessageService, ToastMessageOptions } from 'primeng/api';
interface IToast extends Pick<ToastMessageOptions, 'sticky' | 'life'> {
title?: string;
text: string;
}
@Injectable({
providedIn: 'root',
})
export class ToastService {
constructor(private messageService: MessageService) {}
add(message: ToastMessageOptions) {
if (!message.detail) return;
this.messageService.add(message);
}
info(message: IToast) {
const { title = 'اطلاع', text } = message;
this.add({ ...message, summary: title, detail: text, severity: 'info' });
}
success(message: IToast) {
const { title = 'موفقیت', text } = message;
this.add({ ...message, summary: title, detail: text, severity: 'success' });
}
warn(message: IToast) {
const { title = 'هشدار', text } = message;
this.add({ ...message, summary: title, detail: text, severity: 'warn' });
}
error(message: IToast) {
const { title = 'خطا', text } = message;
this.add({ ...message, summary: title, detail: text, severity: 'error' });
}
}
+305
View File
@@ -0,0 +1,305 @@
import { Signal, WritableSignal, computed, signal } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Maybe } from '../models';
/**
* Base interface for all state objects
*/
export interface BaseState<T = any> {
loading: boolean;
error: Maybe<string>;
lastId?: string;
items?: T;
meta?: Record<string, any>;
}
/**
* Base interface for paginated lists
*/
export interface PaginatedState<T = any> extends BaseState {
items: T[];
totalCount: number;
currentPage: number;
pageSize: number;
hasMore: boolean;
}
/**
* Base interface for entity states (CRUD operations)
*/
export interface EntityState<T = any> extends BaseState {
entities: Record<string | number, T>;
selectedId: Maybe<string | number>;
ids: (string | number)[];
}
/**
* Loading states for different operations
*/
export interface LoadingState {
loading: boolean;
creating: boolean;
updating: boolean;
deleting: boolean;
fetching: boolean;
}
/**
* Generic action interface
*/
export interface Action<T = any> {
type: string;
payload?: T;
meta?: Record<string, any>;
}
/**
* State change event
*/
export interface StateChange<T = any> {
previousState: T;
currentState: T;
action: Action;
timestamp: number;
}
/**
* Base store class with common functionality
*/
export abstract class BaseStore<T extends BaseState> {
protected readonly _state: WritableSignal<T>;
protected readonly _stateSubject: BehaviorSubject<T>;
// Public readonly signals
readonly state: Signal<T>;
readonly loading: Signal<boolean>;
readonly error: Signal<Maybe<string>>;
// Observable for RxJS compatibility
readonly state$: Observable<T>;
constructor(initialState: T) {
this._state = signal(initialState);
this._stateSubject = new BehaviorSubject(initialState);
// Create computed signals
this.state = this._state.asReadonly();
this.loading = computed(() => this._state().loading);
this.error = computed(() => this._state().error);
// Observable for RxJS compatibility
this.state$ = this._stateSubject.asObservable();
}
/**
* Update the entire state
*/
protected setState(newState: T): void {
this._state.set(newState);
this._stateSubject.next(newState);
}
/**
* Partially update the state
*/
protected patchState(partialState: Partial<T>): void {
const currentState = this._state();
const newState = {
...currentState,
...partialState,
};
this.setState(newState);
}
/**
* Set loading state
*/
protected setLoading(loading: boolean, error: Maybe<string> = null): void {
this.patchState({
loading,
error,
} as Partial<T>);
}
/**
* Set error state
*/
protected setError(error: string): void {
this.patchState({
loading: false,
error,
} as Partial<T>);
}
/**
* Clear error state
*/
protected clearError(): void {
this.patchState({
error: null,
} as Partial<T>);
}
/**
* Reset state to initial values
*/
abstract reset(): void;
/**
* Get current state snapshot
*/
getCurrentState(): T {
return this._state();
}
}
/**
* Entity store with CRUD operations
*/
export abstract class EntityStore<
T,
TState extends EntityState<T> = EntityState<T>,
> extends BaseStore<TState> {
// Computed selectors
readonly entities = computed(() => this._state().entities);
readonly selectedId = computed(() => this._state().selectedId);
readonly ids = computed(() => this._state().ids);
readonly selectedEntity = computed(() => {
const state = this._state();
return state.selectedId ? state.entities[state.selectedId] : null;
});
readonly entitiesArray = computed(() => {
const state = this._state();
return state.ids.map((id) => state.entities[id]).filter(Boolean);
});
/**
* Add or update entities
*/
protected upsertEntities(entities: T[], getId: (entity: T) => string | number): void {
const currentState = this._state();
const newEntities = { ...currentState.entities };
const newIds = [...currentState.ids];
entities.forEach((entity) => {
const id = getId(entity);
newEntities[id] = entity;
if (!newIds.includes(id)) {
newIds.push(id);
}
});
this.patchState({
entities: newEntities,
ids: newIds,
} as Partial<TState>);
}
/**
* Remove entity
*/
protected removeEntity(id: string | number): void {
const currentState = this._state();
const newEntities = { ...currentState.entities };
const newIds = currentState.ids.filter((entityId) => entityId !== id);
delete newEntities[id];
this.patchState({
entities: newEntities,
ids: newIds,
selectedId: currentState.selectedId === id ? null : currentState.selectedId,
} as Partial<TState>);
}
/**
* Select entity
*/
selectEntity(id: Maybe<string | number>): void {
this.patchState({
selectedId: id,
} as Partial<TState>);
}
/**
* Clear all entities
*/
protected clearEntities(): void {
this.patchState({
entities: {},
ids: [],
selectedId: null,
} as unknown as Partial<TState>);
}
}
/**
* Paginated store for lists with pagination
*/
export abstract class PaginatedStore<
T,
TState extends PaginatedState<T> = PaginatedState<T>,
> extends BaseStore<TState> {
// Computed selectors
readonly items = computed(() => this._state().items);
readonly totalCount = computed(() => this._state().totalCount);
readonly currentPage = computed(() => this._state().currentPage);
readonly pageSize = computed(() => this._state().pageSize);
readonly hasMore = computed(() => this._state().hasMore);
readonly totalPages = computed(() => {
const state = this._state();
return Math.ceil(state.totalCount / state.pageSize);
});
/**
* Set items for current page
*/
protected setItems(items: T[], totalCount: number, currentPage: number): void {
const state = this._state();
this.patchState({
items,
totalCount,
currentPage,
hasMore: currentPage * state.pageSize < totalCount,
loading: false,
error: null,
} as Partial<TState>);
}
/**
* Append items (for infinite scroll)
*/
protected appendItems(items: T[], totalCount: number): void {
const state = this._state();
const allItems = [...state.items, ...items];
const newPage = state.currentPage + 1;
this.patchState({
items: allItems,
totalCount,
currentPage: newPage,
hasMore: allItems.length < totalCount,
loading: false,
error: null,
} as Partial<TState>);
}
/**
* Set page size
*/
setPageSize(pageSize: number): void {
this.patchState({
pageSize,
currentPage: 1,
} as Partial<TState>);
}
/**
* Go to page
*/
setCurrentPage(page: number): void {
this.patchState({
currentPage: page,
} as Partial<TState>);
}
}
+337
View File
@@ -0,0 +1,337 @@
import { inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { BaseState, BaseStore } from '../base-store';
/**
* Global application state
*/
export interface GlobalState extends BaseState {
// UI State
sidebarCollapsed: boolean;
theme: 'light' | 'dark';
language: 'fa' | 'en';
// App State
isOnline: boolean;
notifications: AppNotification[];
breadcrumbs: Breadcrumb[];
// User preferences
preferences: UserPreferences;
}
/**
* Application notification
*/
export interface AppNotification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
timestamp: number;
read: boolean;
actions?: NotificationAction[];
}
/**
* Notification action
*/
export interface NotificationAction {
label: string;
action: () => void;
type?: 'primary' | 'default';
}
/**
* Breadcrumb item
*/
export interface Breadcrumb {
label: string;
url?: string;
icon?: string;
}
/**
* User preferences
*/
export interface UserPreferences {
dashboardLayout: 'grid' | 'list';
itemsPerPage: number;
autoSave: boolean;
followSystemTheme: boolean;
enableThemeTransitions: boolean;
notifications: {
email: boolean;
push: boolean;
sound: boolean;
};
accessibility: {
highContrast: boolean;
fontSize: 'small' | 'medium' | 'large';
reducedMotion: boolean;
};
}
/**
* Global state store
*/
@Injectable({
providedIn: 'root',
})
export class GlobalStore extends BaseStore<GlobalState> {
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
constructor() {
super({
loading: false,
error: null,
sidebarCollapsed: false,
theme: 'light',
language: 'fa',
isOnline: navigator.onLine,
notifications: [],
breadcrumbs: [],
preferences: {
dashboardLayout: 'grid',
itemsPerPage: 10,
autoSave: true,
followSystemTheme: false,
enableThemeTransitions: true,
notifications: {
email: true,
push: true,
sound: false,
},
accessibility: {
highContrast: false,
fontSize: 'medium',
reducedMotion: false,
},
},
});
this.initializeState();
}
// Computed selectors
readonly sidebarCollapsed = this.computed((state) => state.sidebarCollapsed);
readonly theme = this.computed((state) => state.theme);
readonly language = this.computed((state) => state.language);
readonly isOnline = this.computed((state) => state.isOnline);
readonly notifications = this.computed((state) => state.notifications);
readonly unreadNotifications = this.computed((state) =>
state.notifications.filter((n) => !n.read),
);
readonly breadcrumbs = this.computed((state) => state.breadcrumbs);
readonly preferences = this.computed((state) => state.preferences);
/**
* Initialize state from localStorage and browser events
*/
private initializeState(): void {
// Load preferences from localStorage
const savedPreferences = localStorage.getItem('user_preferences');
if (savedPreferences) {
try {
const preferences = JSON.parse(savedPreferences);
this.patchState({ preferences });
} catch (error) {
console.error('Error loading user preferences:', error);
}
}
// Load theme from localStorage
const savedTheme = localStorage.getItem('app_theme') as 'light' | 'dark';
if (savedTheme) {
this.patchState({ theme: savedTheme });
}
// Load sidebar state from localStorage
const sidebarCollapsed = localStorage.getItem('sidebar_collapsed') === 'true';
this.patchState({ sidebarCollapsed });
// Listen to online/offline events
window.addEventListener('online', () => this.setOnlineStatus(true));
window.addEventListener('offline', () => this.setOnlineStatus(false));
}
/**
* Helper method to create computed signals
*/
private computed<K>(selector: (state: GlobalState) => K) {
return () => selector(this._state());
}
/**
* Toggle sidebar collapse state
*/
toggleSidebar(): void {
const collapsed = !this._state().sidebarCollapsed;
this.patchState({ sidebarCollapsed: collapsed });
localStorage.setItem('sidebar_collapsed', collapsed.toString());
}
/**
* Set sidebar collapse state
*/
setSidebarCollapsed(collapsed: boolean): void {
this.patchState({ sidebarCollapsed: collapsed });
localStorage.setItem('sidebar_collapsed', collapsed.toString());
}
/**
* Toggle theme
*/
toggleTheme(): void {
const theme = this._state().theme === 'light' ? 'dark' : 'light';
this.setTheme(theme);
}
/**
* Set theme
*/
setTheme(theme: 'light' | 'dark'): void {
this.patchState({ theme });
localStorage.setItem('app_theme', theme);
// Apply theme to document
document.documentElement.setAttribute('data-theme', theme);
}
/**
* Set language
*/
setLanguage(language: 'fa' | 'en'): void {
this.patchState({ language });
localStorage.setItem('app_language', language);
}
/**
* Set online status
*/
setOnlineStatus(isOnline: boolean): void {
this.patchState({ isOnline });
if (isOnline) {
this.addNotification({
type: 'success',
title: 'اتصال برقرار شد',
message: 'اتصال اینترنت برقرار شد',
});
} else {
this.addNotification({
type: 'warning',
title: 'قطع اتصال',
message: 'اتصال اینترنت قطع شده است',
});
}
}
/**
* Add notification
*/
addNotification(notification: Omit<AppNotification, 'id' | 'timestamp' | 'read'>): void {
const newNotification: AppNotification = {
...notification,
id: this.generateId(),
timestamp: Date.now(),
read: false,
};
const notifications = [newNotification, ...this._state().notifications];
this.patchState({ notifications });
}
/**
* Mark notification as read
*/
markNotificationRead(id: string): void {
const notifications = this._state().notifications.map((n) =>
n.id === id ? { ...n, read: true } : n,
);
this.patchState({ notifications });
}
/**
* Mark all notifications as read
*/
markAllNotificationsRead(): void {
const notifications = this._state().notifications.map((n) => ({ ...n, read: true }));
this.patchState({ notifications });
}
/**
* Remove notification
*/
removeNotification(id: string): void {
const notifications = this._state().notifications.filter((n) => n.id !== id);
this.patchState({ notifications });
}
/**
* Clear all notifications
*/
clearNotifications(): void {
this.patchState({ notifications: [] });
}
/**
* Set breadcrumbs
*/
setBreadcrumbs(breadcrumbs: Breadcrumb[]): void {
this.patchState({ breadcrumbs });
}
/**
* Update user preferences
*/
updatePreferences(preferences: Partial<UserPreferences>): void {
const currentPreferences = this._state().preferences;
const newPreferences = { ...currentPreferences, ...preferences };
this.patchState({ preferences: newPreferences });
localStorage.setItem('user_preferences', JSON.stringify(newPreferences));
}
/**
* Reset state to initial values
*/
reset(): void {
this.setState({
loading: false,
error: null,
sidebarCollapsed: false,
theme: 'light',
language: 'fa',
isOnline: navigator.onLine,
notifications: [],
breadcrumbs: [],
preferences: {
dashboardLayout: 'grid',
itemsPerPage: 10,
autoSave: true,
followSystemTheme: false,
enableThemeTransitions: true,
notifications: {
email: true,
push: true,
sound: false,
},
accessibility: {
highContrast: false,
fontSize: 'medium',
reducedMotion: false,
},
},
});
}
/**
* Generate unique ID
*/
private generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
}
+26
View File
@@ -0,0 +1,26 @@
// Base store classes and utilities
export * from './base-store';
// Global state management
export * from './global/global.store';
// State effects and utilities
export * from './state-effects.service';
// State management interfaces
export type {
Action,
BaseState,
EntityState,
LoadingState,
PaginatedState,
StateChange,
} from './base-store';
export type {
AppNotification,
Breadcrumb,
GlobalState,
NotificationAction,
UserPreferences,
} from './global/global.store';
+326
View File
@@ -0,0 +1,326 @@
import { Injectable, inject } from '@angular/core';
import { combineLatest, map, shareReplay, startWith } from 'rxjs';
import { LOCAL_STORAGE_KEYS } from 'src/assets/constants';
import { AuthStore } from '../../modules/auth/state/auth.store';
import { GlobalStore } from './global/global.store';
/**
* State effects service for handling cross-store interactions
*/
@Injectable({
providedIn: 'root',
})
export class StateEffects {
private readonly authStore = inject(AuthStore);
private readonly globalStore = inject(GlobalStore);
constructor() {
this.initializeEffects();
}
/**
* Initialize all effects
*/
private initializeEffects(): void {
this.setupAuthEffects();
this.setupGlobalEffects();
}
/**
* Setup authentication-related effects
*/
private setupAuthEffects(): void {
// Update global notifications when auth state changes
this.authStore.state$.subscribe((authState) => {
// if (authState.isAuthenticated && authState.user) {
// this.globalStore.addNotification({
// type: 'success',
// title: 'خوش آمدید',
// message: `${authState.user.firstName} ${authState.user.lastName} عزیز، خوش آمدید`,
// });
// }
});
// Handle session expiry warnings
this.authStore.state$.subscribe((authState) => {
if (authState.sessionExpiry) {
const timeUntilExpiry = authState.sessionExpiry - Date.now();
const minutesUntilExpiry = Math.floor(timeUntilExpiry / 60000);
// Show warning when 5 minutes remain
if (minutesUntilExpiry === 5) {
this.globalStore.addNotification({
type: 'warning',
title: 'هشدار انقضای جلسه',
message: 'جلسه شما در 5 دقیقه منقضی می‌شود',
actions: [
{
label: 'تمدید جلسه',
action: () => this.authStore.refreshAuthToken().subscribe(),
type: 'primary',
},
],
});
}
}
});
}
/**
* Setup global effects
*/
private setupGlobalEffects(): void {
// Auto-save preferences when they change
this.globalStore.state$.subscribe((globalState) => {
localStorage.setItem('user_preferences', JSON.stringify(globalState.preferences));
});
// Handle theme changes
this.globalStore.state$.subscribe((globalState) => {
document.documentElement.setAttribute('data-theme', globalState.theme);
});
// Handle language changes
this.globalStore.state$.subscribe((globalState) => {
document.documentElement.setAttribute('lang', globalState.language);
document.documentElement.setAttribute('dir', globalState.language === 'fa' ? 'rtl' : 'ltr');
});
}
}
/**
* State selectors for complex cross-store queries
*/
@Injectable({
providedIn: 'root',
})
export class StateSelectors {
private readonly authStore = inject(AuthStore);
private readonly globalStore = inject(GlobalStore);
/**
* Combined application state for UI components
*/
readonly appState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
map(([authState, globalState]) => ({
isAuthenticated: authState.isAuthenticated,
user: authState.user,
userRole: authState.user?.role,
loading: authState.loading || globalState.loading,
sidebarCollapsed: globalState.sidebarCollapsed,
theme: globalState.theme,
language: globalState.language,
notifications: globalState.notifications,
unreadCount: globalState.notifications.filter((n) => !n.read).length,
breadcrumbs: globalState.breadcrumbs,
isOnline: globalState.isOnline,
})),
shareReplay(1),
);
/**
* User permissions combined with role-based access
*/
readonly userAccess$ = this.authStore.state$.pipe(
map((authState) => ({
isAuthenticated: authState.isAuthenticated,
role: authState.user?.role,
permissions: authState.permissions,
canAccessAdmin: authState.user?.role === 'ADMIN' || authState.user?.role === 'SUPERADMIN',
canAccessStudent: authState.user?.role === 'STUDENTS',
canAccessGrader: authState.user?.role === 'GRADER',
canAccessSchools: authState.user?.role === 'SCHOOL',
canAccessSuperAdmin: authState.user?.role === 'SUPERADMIN',
})),
shareReplay(1),
);
/**
* Navigation state for header/sidebar components
*/
readonly navigationState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
map(([authState, globalState]) => ({
user: authState.user,
sidebarCollapsed: globalState.sidebarCollapsed,
breadcrumbs: globalState.breadcrumbs,
// userInitials: authState.user
// ? `${authState.user.firstName[0]}${authState.user.lastName[0]}`
// : '',
// userFullName: authState.user ? `${authState.user.firstName} ${authState.user.lastName}` : '',
})),
shareReplay(1),
);
/**
* Notification state for notification components
*/
readonly notificationState$ = this.globalStore.state$.pipe(
map((globalState) => ({
notifications: globalState.notifications,
unreadCount: globalState.notifications.filter((n) => !n.read).length,
hasUnread: globalState.notifications.some((n) => !n.read),
recentNotifications: globalState.notifications
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 5),
})),
shareReplay(1),
);
/**
* Loading state across all stores
*/
readonly loadingState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
map(([authState, globalState]) => ({
isLoading: authState.loading || globalState.loading,
authLoading: authState.loading,
globalLoading: globalState.loading,
})),
startWith({ isLoading: false, authLoading: false, globalLoading: false }),
shareReplay(1),
);
}
/**
* State utilities for common operations
*/
@Injectable({
providedIn: 'root',
})
export class StateUtils {
private readonly authStore = inject(AuthStore);
private readonly globalStore = inject(GlobalStore);
/**
* Check if user can access route
*/
canAccessRoute(requiredRoles?: string[], requiredPermissions?: string[]): boolean {
const authState = this.authStore.getCurrentState();
if (!authState.isAuthenticated) {
return false;
}
if (requiredRoles && requiredRoles.length > 0) {
const userRole = authState.user?.role;
if (!userRole || !requiredRoles.includes(userRole)) {
return false;
}
}
if (requiredPermissions && requiredPermissions.length > 0) {
const hasAllPermissions = requiredPermissions.every((permission) =>
authState.permissions.includes(permission),
);
if (!hasAllPermissions) {
return false;
}
}
return true;
}
/**
* Show success message
*/
showSuccess(title: string, message: string): void {
this.globalStore.addNotification({
type: 'success',
title,
message,
});
}
/**
* Show error message
*/
showError(title: string, message: string): void {
this.globalStore.addNotification({
type: 'error',
title,
message,
});
}
/**
* Show warning message
*/
showWarning(title: string, message: string): void {
this.globalStore.addNotification({
type: 'warning',
title,
message,
});
}
/**
* Show info message
*/
showInfo(title: string, message: string): void {
this.globalStore.addNotification({
type: 'info',
title,
message,
});
}
/**
* Update page breadcrumbs
*/
setBreadcrumbs(breadcrumbs: Array<{ label: string; url?: string; icon?: string }>): void {
this.globalStore.setBreadcrumbs(breadcrumbs);
}
/**
* Get user display name
*/
getUserDisplayName(): string {
const user = this.authStore.getCurrentState().user;
return '';
// return user ? `${user.firstName} ${user.lastName}` : '';
}
/**
* Get user initials
*/
getUserInitials(): string {
const user = this.authStore.getCurrentState().user;
return '';
// return user ? `${user.firstName[0]}${user.lastName[0]}` : '';
}
/**
* Check if user is online
*/
isOnline(): boolean {
return this.globalStore.getCurrentState().isOnline;
}
/**
* Get current theme
*/
getCurrentTheme(): 'light' | 'dark' {
return this.globalStore.getCurrentState().theme;
}
/**
* Get current language
*/
getCurrentLanguage(): 'fa' | 'en' {
return this.globalStore.getCurrentState().language;
}
/**
* Clear all application state (useful for logout)
*/
clearAllState(): void {
this.authStore.reset();
this.globalStore.reset();
// Clear localStorage
localStorage.removeItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
localStorage.removeItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(LOCAL_STORAGE_KEYS.USER_DATA);
localStorage.removeItem(LOCAL_STORAGE_KEYS.LAST_LOGIN_TIME);
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
function normalizeIban(input: string): string {
return input.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
}
function ibanChecksum(iban: string): boolean {
// Move first four chars to the end
const rearranged = iban.slice(4) + iban.slice(0, 4);
// Replace letters with numbers: A=10, B=11, ... Z=35
let converted = '';
for (let i = 0; i < rearranged.length; i++) {
const ch = rearranged.charAt(i);
if (/[A-Z]/.test(ch)) {
converted += (ch.charCodeAt(0) - 55).toString();
} else {
converted += ch;
}
}
// Compute mod 97 using chunking to avoid big integers
let remainder = 0;
const chunkSize = 9; // safe chunk length for JS numbers
for (let offset = 0; offset < converted.length; offset += chunkSize) {
const part = remainder.toString() + converted.substr(offset, chunkSize);
remainder = parseInt(part, 10) % 97;
}
return remainder === 1;
}
/**
* Iran-only IBAN (Sheba) validator.
* Accepts values that normalize to a valid IR IBAN (country code IR, length 26,
* and valid MOD-97 checksum).
*/
export function iranIbanValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (value == null || value === '') return null;
if (typeof value !== 'string') return { iban: true };
const cleaned = normalizeIban(value);
// IR IBAN total length is 26
if (cleaned.length !== 26) return { iban: true };
if (!cleaned.startsWith('IR')) return { iban: true };
// Basic pattern check: IR + 2 digits + rest alnum
if (!/^IR[0-9]{2}[A-Z0-9]+$/.test(cleaned)) return { iban: true };
try {
const ok = ibanChecksum(cleaned);
return ok ? null : { iban: true };
} catch (e) {
return { iban: true };
}
};
}
export default iranIbanValidator;
+5
View File
@@ -0,0 +1,5 @@
export * from './iban.validator';
export * from './mobile.validator';
export * from './must-match.validator';
export * from './password.validator';
export * from './postal-code.validator';
@@ -0,0 +1,30 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
/**
* Validator for mobile phone numbers.
*
* Behavior/assumptions:
* - Allows common Iranian mobile formats:
* - 09xxxxxxxxx (leading 0, total 11 digits)
* - +989xxxxxxxxx (international +98 followed by 9 and 9 digits)
* - 9xxxxxxxxx (no leading 0, 10 digits)
* - Treats empty / null / undefined values as valid (use Validators.required when needed).
* - Returns `{ invalidMobile: true }` when the value does not match the pattern.
*/
export function mobileValidator(): ValidatorFn {
const re = /^(?:(?:\+98)|0)?9\d{9}$/;
return (control: AbstractControl): ValidationErrors | null => {
const v = control.value;
if (v === null || v === undefined || v === '') {
return null; // leave required validation to Validators.required
}
if (typeof v !== 'string') {
return { invalidMobile: true };
}
const trimmed = v.trim();
return re.test(trimmed) ? null : { invalidMobile: true };
};
}
@@ -0,0 +1,31 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Reusable validator that sets a 'mismatch' error on the matching control when values differ.
export function MustMatch(controlName: string, matchingControlName: string): ValidatorFn {
return (formGroup: AbstractControl): ValidationErrors | null => {
const control = formGroup.get(controlName);
const matchingControl = formGroup.get(matchingControlName);
if (!control || !matchingControl) return null;
// If another validator has already found an error on the matching control, don't overwrite it.
const existingErrors = matchingControl.errors ? { ...matchingControl.errors } : null;
if (existingErrors && !existingErrors['mismatch']) {
return null;
}
if (control.value !== matchingControl.value) {
matchingControl.setErrors({ ...(existingErrors ?? {}), mismatch: true });
} else {
if (existingErrors) {
delete existingErrors['mismatch'];
const remaining = Object.keys(existingErrors).length ? existingErrors : null;
matchingControl.setErrors(remaining as any);
} else {
matchingControl.setErrors(null);
}
}
return null;
};
}
@@ -0,0 +1,15 @@
import { ValidatorFn } from '@angular/forms';
// Password must be minimum 8 characters, include at least one uppercase,
// one lowercase, one number and one special character
export const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
// Validator factory named `password` as requested. Returns `null` for empty
// values so `Validators.required` can be used alongside it when needed.
export function password(): ValidatorFn {
return (control) => {
const value = control?.value;
if (value === null || value === undefined || String(value).length === 0) return null;
return PASSWORD_PATTERN.test(String(value)) ? null : { password: 'معتبر نیست' };
};
}
@@ -0,0 +1,12 @@
import { ValidatorFn } from '@angular/forms';
export function postalCodeValidator(): ValidatorFn {
return (control) => {
if (control.value === null || control.value === undefined || control.value === '') {
return null;
}
return control.value.length === 10 && /^[0-9]{10}$/.test(control.value)
? null
: { postalCode: true };
};
}