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
+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' });
}
}