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
+28 -3
View File
@@ -1,16 +1,41 @@
# Editor configuration, see https://editorconfig.org # Editor configuration, see https://editorconfig.org
root = true root = true
# All files
[*] [*]
charset = utf-8 charset = utf-8
indent_style = space end_of_line = lf
indent_size = 4
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.ts] # TypeScript, JavaScript
[*.{ts,js}]
indent_size = 2
max_line_length = 100
quote_type = single quote_type = single
ij_typescript_use_double_quotes = false
# HTML
[*.html]
indent_size = 2
max_line_length = 120
# CSS, SCSS, LESS
[*.{css,scss,less}]
indent_size = 2
max_line_length = 120
# JSON
[*.json]
indent_size = 2
# Markdown
[*.md] [*.md]
max_line_length = off max_line_length = off
trim_trailing_whitespace = false trim_trailing_whitespace = false
# Package files
[{package.json,*.lock}]
indent_size = 2
+28 -13
View File
@@ -1,29 +1,44 @@
{ {
"useTabs": false, "arrowParens": "always",
"tabWidth": 4,
"trailingComma": "none",
"semi": true,
"singleQuote": true,
"printWidth": 250,
"bracketSameLine": false, "bracketSameLine": false,
"bracketSpacing": true,
"endOfLine": "lf",
"overrides": [ "overrides": [
{ {
"files": ["*.ts", "*.mts", "*.d.ts"], "files": "*.html",
"options": { "options": {
"parser": "typescript" "bracketSameLine": true,
"printWidth": 120
} }
}, },
{ {
"files": ["*.html"], "files": "*.scss",
"options": { "options": {
"parser": "html" "printWidth": 120,
"singleQuote": false
} }
}, },
{ {
"files": ["*.component.html"], "files": "*.less",
"options": { "options": {
"parser": "angular" "printWidth": 120,
"singleQuote": false
}
},
{
"files": "*.json",
"options": {
"printWidth": 100
} }
} }
] ],
"plugins": [
"prettier-plugin-organize-imports"
],
"printWidth": 100,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false
} }
+108 -67
View File
@@ -1,93 +1,134 @@
{ {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "cli": {
"analytics": false
},
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"sakai-ng": { "pos.client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular/build:application",
"options": {
"outputPath": "dist/sakai-ng",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/assets/styles.scss"
],
"scripts": []
},
"configurations": { "configurations": {
"development": {
"extractLicenses": false,
"optimization": false,
"sourceMap": true
},
"production": { "production": {
"budgets": [
{
"maximumError": "1MB",
"maximumWarning": "500kB",
"type": "initial"
},
{
"maximumError": "8kB",
"maximumWarning": "4kB",
"type": "anyComponentStyle"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "staging": {
"optimization": false, "extractLicenses": true,
"extractLicenses": false, "fileReplacements": [
"sourceMap": true {
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.staging.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production",
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "sakai-ng:build:production"
},
"development": {
"buildTarget": "sakai-ng:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
} }
], ],
"browser": "src/main.ts",
"fileReplacements": [],
"inlineStyleLanguage": "scss",
"loader": {
".jpg": "file",
".png": "file",
".svg": "text",
".webp": "file"
},
"styles": [ "styles": [
"src/assets/styles.scss" "src/assets/styles.scss"
], ],
"scripts": [] "tsConfig": "tsconfig.app.json"
}
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"development": {
"buildTarget": "pos.client:build:development"
},
"production": {
"buildTarget": "pos.client:build:production"
},
"staging": {
"buildTarget": "pos.client:build:staging"
}
},
"defaultConfiguration": "development",
"options": {
"port": 3001,
"proxyConfig": "src/proxy.conf.js"
}
},
"test": {
"builder": "@angular/build:karma",
"options": {
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"inlineStyleLanguage": "scss",
"karmaConfig": "karma.conf.js",
"styles": [
"src/assets/styles.scss"
],
"tsConfig": "tsconfig.spec.json"
} }
} }
} },
"prefix": "app",
"projectType": "application",
"root": "",
"schematics": {
"@schematics/angular:component": {
"standalone": false,
"style": "scss"
},
"@schematics/angular:directive": {
"standalone": false
},
"@schematics/angular:pipe": {
"standalone": false
}
},
"sourceRoot": "src"
} }
}, },
"cli": { "version": 1
"analytics": false }
}
}
+31 -14
View File
@@ -1,15 +1,4 @@
{ {
"name": "sakai-ng",
"version": "20.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"format": "prettier --write \"**/*.{js,mjs,ts,mts,d.ts,html}\" --cache",
"test": "ng test"
},
"private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^20", "@angular/animations": "^20",
"@angular/common": "^20", "@angular/common": "^20",
@@ -19,19 +8,24 @@
"@angular/platform-browser": "^20", "@angular/platform-browser": "^20",
"@angular/platform-browser-dynamic": "^20", "@angular/platform-browser-dynamic": "^20",
"@angular/router": "^20", "@angular/router": "^20",
"@primeng/themes": "^20.2.0",
"@primeuix/themes": "^1.2.1", "@primeuix/themes": "^1.2.1",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"chart.js": "4.4.2", "chart.js": "4.4.2",
"flatpickr": "^4.6.13",
"flatpickr-wrap": "^1.0.0",
"jest-editor-support": "*",
"primeclt": "^0.1.5", "primeclt": "^0.1.5",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primeng": "^20", "primeng": "^20",
"run-script-os": "*",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
"tailwindcss-primeui": "^0.6.1", "tailwindcss-primeui": "^0.6.1",
"tslib": "^2.8.1", "tslib": "^2.8.1"
"zone.js": "~0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^20", "@angular-devkit/build-angular": "^20",
"@angular/build": "^20.1.4",
"@angular/cli": "^20", "@angular/cli": "^20",
"@angular/compiler-cli": "^20", "@angular/compiler-cli": "^20",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
@@ -49,7 +43,30 @@
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "~5.8.3" "typescript": "~5.8.3"
} },
"name": "pos.client",
"prettier": {
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"scripts": {
"build": "ng build",
"ng": "ng",
"prestart": "node aspnetcore-https",
"start": "run-script-os",
"test": "ng test",
"watch": "ng build --watch --configuration development"
},
"version": "0.0.0"
} }
+12918
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,10 +1,16 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { ToastModule } from 'primeng/toast';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterModule], imports: [RouterModule, ToastModule],
template: `<router-outlet></router-outlet>` template: `
<div>
<p-toast position="bottom-right" />
<router-outlet />
</div>
`,
}) })
export class AppComponent {} export class AppComponent {}
+70 -10
View File
@@ -1,16 +1,76 @@
import { provideHttpClient, withFetch } from '@angular/common/http'; import {
import { ApplicationConfig } from '@angular/core'; ApiBaseUrlInterceptor,
authInterceptor,
dedupInterceptor,
errorInterceptor,
loggingInterceptor,
} from '@/core/interceptors';
import {
HTTP_INTERCEPTORS,
provideHttpClient,
withFetch,
withInterceptors,
withInterceptorsFromDi,
} from '@angular/common/http';
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router'; import {
import Aura from '@primeuix/themes/aura'; provideRouter,
withEnabledBlockingInitialNavigation,
withInMemoryScrolling,
} from '@angular/router';
// Use the consolidated preset that includes our custom variables
import { MessageService } from 'primeng/api';
import { providePrimeNG } from 'primeng/config'; import { providePrimeNG } from 'primeng/config';
import { appRoutes } from './app.routes'; import { appRoutes } from './app.routes';
import MyPreset from './presets';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideRouter(appRoutes, withInMemoryScrolling({ anchorScrolling: 'enabled', scrollPositionRestoration: 'enabled' }), withEnabledBlockingInitialNavigation()), provideRouter(
provideHttpClient(withFetch()), appRoutes,
provideAnimationsAsync(), withInMemoryScrolling({
providePrimeNG({ theme: { preset: Aura, options: { darkModeSelector: '.app-dark' } } }) anchorScrolling: 'enabled',
] scrollPositionRestoration: 'enabled',
}),
withEnabledBlockingInitialNavigation(),
),
provideZonelessChangeDetection(),
// configure HttpClient once: enable fetch and register both functional
// interceptors and legacy (DI-provided) class-based interceptors
provideAnimationsAsync(),
// ensure PrimeNG uses our preset (applies css variables at app initialization)
providePrimeNG({
theme: {
preset: MyPreset,
options: { darkModeSelector: '.app-dark' },
},
translation: {
emptySelectionMessage: 'هیچ موردی انتخاب نشده است',
emptyMessage: 'هیچ داده‌ای برای نمایش وجود ندارد',
emptyFilterMessage: 'هیچ موردی برای نمایش وجود ندارد',
emptySearchMessage: 'هیچ موردی برای نمایش وجود ندارد',
accept: 'تایید',
reject: 'رد کردن',
cancel: 'لغو',
noFileChosenMessage: 'هیچ فایلی انتخاب نشده است',
fileChosenMessage: 'انتخاب فایل',
selectionMessage: '{0} مورد انتخاب شده است',
},
}),
{
provide: HTTP_INTERCEPTORS,
useClass: ApiBaseUrlInterceptor,
multi: true,
},
MessageService,
provideHttpClient(
withFetch(),
withInterceptors([loggingInterceptor, authInterceptor, errorInterceptor, dedupInterceptor]),
withInterceptorsFromDi(),
),
],
}; };
@@ -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 };
};
}
+106 -78
View File
@@ -1,92 +1,120 @@
import { Component } from '@angular/core';
import { MenuItem } from 'primeng/api';
import { RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { MenuItem } from 'primeng/api';
import { StyleClassModule } from 'primeng/styleclass'; import { StyleClassModule } from 'primeng/styleclass';
import { AppConfigurator } from './app.configurator';
import { LayoutService } from '../service/layout.service'; import { LayoutService } from '../service/layout.service';
import { AppConfigurator } from './app.configurator';
@Component({ @Component({
selector: 'app-topbar', selector: 'app-topbar',
standalone: true, standalone: true,
imports: [RouterModule, CommonModule, StyleClassModule, AppConfigurator], imports: [RouterModule, CommonModule, StyleClassModule, AppConfigurator],
template: ` <div class="layout-topbar"> template: ` <div class="layout-topbar">
<div class="layout-topbar-logo-container"> <div class="layout-topbar-logo-container">
<button class="layout-menu-button layout-topbar-action" (click)="layoutService.onMenuToggle()"> <button
<i class="pi pi-bars"></i> class="layout-menu-button layout-topbar-action"
</button> (click)="layoutService.onMenuToggle()"
<a class="layout-topbar-logo" routerLink="/"> >
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg"> <i class="pi pi-bars"></i>
<path </button>
fill-rule="evenodd" <a class="layout-topbar-logo" routerLink="/">
clip-rule="evenodd" <svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z" <path
fill="var(--primary-color)" fill-rule="evenodd"
/> clip-rule="evenodd"
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11"> d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z"
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" /> fill="var(--primary-color)"
</mask> />
<g mask="url(#mask0_1413_1551)"> <mask
<path id="mask0_1413_1551"
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z" style="mask-type: alpha"
fill="var(--primary-color)" maskUnits="userSpaceOnUse"
/> x="0"
</g> y="8"
</svg> width="54"
<span>SAKAI</span> height="11"
</a> >
<path
d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z"
fill="var(--primary-color)"
/>
</mask>
<g mask="url(#mask0_1413_1551)">
<path
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z"
fill="var(--primary-color)"
/>
</g>
</svg>
<span>SAKAI</span>
</a>
</div>
<div class="layout-topbar-actions">
<div class="layout-config-menu">
<button type="button" class="layout-topbar-action" (click)="toggleDarkMode()">
<i
[ngClass]="{
'pi ': true,
'pi-moon': layoutService.isDarkTheme(),
'pi-sun': !layoutService.isDarkTheme(),
}"
></i>
</button>
<div class="relative">
<button
class="layout-topbar-action layout-topbar-action-highlight"
pStyleClass="@next"
enterFromClass="hidden"
enterActiveClass="animate-scalein"
leaveToClass="hidden"
leaveActiveClass="animate-fadeout"
[hideOnOutsideClick]="true"
>
<i class="pi pi-palette"></i>
</button>
<app-configurator />
</div> </div>
</div>
<div class="layout-topbar-actions"> <button
<div class="layout-config-menu"> class="layout-topbar-menu-button layout-topbar-action"
<button type="button" class="layout-topbar-action" (click)="toggleDarkMode()"> pStyleClass="@next"
<i [ngClass]="{ 'pi ': true, 'pi-moon': layoutService.isDarkTheme(), 'pi-sun': !layoutService.isDarkTheme() }"></i> enterFromClass="hidden"
</button> enterActiveClass="animate-scalein"
<div class="relative"> leaveToClass="hidden"
<button leaveActiveClass="animate-fadeout"
class="layout-topbar-action layout-topbar-action-highlight" [hideOnOutsideClick]="true"
pStyleClass="@next" >
enterFromClass="hidden" <i class="pi pi-ellipsis-v"></i>
enterActiveClass="animate-scalein" </button>
leaveToClass="hidden"
leaveActiveClass="animate-fadeout"
[hideOnOutsideClick]="true"
>
<i class="pi pi-palette"></i>
</button>
<app-configurator />
</div>
</div>
<button class="layout-topbar-menu-button layout-topbar-action" pStyleClass="@next" enterFromClass="hidden" enterActiveClass="animate-scalein" leaveToClass="hidden" leaveActiveClass="animate-fadeout" [hideOnOutsideClick]="true"> <div class="layout-topbar-menu hidden lg:block">
<i class="pi pi-ellipsis-v"></i> <div class="layout-topbar-menu-content">
</button> <button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i>
<div class="layout-topbar-menu hidden lg:block"> <span>Calendar</span>
<div class="layout-topbar-menu-content"> </button>
<button type="button" class="layout-topbar-action"> <button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i> <i class="pi pi-inbox"></i>
<span>Calendar</span> <span>Messages</span>
</button> </button>
<button type="button" class="layout-topbar-action"> <button type="button" class="layout-topbar-action">
<i class="pi pi-inbox"></i> <i class="pi pi-user"></i>
<span>Messages</span> <span>Profile</span>
</button> </button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-user"></i>
<span>Profile</span>
</button>
</div>
</div>
</div> </div>
</div>` </div>
</div>
</div>`,
}) })
export class AppTopbar { export class AppTopbar {
items!: MenuItem[]; items!: MenuItem[];
constructor(public layoutService: LayoutService) {} constructor(public layoutService: LayoutService) {}
toggleDarkMode() { toggleDarkMode() {
this.layoutService.layoutConfig.update((state) => ({ ...state, darkTheme: !state.darkTheme })); this.layoutService.layoutConfig.update((state) => ({ ...state, darkTheme: !state.darkTheme }));
} }
} }
+143 -132
View File
@@ -1,178 +1,189 @@
import { Injectable, effect, signal, computed } from '@angular/core'; import { Injectable, computed, effect, signal } from '@angular/core';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
export interface layoutConfig { export interface layoutConfig {
preset?: string; preset?: string;
primary?: string; primary?: string;
surface?: string | undefined | null; surface?: string | undefined | null;
darkTheme?: boolean; darkTheme?: boolean;
menuMode?: string; menuMode?: string;
} }
interface LayoutState { interface LayoutState {
staticMenuDesktopInactive?: boolean; staticMenuDesktopInactive?: boolean;
overlayMenuActive?: boolean; overlayMenuActive?: boolean;
configSidebarVisible?: boolean; configSidebarVisible?: boolean;
staticMenuMobileActive?: boolean; staticMenuMobileActive?: boolean;
menuHoverActive?: boolean; menuHoverActive?: boolean;
} }
interface MenuChangeEvent { interface MenuChangeEvent {
key: string; key: string;
routeEvent?: boolean; routeEvent?: boolean;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class LayoutService { export class LayoutService {
_config: layoutConfig = { _config: layoutConfig = {
preset: 'Aura', preset: 'Aura',
primary: 'emerald', primary: 'violet',
surface: null, surface: null,
darkTheme: false, darkTheme: localStorage.getItem('isDarkTheme') === 'true',
menuMode: 'static' menuMode: 'static',
}; };
_state: LayoutState = { _state: LayoutState = {
staticMenuDesktopInactive: false, staticMenuDesktopInactive: false,
overlayMenuActive: false, overlayMenuActive: false,
configSidebarVisible: false, configSidebarVisible: false,
staticMenuMobileActive: false, staticMenuMobileActive: false,
menuHoverActive: false menuHoverActive: false,
}; };
layoutConfig = signal<layoutConfig>(this._config); layoutConfig = signal<layoutConfig>(this._config);
layoutState = signal<LayoutState>(this._state); layoutState = signal<LayoutState>(this._state);
private configUpdate = new Subject<layoutConfig>(); private configUpdate = new Subject<layoutConfig>();
private overlayOpen = new Subject<any>(); private overlayOpen = new Subject<any>();
private menuSource = new Subject<MenuChangeEvent>(); private menuSource = new Subject<MenuChangeEvent>();
private resetSource = new Subject(); private resetSource = new Subject();
menuSource$ = this.menuSource.asObservable(); menuSource$ = this.menuSource.asObservable();
resetSource$ = this.resetSource.asObservable(); resetSource$ = this.resetSource.asObservable();
configUpdate$ = this.configUpdate.asObservable(); configUpdate$ = this.configUpdate.asObservable();
overlayOpen$ = this.overlayOpen.asObservable(); overlayOpen$ = this.overlayOpen.asObservable();
theme = computed(() => (this.layoutConfig()?.darkTheme ? 'light' : 'dark')); theme = computed(() => (this.layoutConfig()?.darkTheme ? 'light' : 'dark'));
isSidebarActive = computed(() => this.layoutState().overlayMenuActive || this.layoutState().staticMenuMobileActive); isSidebarActive = computed(
() => this.layoutState().overlayMenuActive || this.layoutState().staticMenuMobileActive,
);
isDarkTheme = computed(() => this.layoutConfig().darkTheme); isDarkTheme = computed(() => this.layoutConfig().darkTheme);
getPrimary = computed(() => this.layoutConfig().primary); getPrimary = computed(() => this.layoutConfig().primary);
getSurface = computed(() => this.layoutConfig().surface); getSurface = computed(() => this.layoutConfig().surface);
isOverlay = computed(() => this.layoutConfig().menuMode === 'overlay'); isOverlay = computed(() => this.layoutConfig().menuMode === 'overlay');
transitionComplete = signal<boolean>(false); transitionComplete = signal<boolean>(false);
private initialized = false; private initialized = false;
constructor() { constructor() {
effect(() => { effect(() => {
const config = this.layoutConfig(); const config = this.layoutConfig();
if (config) { if (config) {
this.onConfigUpdate(); this.onConfigUpdate();
} }
}); });
effect(() => { effect(() => {
const config = this.layoutConfig(); const config = this.layoutConfig();
if (!this.initialized || !config) { if (!this.initialized || !config) {
this.initialized = true; this.initialized = true;
return; return;
} }
this.handleDarkModeTransition(config); this.handleDarkModeTransition(config);
}); });
}
private handleDarkModeTransition(config: layoutConfig): void {
if ((document as any).startViewTransition) {
this.startViewTransition(config);
} else {
this.toggleDarkMode(config);
this.onTransitionEnd();
}
}
private startViewTransition(config: layoutConfig): void {
const transition = (document as any).startViewTransition(() => {
this.toggleDarkMode(config);
});
transition.ready
.then(() => {
this.onTransitionEnd();
})
.catch(() => {});
}
toggleDarkMode(config?: layoutConfig): void {
const _config = config || this.layoutConfig();
if (_config.darkTheme) {
document.documentElement.classList.add('app-dark');
} else {
document.documentElement.classList.remove('app-dark');
}
}
private onTransitionEnd() {
this.transitionComplete.set(true);
setTimeout(() => {
this.transitionComplete.set(false);
});
}
onMenuToggle() {
if (this.isOverlay()) {
this.layoutState.update((prev) => ({
...prev,
overlayMenuActive: !this.layoutState().overlayMenuActive,
}));
if (this.layoutState().overlayMenuActive) {
this.overlayOpen.next(null);
}
} }
private handleDarkModeTransition(config: layoutConfig): void { if (this.isDesktop()) {
if ((document as any).startViewTransition) { this.layoutState.update((prev) => ({
this.startViewTransition(config); ...prev,
} else { staticMenuDesktopInactive: !this.layoutState().staticMenuDesktopInactive,
this.toggleDarkMode(config); }));
this.onTransitionEnd(); } else {
} this.layoutState.update((prev) => ({
...prev,
staticMenuMobileActive: !this.layoutState().staticMenuMobileActive,
}));
if (this.layoutState().staticMenuMobileActive) {
this.overlayOpen.next(null);
}
} }
}
private startViewTransition(config: layoutConfig): void { isDesktop() {
const transition = (document as any).startViewTransition(() => { return window.innerWidth > 991;
this.toggleDarkMode(config); }
});
transition.ready isMobile() {
.then(() => { return !this.isDesktop();
this.onTransitionEnd(); }
})
.catch(() => {});
}
toggleDarkMode(config?: layoutConfig): void { onConfigUpdate() {
const _config = config || this.layoutConfig(); this._config = { ...this.layoutConfig() };
if (_config.darkTheme) { this.configUpdate.next(this.layoutConfig());
document.documentElement.classList.add('app-dark'); }
} else {
document.documentElement.classList.remove('app-dark');
}
}
private onTransitionEnd() { onMenuStateChange(event: MenuChangeEvent) {
this.transitionComplete.set(true); this.menuSource.next(event);
setTimeout(() => { }
this.transitionComplete.set(false);
});
}
onMenuToggle() { reset() {
if (this.isOverlay()) { this.resetSource.next(true);
this.layoutState.update((prev) => ({ ...prev, overlayMenuActive: !this.layoutState().overlayMenuActive })); }
if (this.layoutState().overlayMenuActive) {
this.overlayOpen.next(null);
}
}
if (this.isDesktop()) {
this.layoutState.update((prev) => ({ ...prev, staticMenuDesktopInactive: !this.layoutState().staticMenuDesktopInactive }));
} else {
this.layoutState.update((prev) => ({ ...prev, staticMenuMobileActive: !this.layoutState().staticMenuMobileActive }));
if (this.layoutState().staticMenuMobileActive) {
this.overlayOpen.next(null);
}
}
}
isDesktop() {
return window.innerWidth > 991;
}
isMobile() {
return !this.isDesktop();
}
onConfigUpdate() {
this._config = { ...this.layoutConfig() };
this.configUpdate.next(this.layoutConfig());
}
onMenuStateChange(event: MenuChangeEvent) {
this.menuSource.next(event);
}
reset() {
this.resetSource.next(true);
}
} }
+19
View File
@@ -0,0 +1,19 @@
import { NamedRoutes } from '@/core';
import { Routes } from '@angular/router';
export type AuthRouteNames = 'auth';
export const authNamedRoutes: NamedRoutes<AuthRouteNames> = {
auth: {
path: '',
loadComponent: () => import('./pages/auth.component').then((m) => m.AuthComponent),
meta: { title: 'احراز هویت' },
},
};
export const AUTH_ROUTES: Routes = [
{
...authNamedRoutes.auth,
// children: [authNamedRoutes.login],
},
];
@@ -0,0 +1,5 @@
import rolesConst from '@/core/constants/roles.const';
export const CENTRAL_AUTH_ROLES = [
...Object.values(rolesConst).filter((role) => role.key !== 'ADMIN'),
];
@@ -0,0 +1,37 @@
<div class="bg-white h-svh relative">
<div class="absolute inset-0 opacity-30 blur-xs flex items-center justify-center">
<img [src]="authVector" alt="background image" class="object-cover max-w-full max-h-full" />
</div>
<div class="flex justify-center items-center relative z-10 h-svh mx-auto bg-gray-800/80">
<div
class="flex flex-col items-center justify-center px-12 py-10 h-auto bg-surface-card border border-surface-border rounded-lg shadow w-full max-w-md"
>
<div class="flex flex-col gap-4 text-center mb-10 items-center justify-center">
<img [src]="logo" alt="امتحانات شبه نهایی سنجش" class="w-20 h-auto" />
<span class="text-lg font-bold"> به پنل امتحانات شبه نهایی سنجش خوش آمدید. </span>
</div>
@if (activeStep() === "login") {
<app-login
[redirectUrl]="redirectUrl"
[loginApiUrl]="loginApiUrl"
[defaultRole]="role"
class="w-full"
(onSuccessfullySubmit)="onLoggedIn($event)"
(onToSignup)="toSignUp()"
/>
} @else if (activeStep() === "signup") {
<auth-signup role="TEACHER" class="w-full" />
} @else if (activeStep() === "otp") {
<app-otp class="w-full" />
} @else if (activeStep() === "modifyLoginInfo") {
<app-modify-login-info
[userLoginInfo]="{}"
[role]="selectedRole()!"
(onSubmit)="onModifyLoginInfo()"
class="w-full"
/>
}
</div>
</div>
</div>
@@ -0,0 +1,82 @@
import { IAuthResponse, TRoles } from '@/core';
import { ToastService } from '@/core/services/toast.service';
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { Router } from '@angular/router';
import images from 'src/assets/images';
import { LoginComponent } from './login/login.component';
import { ModifyLoginInfoComponent } from './modifyLoginInfo/modify-login-info.component';
import { OTPComponent } from './otp/otp.component';
import { SignupComponent } from './signup/signup.component';
type TSteps = 'login' | 'otp' | 'modifyLoginInfo' | 'signup';
@Component({
selector: 'app-auth',
templateUrl: './auth.component.html',
imports: [LoginComponent, OTPComponent, ModifyLoginInfoComponent, SignupComponent],
})
export class AuthComponent {
@Input() redirectUrl!: string;
@Input() loginApiUrl!: string;
@Input() role?: TRoles;
@Input() defaultStep: TSteps = 'login';
@Output() submit = new EventEmitter<Promise<boolean>>();
private readonly toastService = inject(ToastService);
private readonly router = inject(Router);
readonly logo = images.logo;
readonly authVector = images.login;
activeStep = signal<TSteps>(this.defaultStep);
selectedRole = signal<TRoles | undefined>(this.role);
toSignUp = () => {
this.activeStep.set('signup');
};
onLoggedIn = (data: IAuthResponse) => {
this.toastService.success({ text: 'شما با موفقیت وارد شدید.' });
this.selectedRole.set(data.role.toUpperCase() as TRoles);
if (data.mustChangePassword) {
this.activeStep.set('modifyLoginInfo');
return;
}
if (this.submit.observed) {
return this.submit.emit(Promise.resolve(true));
}
this.redirectToDashboard();
};
onModifyLoginInfo = () => {
if (this.submit.observed) {
return this.submit.emit(Promise.resolve(true));
}
this.redirectToDashboard();
};
private redirectToDashboard() {
let redirectUrl = this.redirectUrl;
if (!redirectUrl) {
switch (this.selectedRole()) {
case 'SCHOOL':
redirectUrl = '/schools';
break;
case 'ADMIN':
redirectUrl = '/admin';
break;
case 'TEACHER':
redirectUrl = '/teachers';
break;
case 'STUDENTS':
redirectUrl = '/students';
break;
}
}
this.router.navigateByUrl(redirectUrl);
}
}
@@ -0,0 +1,105 @@
<form [formGroup]="loginForm" class="w-full flex flex-col gap-4" (ngSubmit)="submit()">
@if (!defaultRole) {
<div class="flex items-center gap-4 justify-center mb-10">
@for (role of centralAuthRoles; track role.key) {
<div class="flex items-center gap-1">
<p-radiobutton [inputId]="role.key" name="role" [value]="role.key" formControlName="role" size="small" />
<uikit-label [name]="role.key" class="cursor-pointer">{{ role.title }}</uikit-label>
</div>
}
</div>
}
<uikit-field name="username" pSize="large" [control]="loginForm.controls.username" label="نام کاربری">
<input
pInputText
id="username"
name="username"
type="text"
placeholder="نام کاربری"
class="w-full placeholder:text-right"
pSize="large"
dir="ltr"
autocomplete="username"
formControlName="username"
[invalid]="loginForm.controls.username.touched && loginForm.controls.username.invalid"
/>
</uikit-field>
<uikit-field name="password1" pSize="large" [control]="loginForm.controls.password" label="رمز عبور">
<p-password
id="password1"
size="large"
name="password"
formControlName="password"
autocomplete="password"
placeholder="رمز عبور"
[toggleMask]="true"
[fluid]="true"
[feedback]="false"
inputStyleClass="placeholder:text-right"
[inputStyle]="{
direction: 'ltr',
'padding-inline-start': 'calc((var(--p-form-field-padding-x) * 2) + var(--p-icon-size))',
'padding-inline-end': 'var(--p-inputtext-lg-padding-x)',
}"
[invalid]="loginForm.controls.password.touched && loginForm.controls.password.invalid"
/>
</uikit-field>
<!-- <div class="flex items-center justify-between mt-2 mb-8 gap-8">
<div class="flex items-center">
<p-checkbox formControlName="rememberMe" id="rememberMe" binary class="me-2"></p-checkbox>
<uikit-label name="rememberMe" pSize="small">مرا به خاطر بسپار</uikit-label>
</div>
<span class="text-sm font-medium no-underline ms-2 text-right cursor-pointer text-primary">
رمز را فراموش کردید؟
</span>
</div> -->
<div class="mb-6 flex flex-col items-center gap-4 w-[16rem] mx-auto">
<div
class="relative bg-surface-ground rounded-xl overflow-hidden shadow shrink-0 border border-[var(--p-inputtext-border-color)] w-full aspect-[2]"
>
<img p-image id="captchaImage" [src]="captchaImageSrc()" class="w-full object-contain" />
@if (isCaptchaLoading()) {
<div class="!absolute inset-0 bg-surface-hover/50 flex items-center justify-center">
<p-progress-spinner
strokeWidth="4"
fill="transparent"
animationDuration="1s"
[style]="{ width: '48px', height: '48px' }"
/>
</div>
} @else if (isCaptchaExpired()) {
<div
class="!absolute inset-0 bg-surface-hover/70 flex items-center justify-center text-center p-2 text-sm font-medium cursor-pointer"
(click)="onRefreshCaptcha()"
>
<i class="pi pi-refresh !text-3xl text-gray-500"></i>
</div>
}
</div>
<p-inputgroup>
<input
pInputText
id="captcha"
type="text"
placeholder="کد کپچا را وارد کنید"
class="w-full"
formControlName="captcha"
/>
<p-inputgroup-addon>
<p-button icon="pi pi-refresh" severity="secondary" variant="text" (click)="onRefreshCaptchaImage()" />
</p-inputgroup-addon>
</p-inputgroup>
</div>
<div class="grid grid-cols-1 gap-3">
@if (loginForm.controls.role.value === "TEACHER") {
<span class="w-full text-center text-sm select-none">
برای ثبت نام در سامانه <a class="text-primary cursor-pointer" (click)="toSignup()">اینجا</a> کلیک کنید.
</span>
}
<p-button label="ورود" styleClass="w-full" size="large" [loading]="isLoading()" type="submit"></p-button>
</div>
</form>
@@ -0,0 +1,123 @@
import { AuthService } from '@/core';
import { IAuthResponse, LoginCredentials, TRoles } from '@/core/models';
import { ToastService } from '@/core/services/toast.service';
import { UikitLabelComponent } from '@/uikit';
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, computed, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Button } from 'primeng/button';
import { ImageModule } from 'primeng/image';
import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { InputText } from 'primeng/inputtext';
import { Password } from 'primeng/password';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { RadioButtonModule } from 'primeng/radiobutton';
import { CaptchaService } from 'src/app/core/services/captcha.service';
import { CENTRAL_AUTH_ROLES } from '../../constants/central-auth-roles.const';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
imports: [
CommonModule,
ReactiveFormsModule,
Button,
Password,
InputText,
ImageModule,
ProgressSpinnerModule,
UikitLabelComponent,
RadioButtonModule,
InputGroupModule,
InputGroupAddonModule,
UikitFieldComponent,
],
})
export class LoginComponent {
@Input() redirectUrl!: string;
@Input() loginApiUrl!: string;
@Input() defaultRole?: TRoles;
@Output() onSuccessfullySubmit = new EventEmitter<IAuthResponse>();
@Output() onToSignup = new EventEmitter<void>();
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
private readonly captchaService = inject(CaptchaService);
private readonly toastService = inject(ToastService);
readonly isLoading = computed(() => this.authService.isLoading());
readonly errorMessage = signal<string>('');
readonly hidePassword = signal<boolean>(true);
readonly isCaptchaExpired = this.captchaService.captchaIsExpired;
readonly isCaptchaLoading = this.captchaService.loading;
readonly captchaImageSrc = this.captchaService.captchaImageSrc;
readonly centralAuthRoles = CENTRAL_AUTH_ROLES;
readonly loginForm = this.fb.group({
username: ['', [Validators.required]],
password: ['', [Validators.required]],
rememberMe: [false],
captcha: ['', [Validators.required]],
role: [this.defaultRole || this.centralAuthRoles[0].key, [Validators.required]],
});
selectedRole = signal<TRoles>('SCHOOL');
ngOnInit() {
this.captchaService.requestNewCaptcha();
}
togglePasswordVisibility(): void {
this.hidePassword.set(!this.hidePassword());
}
resetCaptcha(): void {
this.fb.control('captcha').setValue('');
this.captchaService.renewCaptcha();
}
onRefreshCaptcha(): void {
this.resetCaptcha();
}
onRefreshCaptchaImage(): void {
this.resetCaptcha();
// document
// .getElementById('captchaImage')
// ?.setAttribute('src', this.captchaService.captchaImageSrc()! + `&${new Date().getTime()}`);
}
toSignup() {
this.onToSignup.emit();
}
submit(): void {
this.loginForm.markAllAsTouched();
if (this.loginForm.invalid) return;
if (!this.captchaService.captchaId()) {
this.toastService.error({ text: 'مقدار کپچا را وارد کنید' });
}
const credentials: LoginCredentials = this.loginForm.value as LoginCredentials;
this.authService
.login(credentials, (this.defaultRole || this.loginForm.value.role)!)
.subscribe({
next: (data) => {
this.errorMessage.set('');
if (this.redirectUrl) {
this.router.navigateByUrl(this.redirectUrl.replace(/\/[^/]*$/, ''));
} else {
this.onSuccessfullySubmit.emit(data);
}
},
error: (error) => {
this.resetCaptcha();
this.errorMessage.set('نام کاربری یا رمز عبور اشتباه است');
console.error('Login failed:', error);
},
});
}
}
@@ -0,0 +1,65 @@
<form [formGroup]="infoForm" class="w-full flex flex-col gap-4" (ngSubmit)="submit()">
<p-message severity="warn" [closable]="false" variant="text" icon="pi pi-info-circle" class="mb-5">
برای ادامه می‌بایست اطلاعات ورود خود را به روز کنید
</p-message>
<uikit-field name="username" label="نام کاربری" pSize="large" class="" [control]="infoForm.get('username')">
<input
pInputText
id="username"
pSize="large"
formControlName="username"
[invalid]="infoForm.get('username')?.touched && infoForm.get('username')?.invalid"
/>
</uikit-field>
<uikit-field label="رمز عبور" pSize="large" class="" [control]="infoForm.get('password')">
<p-password
id="password1"
size="large"
name="password"
formControlName="password"
autocomplete="password"
[toggleMask]="true"
[fluid]="true"
[feedback]="false"
[invalid]="infoForm.get('password')?.touched && infoForm.get('password')?.invalid"
/>
<span class="text-xs mt-2 text-muted-color">
رمز باید حداقل ۸ کاراکتر بوده و شامل حداقل یک حرف بزرگ، یک حرف کوچک، یک عدد و یک کاراکتر ویژه باشد.
</span>
</uikit-field>
<uikit-field label="تکرار رمز عبور" pSize="large" class="" [control]="infoForm.get('confirmPassword')">
<p-password
id="confirmPassword"
size="large"
name="confirmPassword"
formControlName="confirmPassword"
autocomplete="new-password"
[toggleMask]="true"
[fluid]="true"
[feedback]="false"
[invalid]="infoForm.get('confirmPassword')?.touched && infoForm.get('confirmPassword')?.invalid"
/>
</uikit-field>
<uikit-field label="شماره تلفن همراه" pSize="large" class="" [control]="infoForm.get('mobile')">
<input
pInputText
pSize="large"
formControlName="mobile"
[invalid]="infoForm.get('mobile')?.touched && infoForm.get('mobile')?.invalid"
/>
</uikit-field>
<uikit-field label="آدرس ایمیل" pSize="large" class="" [control]="infoForm.get('email')">
<input
pInputText
pSize="large"
formControlName="email"
[invalid]="infoForm.get('email')?.touched && infoForm.get('email')?.invalid"
/>
</uikit-field>
<p-button label="تایید" styleClass="w-full mt-4" size="large" [loading]="isLoading()" type="submit"></p-button>
</form>
@@ -0,0 +1,100 @@
import { AuthService } from '@/core';
import { IUserLoginInfo, TRoles } from '@/core/models';
import { ToastService } from '@/core/services/toast.service';
import { MustMatch } from '@/core/validators';
import { password } from '@/core/validators/password.validator';
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Button } from 'primeng/button';
import { ImageModule } from 'primeng/image';
import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { InputText } from 'primeng/inputtext';
import { Message } from 'primeng/message';
import { Password } from 'primeng/password';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { RadioButtonModule } from 'primeng/radiobutton';
@Component({
selector: 'app-modify-login-info',
templateUrl: './modify-login-info.component.html',
imports: [
CommonModule,
ReactiveFormsModule,
Button,
Password,
InputText,
ImageModule,
ProgressSpinnerModule,
RadioButtonModule,
InputGroupModule,
InputGroupAddonModule,
UikitFieldComponent,
Message,
],
})
export class ModifyLoginInfoComponent {
@Input() userLoginInfo!: Partial<IUserLoginInfo>;
@Input() role!: TRoles;
@Output() onSubmit = new EventEmitter<IUserLoginInfo>();
@Output() toLogin = new EventEmitter<void>();
private readonly authService = inject(AuthService);
private readonly toastService = inject(ToastService);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
readonly isLoading = signal<boolean>(false);
readonly errorMessage = signal<string>('');
readonly hidePassword = signal<boolean>(true);
readonly infoForm = this.fb.group(
{
username: [this.userLoginInfo?.username, [Validators.required]],
password: ['', [Validators.required, password()]],
confirmPassword: ['', [Validators.required]],
mobile: [this.userLoginInfo?.mobile, [Validators.required]],
email: [this.userLoginInfo?.email, [Validators.required, Validators.email]],
},
{
validators: [MustMatch('password', 'confirmPassword')],
},
);
ngOnInit() {
this.authService.getInfo(this.role).subscribe({
next: (info) => {
this.infoForm.patchValue(info);
},
error: () => {
this.toLogin.emit();
},
});
}
submit(): void {
this.infoForm.markAllAsTouched();
if (this.infoForm.invalid) return;
const credentials: IUserLoginInfo = this.infoForm.value as IUserLoginInfo;
this.isLoading.set(true);
this.authService.changeInfo(credentials, this.role).subscribe({
next: () => {
this.toastService.success({
text: 'اطلاعات با موفقیت به‌روزرسانی شد',
});
this.errorMessage.set('');
this.onSubmit?.emit();
this.isLoading.set(false);
// this.router.navigateByUrl(this.redirectUrl.replace(/\/[^/]*$/, ''));
},
error: (error) => {
this.isLoading.set(false);
// this.errorMessage.set('نام کاربری یا رمز عبور اشتباه است');
},
});
}
}
@@ -0,0 +1,5 @@
<form class="flex justify-center flex-col gap-3 items-center px-6" (ngSubmit)="submit()">
<p-inputotp name="otp" size="large" [length]="5" [(ngModel)]="otpCode" />
<span class="text-muted-color"> کد ارسال شده را وارد کنید. </span>
<button pButton class="w-full mt-5" [loading]="submitLoading()">ثبت</button>
</form>
@@ -0,0 +1,18 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonDirective } from 'primeng/button';
import { InputOtpModule } from 'primeng/inputotp';
@Component({
selector: 'app-otp',
standalone: true,
imports: [CommonModule, InputOtpModule, FormsModule, ButtonDirective],
templateUrl: './otp.component.html',
})
export class OTPComponent {
otpCode = signal<string>('');
submitLoading = signal<boolean>(false);
submit = () => {};
}
@@ -0,0 +1,60 @@
<form [formGroup]="form" class="w-full flex flex-col gap-4" (ngSubmit)="submit()">
<app-input label="نام" name="firstName" size="large" [control]="form.controls.firstName" />
<app-input label="نام خانوادگی" name="lastName" size="large" [control]="form.controls.lastName" />
<app-gender-select [control]="form.controls.gender" size="large" />
<app-input
label="شماره تلفن همراه"
name="mobile"
size="large"
autocomplete="tel"
[control]="form.controls.mobile"
type="mobile"
/>
<app-input
label="آدرس ایمیل"
name="email"
size="large"
autocomplete="email"
[control]="form.controls.email"
type="email"
/>
<app-input
label="نام کاربری"
name="username"
size="large"
autocomplete="username"
[control]="form.controls.username"
/>
<uikit-field label="رمز عبور" name="password" pSize="large" [control]="form.controls.password">
<p-password
id="password1"
size="large"
name="password"
formControlName="password"
autocomplete="password"
[toggleMask]="true"
[fluid]="true"
[feedback]="false"
[invalid]="form.controls.password.touched && form.controls.password.invalid"
/>
<span class="text-xs mt-2 text-muted-color">
رمز باید حداقل ۸ کاراکتر بوده و شامل حداقل یک حرف بزرگ، یک حرف کوچک، یک عدد و یک کاراکتر ویژه باشد.
</span>
</uikit-field>
<uikit-field label="تکرار رمز عبور" pSize="large" name="confirmPassword" [control]="form.controls.confirmPassword">
<p-password
id="confirmPassword"
size="large"
name="confirmPassword"
formControlName="confirmPassword"
autocomplete="new-password"
[toggleMask]="true"
[fluid]="true"
[feedback]="false"
[invalid]="form.controls.confirmPassword.touched && form.controls.confirmPassword.invalid"
/>
</uikit-field>
<p-button label="تایید" styleClass="w-full mt-4" size="large" [loading]="isLoading()" type="submit"></p-button>
</form>
@@ -0,0 +1,94 @@
import { AuthService } from '@/core';
import { ISignupRequestPayload, IUserLoginInfo, Maybe, TRoles } from '@/core/models';
import { ToastService } from '@/core/services/toast.service';
import { mobileValidator, MustMatch } from '@/core/validators';
import { password } from '@/core/validators/password.validator';
import { GenderSelectComponent } from '@/shared/catalog';
import { InputComponent } from '@/shared/components';
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Button } from 'primeng/button';
import { ImageModule } from 'primeng/image';
import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { Password } from 'primeng/password';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { RadioButtonModule } from 'primeng/radiobutton';
@Component({
selector: 'auth-signup',
templateUrl: './signup.component.html',
imports: [
CommonModule,
ReactiveFormsModule,
Button,
Password,
ImageModule,
ProgressSpinnerModule,
RadioButtonModule,
InputGroupModule,
InputGroupAddonModule,
UikitFieldComponent,
InputComponent,
GenderSelectComponent,
],
})
export class SignupComponent {
@Input() userLoginInfo!: Partial<IUserLoginInfo>;
@Input() role!: TRoles;
@Output() onSubmit = new EventEmitter<IUserLoginInfo>();
@Output() toLogin = new EventEmitter<void>();
constructor(
private readonly authService: AuthService,
private readonly toastService: ToastService,
private readonly router: Router,
) {}
private readonly fb = inject(FormBuilder);
readonly isLoading = signal<boolean>(false);
readonly errorMessage = signal<string>('');
readonly hidePassword = signal<boolean>(true);
readonly form = this.fb.group(
{
firstName: ['', [Validators.required]],
lastName: ['', [Validators.required]],
gender: [null as Maybe<boolean>, [Validators.required]],
mobile: ['', [Validators.required, mobileValidator()]],
email: ['', [Validators.required, Validators.email]],
username: ['', [Validators.required]],
password: ['', [Validators.required, password()]],
confirmPassword: ['', [Validators.required]],
},
{
validators: [MustMatch('password', 'confirmPassword')],
},
);
submit(): void {
this.form.markAllAsTouched();
if (this.form.invalid) return;
const credentials = this.form.value as ISignupRequestPayload;
this.isLoading.set(true);
this.authService.signup(credentials, this.role).subscribe({
next: () => {
this.toastService.success({
text: 'ثبت نام با موفقیت انجام شد. لطفا وارد شوید.',
});
this.errorMessage.set('');
this.onSubmit?.emit();
this.isLoading.set(false);
},
error: (error) => {
this.isLoading.set(false);
},
});
}
}
+404
View File
@@ -0,0 +1,404 @@
import { HttpClient } from '@angular/common/http';
import { computed, inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, of, tap } from 'rxjs';
import { LOCAL_STORAGE_KEYS, ROLES } from 'src/assets/constants';
import { IAuthResponse, LoginCredentials, Maybe, TRoles, User } from '../../../core/models';
import { BaseState, BaseStore } from '../../../core/state/base-store';
/**
* Authentication state
*/
export interface AuthState extends BaseState {
user: Maybe<User>;
token: Maybe<string>;
refreshToken: Maybe<string>;
isAuthenticated: boolean;
permissions: string[];
loginAttempts: number;
lastLoginTime: Maybe<number>;
sessionExpiry: Maybe<number>;
}
/**
* Login state for UI feedback
*/
export interface LoginState {
isLoggingIn: boolean;
isRefreshing: boolean;
loginError: Maybe<string>;
}
/**
* Authentication state store
*/
@Injectable({
providedIn: 'root',
})
export class AuthStore extends BaseStore<AuthState> {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
// Login-specific state
private readonly _loginState = {
isLoggingIn: false,
isRefreshing: false,
loginError: null as Maybe<string>,
};
constructor() {
super({
loading: false,
error: null,
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
permissions: [],
loginAttempts: 0,
lastLoginTime: null,
sessionExpiry: null,
});
this.initializeAuth();
}
// Computed selectors
readonly user = computed(() => this._state().user);
readonly token = computed(() => this._state().token);
readonly refreshToken = computed(() => this._state().refreshToken);
readonly isAuthenticated = computed(() => this._state().isAuthenticated);
readonly userRole = computed(() => this._state().user?.role);
readonly permissions = computed(() => this._state().permissions);
readonly loginAttempts = computed(() => this._state().loginAttempts);
readonly lastLoginTime = computed(() => this._state().lastLoginTime);
readonly sessionExpiry = computed(() => this._state().sessionExpiry);
// Login state selectors
readonly isLoggingIn = computed(() => this._loginState.isLoggingIn);
readonly isRefreshing = computed(() => this._loginState.isRefreshing);
readonly loginError = computed(() => this._loginState.loginError);
/**
* Initialize authentication from stored data
*/
private initializeAuth(): void {
const token = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
const refreshToken = localStorage.getItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
const userData = localStorage.getItem(LOCAL_STORAGE_KEYS.USER_DATA);
const loginAttempts = parseInt(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS) || '0');
const lastLoginTime = parseInt(localStorage.getItem(LOCAL_STORAGE_KEYS.LAST_LOGIN_TIME) || '0');
if (token && refreshToken && userData) {
try {
const user = JSON.parse(userData) as User;
const sessionExpiry = this.getTokenExpiry(token);
this.patchState({
user,
token,
refreshToken,
isAuthenticated: true,
permissions: [],
// permissions: user.permissions?.map((p) => `${p.resource}:${p.action}`) || [],
loginAttempts,
lastLoginTime: lastLoginTime || null,
sessionExpiry,
});
// Check if token is expired
if (this.isTokenExpired()) {
this.refreshAuthToken().subscribe();
}
} catch (error) {
console.error('Error parsing stored user data:', error);
this.logout();
}
}
}
/**
* Login with credentials
*/
login(credentials: LoginCredentials) {
this._loginState.isLoggingIn = true;
this._loginState.loginError = null;
this.setLoading(true);
return this.http.post<IAuthResponse>('/api/auth/login', credentials).pipe(
tap((response) => {
this.handleAuthSuccess(response);
this._loginState.isLoggingIn = false;
}),
catchError((error) => {
this.handleAuthError(error);
return of(null);
}),
);
}
/**
* Logout user
*/
logout(): void {
// Clear stored data
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);
// Reset state
this.patchState({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
permissions: [],
lastLoginTime: null,
sessionExpiry: null,
loading: false,
error: null,
});
// Reset login state
this._loginState.isLoggingIn = false;
this._loginState.isRefreshing = false;
this._loginState.loginError = null;
// Navigate to login
this.router.navigate(['/auth']);
}
/**
* Refresh authentication token
*/
refreshAuthToken() {
const refreshToken = this._state().refreshToken;
if (!refreshToken) {
this.logout();
return of(null);
}
this._loginState.isRefreshing = true;
this.setLoading(true);
return this.http.post<IAuthResponse>('/api/auth/refresh', { refreshToken }).pipe(
tap((response) => {
this.handleAuthSuccess(response);
this._loginState.isRefreshing = false;
}),
catchError((error) => {
console.error('Token refresh failed:', error);
this.logout();
return of(null);
}),
);
}
/**
* Check if user has specific role
*/
hasRole(role: TRoles): boolean {
return this._state().user?.role === role;
}
/**
* Check if user has any of the specified roles
*/
hasAnyRole(roles: TRoles[]): boolean {
const userRole = this._state().user?.role;
return userRole ? roles.includes(userRole) : false;
}
/**
* Check if user has specific permission
*/
hasPermission(permission: string): boolean {
const permissions = this._state().permissions;
return permissions.includes(permission);
}
/**
* Check if user has all specified permissions
*/
hasAllPermissions(permissions: string[]): boolean {
const userPermissions = this._state().permissions;
return permissions.every((permission) => userPermissions.includes(permission));
}
/**
* Check if user has any of the specified permissions
*/
hasAnyPermission(permissions: string[]): boolean {
const userPermissions = this._state().permissions;
return permissions.some((permission) => userPermissions.includes(permission));
}
/**
* Check if user can access resource
*/
canAccess(requiredRoles?: TRoles[], requiredPermissions?: string[]): boolean {
if (!this._state().isAuthenticated) return false;
if (requiredRoles && !this.hasAnyRole(requiredRoles)) {
return false;
}
if (requiredPermissions && !this.hasAllPermissions(requiredPermissions)) {
return false;
}
return true;
}
/**
* Get current access token
*/
getToken(): string | null {
return this._state().token;
}
/**
* Get current refresh token
*/
getRefreshToken(): string | null {
return this._state().refreshToken;
}
/**
* Check if token is expired
*/
isTokenExpired(): boolean {
const sessionExpiry = this._state().sessionExpiry;
if (!sessionExpiry) return true;
return Date.now() > sessionExpiry;
}
/**
* Get time until token expires (in minutes)
*/
getTimeUntilExpiry(): number {
const sessionExpiry = this._state().sessionExpiry;
if (!sessionExpiry) return 0;
return Math.max(0, Math.floor((sessionExpiry - Date.now()) / 60000));
}
/**
* Update user profile
*/
updateUserProfile(updates: Partial<User>): void {
const currentUser = this._state().user;
if (!currentUser) return;
const updatedUser = { ...currentUser, ...updates };
this.patchState({ user: updatedUser });
localStorage.setItem(LOCAL_STORAGE_KEYS.USER_DATA, JSON.stringify(updatedUser));
}
/**
* Handle successful authentication
*/
private handleAuthSuccess(response: IAuthResponse): void {
const sessionExpiry = this.getTokenExpiry(response.token);
const currentTime = Date.now();
// Store in localStorage
localStorage.setItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN, response.token);
localStorage.setItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN, response.refreshToken);
// localStorage.setItem(LOCAL_STORAGE_KEYS.USER_DATA, JSON.stringify(response.user));
localStorage.setItem(LOCAL_STORAGE_KEYS.LAST_LOGIN_TIME, currentTime.toString());
// Update state
this.patchState({
user: { fullName: response.fullName, role: response.role as TRoles },
token: response.token,
refreshToken: response.refreshToken,
isAuthenticated: true,
// permissions: response.user.permissions?.map((p) => `${p.resource}:${p.action}`) || [],
lastLoginTime: currentTime,
sessionExpiry,
loading: false,
error: null,
});
// Reset login attempts on successful login
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS);
// Navigate based on user role
// this.navigateByRole(response.user.role);
}
/**
* Handle authentication error
*/
private handleAuthError(error: any): void {
console.error('Authentication error:', error);
// Increment login attempts
const attempts = this._state().loginAttempts + 1;
this.patchState({ loginAttempts: attempts });
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS, attempts.toString());
// Set login error
this._loginState.isLoggingIn = false;
this._loginState.loginError = error.error?.message || 'خطا در ورود به سیستم';
if (this._loginState.loginError) {
this.setError(this._loginState.loginError);
}
}
/**
* Navigate user based on their role
*/
private navigateByRole(role: TRoles): void {
const roleRoutes = {
[ROLES.ADMIN]: '/admin',
[ROLES.SCHOOL]: '/schools',
[ROLES.TEACHER]: '/teachers',
[ROLES.STUDENTS]: '/students',
[ROLES.GRADER]: '/grader',
[ROLES.SUPERADMIN]: '/superadmin',
};
this.router.navigate([roleRoutes[role]]);
}
/**
* Extract token expiry from JWT
*/
private getTokenExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000; // Convert to milliseconds
} catch (error) {
console.error('Error parsing token:', error);
return null;
}
}
/**
* Reset state to initial values
*/
reset(): void {
this.setState({
loading: false,
error: null,
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
permissions: [],
loginAttempts: 0,
lastLoginTime: null,
sessionExpiry: null,
});
this._loginState.isLoggingIn = false;
this._loginState.isRefreshing = false;
this._loginState.loginError = null;
}
}
+5
View File
@@ -0,0 +1,5 @@
// Authentication state management
export * from './auth.store';
// Types
export type { AuthState, LoginState } from './auth.store';
@@ -0,0 +1,16 @@
<uikit-field label="جنسیت" name="gender" [control]="control">
<ng-container *ngIf="formControl as fc">
<p-select
[options]="[
{ label: 'مرد', id: true },
{ label: 'زن', id: false },
]"
[size]="size"
[id]="'gender'"
[optionValue]="'id'"
[attr.name]="'gender'"
[formControl]="fc"
[invalid]="fc.invalid && (fc.dirty || fc.touched)"
></p-select>
</ng-container>
</uikit-field>
@@ -0,0 +1,20 @@
import { Maybe } from '@/core';
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { AbstractControl, FormControl, ReactiveFormsModule } from '@angular/forms';
import { Select } from 'primeng/select';
@Component({
selector: 'app-gender-select',
templateUrl: './gender-select.component.html',
imports: [CommonModule, UikitFieldComponent, Select, ReactiveFormsModule],
})
export class GenderSelectComponent {
@Input() control!: Maybe<AbstractControl<any, any>>;
@Input() size?: 'small' | 'large';
get formControl(): FormControl | undefined {
return this.control as FormControl | undefined;
}
}
@@ -0,0 +1 @@
{{ genderTitle() }}
@@ -0,0 +1,13 @@
import { Component, computed, Input } from '@angular/core';
@Component({
selector: 'catalog-gender-tag',
templateUrl: './gender-tag.component.html',
})
export class CatalogGenderTagComponent {
@Input() gender!: boolean;
constructor() {}
genderTitle = computed(() => (this.gender ? 'مرد' : 'زن'));
}
+2
View File
@@ -0,0 +1,2 @@
export * from './gender-select.component';
export * from './gender-tag.component';
+1
View File
@@ -0,0 +1 @@
export * from './gender';
@@ -0,0 +1,6 @@
<p-breadcrumb [model]="items"></p-breadcrumb>
<div class="absolute left-0 inset-y-0">
<div class="me-4 flex items-center justify-center h-full">
<i class="pi pi-question-circle" pTooltip="محتوای آموزشی" tooltipPosition="bottom"></i>
</div>
</div>
@@ -0,0 +1,23 @@
import { BreadcrumbService } from '@/core/services/breadcrumb.service';
import { Component, inject } from '@angular/core';
import { MenuItem } from 'primeng/api';
import { BreadcrumbModule } from 'primeng/breadcrumb';
import { TooltipModule } from 'primeng/tooltip';
@Component({
selector: 'app-breadcrumb',
standalone: true,
imports: [BreadcrumbModule, TooltipModule],
templateUrl: './breadcrumb.component.html',
host: { class: 'block w-full relative' },
})
export class BreadcrumbComponent {
private breadcrumbService = inject(BreadcrumbService);
get items(): MenuItem[] {
return this.breadcrumbService.items.map((item) => ({
...item,
label: item.title || '',
}));
}
}
@@ -0,0 +1,23 @@
<p-card>
<ng-template #title>
<ng-container [ngTemplateOutlet]="header">
<div class="flex items-center gap-10 justify-between">
<span>{{ cardTitle }}</span>
<div class="flex items-center gap-2 shrink-0">
<ng-container [ngTemplateOutlet]="moreAction"></ng-container>
@if (editable) {
<button
pButton
type="button"
[icon]="`pi ${editMode ? 'pi-times' : 'pi-pencil'}`"
class="p-button-text p-button-plain"
(click)="onEditClick()"
></button>
}
</div>
</div>
</ng-container>
<hr />
</ng-template>
<ng-content></ng-content>
</p-card>
@@ -0,0 +1,34 @@
import { Maybe } from '@/core';
import { CommonModule } from '@angular/common';
import { Component, ContentChild, EventEmitter, Input, Output, TemplateRef } from '@angular/core';
import { ButtonDirective } from 'primeng/button';
import { Card } from 'primeng/card';
@Component({
selector: 'app-card-data',
standalone: true,
templateUrl: './card-data.component.html',
imports: [Card, CommonModule, ButtonDirective],
})
export class AppCardComponent {
@Input() cardTitle!: string;
@Input() editable: boolean = false;
@Input() editMode: boolean = false;
@Output() onEdit = new EventEmitter<void>();
@ContentChild('header', { static: true }) header?: Maybe<TemplateRef<any>>;
@ContentChild('moreAction', { static: true }) moreAction?: Maybe<TemplateRef<any>>;
constructor() {
if (this.editable) {
if (!this.onEdit) {
throw new Error('onEdit output must be bound when editable is true');
}
}
}
onEditClick() {
this.onEdit.emit();
}
}
@@ -0,0 +1,4 @@
<div class="flex justify-end gap-2">
<p-button [label]="cancelLabel" severity="secondary" (click)="cancel()" />
<p-button [label]="submitLabel" type="submit" [loading]="loading" (onClick)="submit()" />
</div>
@@ -0,0 +1,25 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Button } from 'primeng/button';
@Component({
selector: 'app-form-footer-actions',
templateUrl: './form-footer-actions.component.html',
imports: [Button],
})
export class FormFooterActionsComponent {
@Input() submitLabel = 'تایید';
@Input() cancelLabel = 'لغو';
@Input() loading = false;
@Output() onSubmit = new EventEmitter<void>();
@Output() onCancel = new EventEmitter<void>();
constructor() {}
submit() {
this.onSubmit.emit();
}
cancel() {
this.onCancel.emit();
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './breadcrumb.component';
export * from './card-data.component';
export * from './inlineConfirmation/inline-confirmation.component';
export * from './input/input.component';
export * from './key-value.component/key-value.component';
export * from './table-action-row.component';
@@ -0,0 +1,9 @@
<div class="inline-flex items-center gap-2 flex-wrap">
<p-badge
[value]="value ? confirmedMessage : rejectedMessage"
[severity]="value ? 'success' : 'danger'"
class="cursor-pointer"
(click)="onClick($event)"
/>
<p-confirmpopup />
</div>
@@ -0,0 +1,55 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { ConfirmationService } from 'primeng/api';
import { Badge } from 'primeng/badge';
import { ConfirmPopup } from 'primeng/confirmpopup';
@Component({
selector: 'inline-confirmation',
templateUrl: './inline-confirmation.component.html',
imports: [ConfirmPopup, Badge],
providers: [ConfirmationService],
})
export class InlineConfirmationComponent {
@Input() value!: boolean;
@Input() confirmedMessage?: string = 'اطلاعات تایید شده است';
@Input() rejectedMessage?: string = 'اطلاعات تایید نشده است';
@Input() loading?: boolean = false;
@Input() onConfirmMessage: string = 'آیا از تایید این اطلاعات اطمینان دارید؟';
@Input() onRejectMessage: string = 'آیا از رد این اطلاعات اطمینان دارید؟';
@Input() confirmationHeader: string = 'تایید تغییر وضعیت';
@Input() confirmationIcon: string = 'pi pi-exclamation-triangle';
@Input() acceptCTALabel: string = 'بله';
@Input() rejectCTALabel: string = 'خیر';
@Output() onConfirm = new EventEmitter<void>();
@Output() onReject = new EventEmitter<void>();
constructor(private confirmationService: ConfirmationService) {}
toggle = () => {
if (this.value) {
this.onReject.emit();
} else {
this.onConfirm.emit();
}
};
onClick($event: Event) {
this.showConfirmation($event);
}
showConfirmation(event: Event) {
this.confirmationService.confirm({
target: (event.target as HTMLElement)?.parentNode?.parentNode!,
message: this.value ? this.onRejectMessage : this.onConfirmMessage,
header: this.confirmationHeader,
icon: this.confirmationIcon,
acceptLabel: this.acceptCTALabel,
rejectLabel: this.rejectCTALabel,
rejectButtonProps: {
variant: 'outlined',
},
accept: () => this.toggle(),
});
}
}
@@ -0,0 +1,17 @@
<uikit-field [label]="label" [name]="name" [control]="control">
<input
pInputText
[attr.id]="name"
[formControl]="control"
[attr.name]="name"
[attr.placeholder]="placeholder"
[attr.inputmode]="inputMode"
[attr.maxlength]="maxLength || null"
[attr.type]="htmlType"
[attr.autocomplete]="autocomplete"
[class]="`${inputClass} w-full ${size === 'large' ? 'p-inputtext-lg' : size === 'small' ? 'p-inputtext-sm' : ''}`"
[required]="isRequired"
[invalid]="control.invalid && (control.touched || control.dirty)"
(input)="onInput($event)"
/>
</uikit-field>
@@ -0,0 +1,96 @@
import { Maybe } from '@/core';
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { InputTextModule } from 'primeng/inputtext';
@Component({
selector: 'app-input',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, InputTextModule, UikitFieldComponent],
templateUrl: './input.component.html',
})
export class InputComponent {
@Input() type: 'simple' | 'postalCode' | 'mobile' | 'phone' | 'email' = 'simple';
@Input() control!: FormControl<Maybe<any>>;
@Input() name!: string;
@Input() label: string = '';
@Input() customPlaceholder?: string;
@Input() required = false;
@Input() disabled = false;
@Input() size?: 'small' | 'large';
@Input() autocomplete?: string = 'off';
@Output() valueChange = new EventEmitter<string>();
onInput(ev: Event) {
const v = (ev.target as HTMLInputElement).value;
this.valueChange.emit(v);
}
get placeholder(): string | null {
if (this.customPlaceholder) return this.customPlaceholder;
switch (this.type) {
case 'mobile':
return '09xxxxxxxx';
case 'postalCode':
return 'کد پستی (۱۰ رقم)';
case 'phone':
return 'شماره تلفن';
case 'email':
return 'آدرس ایمیل خود را وارد کنید';
default:
return '';
}
}
get inputMode(): string | null {
switch (this.type) {
case 'mobile':
case 'phone':
case 'postalCode':
return 'numeric';
default:
return 'text';
}
}
get maxLength(): number | null {
switch (this.type) {
case 'mobile':
return 11;
case 'postalCode':
return 10;
case 'phone':
return 11;
default:
return null;
}
}
get inputClass(): string {
switch (this.type) {
case 'mobile':
case 'phone':
return 'ltrInput';
case 'email':
case 'postalCode':
return 'ltrInput rtlPlaceholder';
default:
return '';
}
}
get htmlType(): string {
switch (this.type) {
case 'email':
return 'email';
default:
return 'text';
}
}
get isRequired(): boolean {
return this.required || this.control.hasValidator(Validators.required);
}
}
@@ -0,0 +1,6 @@
<div class="inline-flex gap-2 items-center w-full">
<span class="text-muted-color text-base font-normal shrink-0">{{ label }}:</span>
<ng-content>
<span class="text-text-color text-base font-bold grow">{{ value || "-" }}</span>
</ng-content>
</div>
@@ -0,0 +1,16 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-key-value',
standalone: true,
imports: [CommonModule],
templateUrl: './key-value.component.html',
host: {
class: 'w-full block',
},
})
export class KeyValueComponent {
@Input() label!: string;
@Input() value?: string = '-';
}
@@ -0,0 +1,139 @@
<div class="h-full bg-surface-overlay">
<div class="h-full flex flex-col gap-4">
<p-table
[columns]="columns"
[scrollable]="true"
[value]="items"
[loading]="loading"
columnResizeMode="fit"
stripedRows
[showFirstLastIcon]="false"
class="grow !flex flex-col overflow-hidden"
[tableStyleClass]="!items.length && !loading ? 'h-full' : ''"
>
@if (pageTitle || showAdd || filter || caption) {
<ng-template pTemplate="caption">
<ng-container [ngTemplateOutlet]="caption">
<div class="flex justify-between items-center gap-4">
<h5 class="font-bold">{{ pageTitle }}</h5>
@if (showAdd || filter) {
<div class="flex items-center gap-2">
@if (filter) {
<p-button
label="فیلتر"
icon="pi pi-filter"
variant="outlined"
badgeSeverity="info"
[badge]="isFiltered ? '1' : undefined"
(click)="openFilter()"
></p-button>
}
@if (showAdd) {
<p-button label="{{ addNewCtaLabel }}" icon="pi pi-plus" (click)="openAddForm()"></p-button>
}
</div>
}
</div>
</ng-container>
</ng-template>
}
<ng-template #header let-columns>
<tr>
@for (col of columns; track col.header) {
<th [style]="{ width: col.width, minWidth: col.minWidth }">{{ col.header }}</th>
}
@if (actionsCount()) {
<th type="th" [style]="{ width: `${actionsCount() * 2 + (actionsCount() - 1) * 0.25}rem` }"></th>
}
</tr>
</ng-template>
<ng-template #body let-item>
<tr>
@for (col of columns; track col.field) {
<td>
@if (getTemplate(col)) {
<ng-container
[ngTemplateOutlet]="getTemplate(col)"
[ngTemplateOutletContext]="{ $implicit: item }"
></ng-container>
} @else if (col.canCopy) {
<uikit-copy [text]="getCell(item, col)"></uikit-copy>
} @else {
<span>
{{ getCell(item, col) }}
</span>
}
</td>
}
@if (actionsCount()) {
<td
table-action-row
type="td"
[showDetails]="showDetails"
[showDelete]="showDelete"
[showEdit]="showEdit"
(edit)="edit(item)"
(delete)="remove(item)"
(details)="details(item)"
></td>
}
</tr>
</ng-template>
<ng-template #emptymessage>
<tr class="h-full">
<td [colSpan]="columns.length.toString()" class="w-full">
<uikit-empty-state
[title]="emptyPlaceholderTitle"
[description]="emptyPlaceholderDescription"
[ctaLabel]="emptyPlaceholderCtaLabel || addNewCtaLabel"
[showCTA]="showAdd"
(ctaClick)="openAddForm()"
></uikit-empty-state>
</td>
</tr>
</ng-template>
<ng-template #loadingbody let-columns>
@for (i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; track i) {
<tr style="height: 46px">
@for (col of columns; track col) {
<td>
<p-skeleton></p-skeleton>
</td>
}
@if (actionsCount()) {
<td>
<p-skeleton></p-skeleton>
</td>
}
</tr>
}
</ng-template>
</p-table>
@if (showPaginator) {
<app-paginator
[currentPage]="currentPage || 1"
[totalRecords]="totalRecords"
[perPage]="perPage || 10"
[loading]="loading"
(onChange)="onPage($event)"
/>
}
</div>
@if (filter) {
<p-drawer
[visible]="filterDrawerVisible()"
(onHide)="closeFilter()"
header="فیلتر"
class="contnet"
styleClass="!w-[80vw] md:!w-96 md:!max-w-96 !max-w-sm"
>
<div class="pt-2">
<ng-container [ngTemplateOutlet]="filter" [ngTemplateOutletContext]="{ $implicit: columns }"> </ng-container>
</div>
</p-drawer>
}
</div>
@@ -0,0 +1,170 @@
import { PaginatorComponent, UikitCopyComponent, UikitEmptyStateComponent } from '@/uikit';
import { CommonModule } from '@angular/common';
import {
Component,
computed,
ContentChild,
ElementRef,
EventEmitter,
Input,
Output,
Renderer2,
signal,
TemplateRef,
} from '@angular/core';
import { ButtonModule } from 'primeng/button';
import { DrawerModule } from 'primeng/drawer';
import { PaginatorModule } from 'primeng/paginator';
import { SkeletonModule } from 'primeng/skeleton';
import { TableModule } from 'primeng/table';
import { TableActionRowComponent } from '../table-action-row.component';
export interface IColumn {
field: string;
header: string;
width?: string;
minWidth?: string;
canCopy?: boolean;
customDataModel?: TemplateRef<any> | ((item: any) => string | number | boolean);
}
@Component({
selector: 'app-page-data-list',
templateUrl: './page-data-list.component.html',
imports: [
CommonModule,
TableActionRowComponent,
UikitEmptyStateComponent,
TableModule,
ButtonModule,
SkeletonModule,
PaginatorModule,
DrawerModule,
PaginatorComponent,
UikitCopyComponent,
],
})
export class PageDataListComponent<I> {
constructor(
private host: ElementRef,
private renderer: Renderer2,
) {}
@Input() pageTitle!: string;
@Input() addNewCtaLabel?: string;
@Input() columns!: IColumn[];
@Input() items!: I[];
@Input() loading!: boolean;
@Input() emptyPlaceholderTitle: string = 'موردی برای نمایش وجود ندارد';
@Input() emptyPlaceholderDescription?: string = '';
@Input() emptyPlaceholderCtaLabel?: string;
@Input() totalRecords!: number;
@Input() perPage?: number = 10;
@Input() currentPage?: number = 1;
@Input() showEdit: boolean = false;
@Input() showDelete: boolean = false;
@Input() showDetails: boolean = false;
@Input() showAdd: boolean = false;
@Input() isFiltered: boolean = false;
@Input() fullHeight: boolean = false;
@ContentChild('filter', { static: true }) filter!: TemplateRef<any> | null;
@ContentChild('caption', { static: true }) caption!: TemplateRef<any> | null;
@Output() onEdit = new EventEmitter<I>();
@Output() onDelete = new EventEmitter<I>();
@Output() onDetails = new EventEmitter<I>();
@Output() onAdd = new EventEmitter<void>();
@Output() pageChange = new EventEmitter<any>();
filterDrawerVisible = signal<boolean>(false);
ngOnInit() {
if (this.fullHeight) {
this.renderer.setStyle(this.host.nativeElement, 'height', '100cqmin');
}
}
edit = (item: I) => {
this.onEdit.emit(item);
};
remove = (item: I) => {
this.onDelete.emit(item);
};
details = (item: I) => {
this.onDetails.emit(item);
};
openAddForm = () => {
this.onAdd.emit();
};
openFilter = () => {
this.filterDrawerVisible.set(true);
};
closeFilter = () => {
this.filterDrawerVisible.set(false);
};
onPage = ($event: any) => {
this.pageChange.emit($event);
};
get showPaginator() {
return !!(this.totalRecords && this.perPage && this.totalRecords > this.perPage);
}
getCell(item: Record<string, any>, column: IColumn): any {
if (!item || !column) return '';
try {
const { field } = column;
if (column.customDataModel) {
return this.renderCustom(column, item);
}
const data = item[field];
if (data === undefined || data === null) {
return '-';
}
if (typeof data === 'object') {
return data.title || '-';
}
return data || '-';
} catch (e) {
return '-';
}
}
getTemplate(col: IColumn): TemplateRef<any> | null {
const v = col.customDataModel;
return v instanceof TemplateRef ? (v as TemplateRef<any>) : null;
}
renderCustom(column: IColumn, item: any): any {
const v = column.customDataModel;
if (!v) {
return null;
}
if (typeof v === 'function') {
try {
return (v as (item: any) => any)(item);
} catch {
return '-';
}
}
if (typeof v === 'string') return v;
return null;
}
actionsCount = computed(() => {
let totalCount = 0;
if (this.showEdit) totalCount += 1;
if (this.showDelete) totalCount += 1;
if (this.showDetails) totalCount += 1;
return totalCount;
});
}
@@ -0,0 +1,25 @@
<td class="text-center flex items-center gap-1">
@if (showEdit) {
<p-button
size="small"
icon="pi pi-pencil"
variant="outlined"
severity="primary"
(click)="edit.emit()"
title="ویرایش"
/>
}
@if (showDelete) {
<p-button
size="small"
icon="pi pi-trash"
variant="outlined"
severity="danger"
(click)="delete.emit()"
title="حذف"
/>
}
@if (showDetails) {
<p-button size="small" icon="pi pi-chevron-left" variant="outlined" (click)="details.emit()" title="جزئیات" />
}
</td>
@@ -0,0 +1,26 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Button } from 'primeng/button';
@Component({
selector: '[table-action-row]',
standalone: true,
imports: [CommonModule, Button],
templateUrl: './table-action-row.component.html',
})
export class TableActionRowComponent {
@Input() showEdit = false;
@Input() showDelete = false;
@Input() showDetails = false;
@Output() edit = new EventEmitter<void>();
@Output() delete = new EventEmitter<void>();
@Output() details = new EventEmitter<void>();
get widthStyle(): string {
const count = [this.showEdit, this.showDelete, this.showDetails].filter(Boolean).length;
return `${count * 2.5 + count - 1 * 0.25}rem`;
}
widthClass: string = '';
}
@@ -0,0 +1,131 @@
import {
Directive,
ElementRef,
HostListener,
Input,
OnInit,
Optional,
Renderer2,
Self,
} from '@angular/core';
import { NgControl } from '@angular/forms';
@Directive({
selector: '[appPriceMask]',
standalone: true,
})
export class PriceMaskDirective implements OnInit {
@Input('appPriceMaskLocale') locale: string = 'fa-IR';
@Input('appPriceMaskFraction') fraction = 0;
/**
* When true, force comma (",") as thousands separator by using `en-US` formatting.
* If false, uses the provided `appPriceMaskLocale`.
*/
@Input('appPriceMaskUseComma') useComma = false;
private inputEl!: HTMLInputElement | null;
constructor(
private el: ElementRef,
private renderer: Renderer2,
@Optional() @Self() private ngControl: NgControl | null,
) {}
ngOnInit(): void {
// p-inputNumber renders an input inside its host element; try to locate it
const host: HTMLElement = this.el.nativeElement;
this.inputEl =
host.tagName.toLowerCase() === 'input'
? (host as HTMLInputElement)
: host.querySelector('input');
}
private toArabicDigitsAwareNumber(str: string): string {
if (!str) return '';
// Map Arabic-Indic and Extended Arabic-Indic digits to ASCII digits
const map: Record<string, string> = {
'٠': '0',
'١': '1',
'٢': '2',
'٣': '3',
'٤': '4',
'٥': '5',
'٦': '6',
'٧': '7',
'٨': '8',
'٩': '9',
'۰': '0',
'۱': '1',
'۲': '2',
'۳': '3',
'۴': '4',
'۵': '5',
'۶': '6',
'۷': '7',
'۸': '8',
'۹': '9',
};
return str.replace(/[٠-٩۰-۹]/g, (d) => map[d] ?? d);
}
private cleanNumericString(raw: string): string {
// Convert localized digits to ASCII, then remove everything except digits, dot and minus
const ascii = this.toArabicDigitsAwareNumber(raw);
return ascii.replace(/[^0-9.\-]/g, '');
}
private formatNumber(value: number): string {
try {
const fmtLocale = this.useComma ? 'en-US' : this.locale;
return new Intl.NumberFormat(fmtLocale, {
maximumFractionDigits: this.fraction,
minimumFractionDigits: 0,
useGrouping: true,
}).format(value);
} catch (e) {
return String(value);
}
}
@HostListener('input', ['$event'])
onInput(ev: Event) {
if (!this.inputEl) return;
const raw = (ev.target as HTMLInputElement).value || this.inputEl.value || '';
const cleaned = this.cleanNumericString(raw);
if (cleaned === '') {
// clear control
if (this.ngControl?.control) this.ngControl.control.setValue(null);
return;
}
const asNumber = Number(cleaned);
if (isNaN(asNumber)) return;
const formatted = this.formatNumber(asNumber);
// preserve caret position roughly: compute delta and adjust
const prevPos = this.inputEl.selectionStart ?? raw.length;
const prevLen = raw.length;
// Update FormControl value with numeric value first (suppress events)
if (this.ngControl?.control) {
try {
this.ngControl.control.setValue(asNumber, { emitEvent: false });
} catch (err) {
// fallback: ignore
}
}
// Update the displayed value (override any writeValue from control)
this.renderer.setProperty(this.inputEl, 'value', formatted);
try {
const newLen = formatted.length;
let newPos = prevPos + (newLen - prevLen);
if (newPos < 0) newPos = 0;
if (newPos > newLen) newPos = newLen;
this.inputEl.setSelectionRange(newPos, newPos);
} catch (err) {
// ignore selection errors
}
}
}
@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ConfigService } from '../../core/services/config.service';
/**
* Base API service that uses ConfigService for API calls
* Extend this class in your feature services for consistent API access
*/
@Injectable({
providedIn: 'root',
})
export class BaseApiService {
constructor(
protected http: HttpClient,
protected configService: ConfigService,
) {}
/**
* Perform a GET request
* @param endpoint The API endpoint (e.g., '/users')
* @param params Optional query parameters
*/
protected get<T>(endpoint: string, params?: HttpParams): Observable<T> {
const url = this.configService.getApiUrl(endpoint);
return this.http.get<T>(url, { params });
}
/**
* Perform a POST request
* @param endpoint The API endpoint
* @param body Request body
*/
protected post<T>(endpoint: string, body: any): Observable<T> {
const url = this.configService.getApiUrl(endpoint);
return this.http.post<T>(url, body);
}
/**
* Perform a PUT request
* @param endpoint The API endpoint
* @param body Request body
*/
protected put<T>(endpoint: string, body: any): Observable<T> {
const url = this.configService.getApiUrl(endpoint);
return this.http.put<T>(url, body);
}
/**
* Perform a PATCH request
* @param endpoint The API endpoint
* @param body Request body
*/
protected patch<T>(endpoint: string, body: any): Observable<T> {
const url = this.configService.getApiUrl(endpoint);
return this.http.patch<T>(url, body);
}
/**
* Perform a DELETE request
* @param endpoint The API endpoint
*/
protected delete<T>(endpoint: string): Observable<T> {
const url = this.configService.getApiUrl(endpoint);
return this.http.delete<T>(url);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './base-api.service';
+12
View File
@@ -0,0 +1,12 @@
<div
class="relative w-full flex items-center justify-center gap-2 cursor-pointer"
(click)="copy($event)"
aria-label="کپی"
>
<div class="flex items-center gap-2">
<i class="pi pi-copy text-lg"></i>
</div>
{{ text }}
<p-popover #op>کپی شد</p-popover>
</div>
+55
View File
@@ -0,0 +1,55 @@
import { CommonModule } from '@angular/common';
import { Component, Input, ViewChild } from '@angular/core';
import { PopoverModule } from 'primeng/popover';
@Component({
selector: 'uikit-copy',
standalone: true,
imports: [CommonModule, PopoverModule],
templateUrl: './copy.component.html',
})
export class UikitCopyComponent {
@Input() text: string | null = null;
@ViewChild('op') op: any;
copied = false;
async copy(event?: Event) {
try {
const payload = this.text ?? '';
if (navigator && navigator.clipboard && payload !== '') {
await navigator.clipboard.writeText(payload);
this.showCopiedTooltip(event);
}
} catch (err) {
// fallback: try execCommand (older browsers) or ignore
try {
const textarea = document.createElement('textarea');
textarea.value = this.text ?? '';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
this.showCopiedTooltip(event);
} catch (e) {
console.error('Copy failed', e);
}
}
}
private showCopiedTooltip(event?: Event) {
this.copied = true;
try {
this.op?.show(event);
} catch (e) {
// ignore if popover not available
}
setTimeout(() => {
try {
this.op?.hide();
} catch (_) {}
this.copied = false;
}, 1400);
}
}
@@ -0,0 +1,20 @@
<uikit-field [label]="label" [control]="control" class="w-full datepicker">
<div #wrapper [attr.data-disable]="disabled" [attr.data-name]="name" class="w-full">
<input pInputText data-input [attr.placeholder]="placeholder" [name]="name" class="w-full" />
</div>
<div class="flat"></div>
</uikit-field>
<!--
<div #wrapper class="flex items-center gap-2" data-enable-time="false" data-click-opens="true">
<input pInputText class="w-full bg-white text-sm rounded border p-2" data-input [attr.placeholder]="placeholder" />
<button pButton type="button" class="p-button-text text-gray-500" data-toggle aria-label="toggle">
<i class="pi pi-calendar"></i>
</button>
<button pButton type="button" class="p-button-text text-gray-500" data-clear aria-label="clear">
<i class="pi pi-times"></i>
</button>
</div> -->
@@ -0,0 +1,48 @@
import { Maybe } from '@/core';
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import flatpickr from 'flatpickr-wrap';
import { BaseOptions } from 'flatpickr-wrap/dist/types/options';
import { InputTextModule } from 'primeng/inputtext';
import { UikitFieldComponent } from '../uikit-field.component';
@Component({
selector: 'uikit-datepicker',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, InputTextModule, UikitFieldComponent],
templateUrl: './datepicker.component.html',
})
export class UikitFlatpickrJalaliComponent implements OnInit {
@Input() control?: FormControl<Maybe<string>>;
@Input() placeholder = 'انتخاب تاریخ';
@Input() label = 'تاریخ';
@Input() disabled = false;
@Input() name: string = 'datepicker';
@Input() options: Partial<BaseOptions> = {};
@Output() valueChange = new EventEmitter<string>();
@ViewChild('wrapper', { static: true }) wrapperRef!: ElementRef<HTMLElement>;
private fpInstance: any | null = null;
ngOnInit(): void {
this.fpInstance = flatpickr(this.wrapperRef.nativeElement, {
...this.options,
altInputClass: 'p-inputtext w-full',
onChange: (selectedDates: Date[], dateStr: string) => {
this.valueChange.emit(dateStr);
if (this.control) this.control.setValue(dateStr);
},
});
}
}
@@ -0,0 +1,30 @@
<uikit-field [label]="label" [name]="name" [control]="control">
<div class="grid grid-cols-2 items-center gap-4">
<p-autoComplete
[formControl]="hoursCtrl"
[suggestions]="filteredHours"
(completeMethod)="filterHours($event)"
[dropdown]="true"
[forceSelection]="false"
placeholder="ساعت"
inputClass="w-full"
type="number"
></p-autoComplete>
<p-autoComplete
[formControl]="minutesCtrl"
[suggestions]="filteredMinutes"
(completeMethod)="filterMinutes($event)"
[dropdown]="true"
[forceSelection]="false"
placeholder="دقیقه"
type="number"
inputClass="w-full"
></p-autoComplete>
</div>
@if (hoursCtrl.value || minutesCtrl.value) {
<span class="text-xs mt-1 text-muted-color">
مدت زمان آزمون را {{ hoursCtrl.value || 0 }} ساعت و {{ minutesCtrl.value || 0 }} دقیقه انتخاب کردید.
</span>
}
</uikit-field>
@@ -0,0 +1,93 @@
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { AutoCompleteModule } from 'primeng/autocomplete';
@Component({
selector: 'uikit-duration',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, AutoCompleteModule, UikitFieldComponent],
templateUrl: './duration-picker.component.html',
})
export class UikitDurationPickerComponent implements OnInit {
@Input() label = 'مدت زمان';
@Input() name = 'duration';
@Input() control!: FormControl<string | null>;
hoursCtrl = new FormControl<string | null>('0');
minutesCtrl = new FormControl<string | null>('0');
hoursOptions: string[] = Array.from({ length: 24 }, (_, i) => String(i));
minutesOptions: string[] = ['0', '5', '10', '15', '20', '30', '45'];
filteredHours: string[] = [];
filteredMinutes: string[] = [];
ngOnInit(): void {
// initialize from parent control if present
if (this.control) {
const total = Number(this.control.value) || 0;
const h = Math.floor(total / 60);
const m = total % 60;
this.hoursCtrl.setValue(String(h));
this.minutesCtrl.setValue(String(m));
this.hoursCtrl.valueChanges.subscribe(() => {
if (this.toNumberSafe(this.hoursCtrl.value) > 23) {
this.hoursCtrl.setValue('23', { emitEvent: false });
}
this.syncToParent();
});
this.minutesCtrl.valueChanges.subscribe(() => {
if (this.toNumberSafe(this.minutesCtrl.value) > 59) {
this.minutesCtrl.setValue('59', { emitEvent: false });
}
this.syncToParent();
});
this.control.valueChanges.subscribe((v) => this.syncFromParent(v));
} else {
this.hoursCtrl.valueChanges.subscribe(() => this.syncToParent());
this.minutesCtrl.valueChanges.subscribe(() => this.syncToParent());
}
}
private toNumberSafe(s: string | null | undefined) {
if (s == null || s === '') return 0;
const n = Number(s);
return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
}
syncToParent() {
if (!this.control) return;
const h = this.toNumberSafe(this.hoursCtrl.value);
const m = this.toNumberSafe(this.minutesCtrl.value);
const total = h * 60 + m;
// write numeric minutes into parent control only when changed
const current = this.control.value;
if (String(current) !== String(total)) {
this.control.setValue(String(total), { emitEvent: false });
}
}
syncFromParent(v: any) {
const total = Number(v) || 0;
const h = Math.floor(total / 60);
const m = total % 60;
if (String(h) !== this.hoursCtrl.value)
this.hoursCtrl.setValue(String(h), { emitEvent: false });
if (String(m) !== this.minutesCtrl.value)
this.minutesCtrl.setValue(String(m), { emitEvent: false });
}
filterHours(event: { query: string }) {
const q = (event.query || '').toString();
this.filteredHours = this.hoursOptions.filter((x) => x.startsWith(q));
}
filterMinutes(event: { query: string }) {
const q = (event.query || '').toString();
this.filteredMinutes = this.minutesOptions.filter((x) => x.startsWith(q));
}
}
+7
View File
@@ -0,0 +1,7 @@
export * from './copy/copy.component';
export * from './datepicker/datepicker.component';
export * from './duration-picker/duration-picker.component';
export * from './paginator.component';
export * from './uikit-emptystate.component';
export * from './uikit-field.component';
export * from './uikit-label.component';

Some files were not shown because too many files have changed in this diff Show More