feat: implement tenant branding system and global toast notification service
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
@if (status() === 'ready') {
|
||||
<router-outlet />
|
||||
<app-toast-container />
|
||||
} @else if (status() === 'not-found') {
|
||||
<section class="tenant-status">
|
||||
<div class="tenant-status__card">
|
||||
|
||||
@@ -15,6 +15,7 @@ const tenant: Tenant = {
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
success_color: '#198754',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
|
||||
@@ -14,6 +14,7 @@ const tenant: Tenant = {
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
success_color: '#198754',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RouterOutlet } from '@angular/router';
|
||||
|
||||
import { TenantService } from './core/services/tenant.service';
|
||||
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
||||
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const cleanHex = hex.replace('#', '').trim();
|
||||
@@ -21,16 +22,18 @@ function hexToRgb(hex: string): string {
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, ToastContainerComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
'[style.--tenant-primary]': 'tenantPrimaryColor()',
|
||||
'[style.--tenant-secondary]': 'tenantSecondaryColor()',
|
||||
'[style.--tenant-danger]': 'tenantDangerColor()',
|
||||
'[style.--tenant-success]': 'tenantSuccessColor()',
|
||||
'[style.--tenant-primary-rgb]': 'tenantPrimaryColorRgb()',
|
||||
'[style.--tenant-secondary-rgb]': 'tenantSecondaryColorRgb()',
|
||||
'[style.--tenant-danger-rgb]': 'tenantDangerColorRgb()',
|
||||
'[style.--tenant-success-rgb]': 'tenantSuccessColorRgb()',
|
||||
'[style.--tenant-header-footer-bg]': 'tenantHeaderFooterBgColor()'
|
||||
}
|
||||
})
|
||||
@@ -48,6 +51,9 @@ export class App {
|
||||
protected readonly tenantDangerColor = computed(
|
||||
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor
|
||||
);
|
||||
protected readonly tenantSuccessColor = computed(
|
||||
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor
|
||||
);
|
||||
protected readonly tenantPrimaryColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantPrimaryColor())
|
||||
);
|
||||
@@ -57,6 +63,9 @@ export class App {
|
||||
protected readonly tenantDangerColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantDangerColor())
|
||||
);
|
||||
protected readonly tenantSuccessColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantSuccessColor())
|
||||
);
|
||||
protected readonly tenantHeaderFooterBgColor = computed(
|
||||
() =>
|
||||
this.tenantService.tenant()?.header_footer_bg_color ??
|
||||
|
||||
@@ -14,6 +14,7 @@ const tenant: Tenant = {
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
success_color: '#198754',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DEFAULT_TENANT_BRANDING = {
|
||||
primaryColor: '#102bda',
|
||||
secondaryColor: '#9e9e9e',
|
||||
dangerColor: '#fd4c4c',
|
||||
successColor: '#198754',
|
||||
headerFooterBgColor: '#313131'
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Tenant {
|
||||
primary_color: string;
|
||||
secondary_color: string;
|
||||
danger_color: string;
|
||||
success_color: string;
|
||||
header_footer_bg_color: string;
|
||||
header_logo: string;
|
||||
footer_logo: string;
|
||||
|
||||
@@ -16,6 +16,7 @@ const tenant: Tenant = {
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
success_color: '#198754',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
|
||||
101
src/app/core/services/toast.service.spec.ts
Normal file
101
src/app/core/services/toast.service.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
describe('ToastService', () => {
|
||||
let service: ToastService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ToastService]
|
||||
});
|
||||
service = TestBed.inject(ToastService);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with an empty list of toasts', () => {
|
||||
expect(service.toasts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should show a toast and assign an id', () => {
|
||||
const id = service.show('Hello world', 'info');
|
||||
expect(id).toBeTruthy();
|
||||
expect(service.toasts().length).toBe(1);
|
||||
expect(service.toasts()[0]).toEqual({
|
||||
id,
|
||||
message: 'Hello world',
|
||||
type: 'info',
|
||||
duration: 3000
|
||||
});
|
||||
});
|
||||
|
||||
it('should add specific variant toasts via helper methods', () => {
|
||||
const infoId = service.info('Info message');
|
||||
const dangerId = service.danger('Danger message', 5000);
|
||||
const successId = service.success('Success message', 0);
|
||||
|
||||
const toasts = service.toasts();
|
||||
expect(toasts.length).toBe(3);
|
||||
|
||||
expect(toasts.find((t) => t.id === infoId)).toEqual({
|
||||
id: infoId,
|
||||
message: 'Info message',
|
||||
type: 'info',
|
||||
duration: 3000
|
||||
});
|
||||
|
||||
expect(toasts.find((t) => t.id === dangerId)).toEqual({
|
||||
id: dangerId,
|
||||
message: 'Danger message',
|
||||
type: 'danger',
|
||||
duration: 5000
|
||||
});
|
||||
|
||||
expect(toasts.find((t) => t.id === successId)).toEqual({
|
||||
id: successId,
|
||||
message: 'Success message',
|
||||
type: 'success',
|
||||
duration: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should dismiss a toast manually by id', () => {
|
||||
const id1 = service.show('Toast 1');
|
||||
const id2 = service.show('Toast 2');
|
||||
|
||||
expect(service.toasts().length).toBe(2);
|
||||
|
||||
service.dismiss(id1);
|
||||
|
||||
expect(service.toasts().length).toBe(1);
|
||||
expect(service.toasts()[0].id).toBe(id2);
|
||||
});
|
||||
|
||||
it('should automatically dismiss a toast after the duration', () => {
|
||||
service.show('Auto dismiss toast', 'info', 2000);
|
||||
|
||||
expect(service.toasts().length).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
expect(service.toasts().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not dismiss a toast automatically if duration is 0', () => {
|
||||
service.show('Persistent toast', 'info', 0);
|
||||
|
||||
expect(service.toasts().length).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
|
||||
expect(service.toasts().length).toBe(1);
|
||||
});
|
||||
});
|
||||
47
src/app/core/services/toast.service.ts
Normal file
47
src/app/core/services/toast.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'danger' | 'info';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ToastService {
|
||||
private readonly toastsSignal = signal<Toast[]>([]);
|
||||
readonly toasts = this.toastsSignal.asReadonly();
|
||||
|
||||
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const newToast: Toast = { id, message, type, duration };
|
||||
|
||||
this.toastsSignal.update((toasts) => [...toasts, newToast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.dismiss(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
info(message: string, duration = 3000): string {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
|
||||
danger(message: string, duration = 3000): string {
|
||||
return this.show(message, 'danger', duration);
|
||||
}
|
||||
|
||||
success(message: string, duration = 3000): string {
|
||||
return this.show(message, 'success', duration);
|
||||
}
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
<div class="button-grid">
|
||||
<app-button [disabled]="true">Primary</app-button>
|
||||
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
|
||||
|
||||
<app-button variant="danger" [disabled]="true">Danger</app-button>
|
||||
<app-button variant="danger-secondary" [disabled]="true">Danger secondary</app-button>
|
||||
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
|
||||
@@ -40,6 +41,15 @@
|
||||
<app-button variant="cancel">E</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<h2>Toasts</h2>
|
||||
<div class="button-grid">
|
||||
<app-button (click)="triggerToast('info')">Trigger Info</app-button>
|
||||
<app-button variant="secondary" (click)="triggerToast('success')">Trigger Success</app-button>
|
||||
<app-button variant="danger" (click)="triggerToast('danger')">Trigger Danger</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider" />
|
||||
@@ -237,7 +247,7 @@
|
||||
<div class="colors-section">
|
||||
<h3 class="mb-3">Colores de Marca</h3>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="color-swatch-card shadow-sm">
|
||||
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-primary)'"></div>
|
||||
<div class="color-swatch-card__body">
|
||||
@@ -247,7 +257,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="color-swatch-card shadow-sm">
|
||||
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-secondary)'"></div>
|
||||
<div class="color-swatch-card__body">
|
||||
@@ -257,7 +267,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="color-swatch-card shadow-sm">
|
||||
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-danger)'"></div>
|
||||
<div class="color-swatch-card__body">
|
||||
@@ -266,6 +276,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="color-swatch-card shadow-sm">
|
||||
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-success)'"></div>
|
||||
<div class="color-swatch-card__body">
|
||||
<span class="color-swatch-card__title">Success</span>
|
||||
<code class="color-swatch-card__value">{{ tenant()?.success_color || 'Default' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ const tenant: Tenant = {
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
success_color: '#198754',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
@@ -63,6 +64,7 @@ describe('ReutilizablesTestPageComponent', () => {
|
||||
expect(element.textContent).toContain(tenant.primary_color);
|
||||
expect(element.textContent).toContain(tenant.secondary_color);
|
||||
expect(element.textContent).toContain(tenant.danger_color);
|
||||
expect(element.textContent).toContain(tenant.success_color);
|
||||
});
|
||||
|
||||
it('renders the paginator demo with the initial page status', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PaginatorComponent } from '../../../../shared/components/paginator/pagi
|
||||
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
|
||||
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
||||
import { TenantService } from '../../../../core/services/tenant.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reutilizables-test-page',
|
||||
@@ -14,6 +15,7 @@ import { TenantService } from '../../../../core/services/tenant.service';
|
||||
})
|
||||
export class ReutilizablesTestPageComponent {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
protected readonly tenant = this.tenantService.tenant;
|
||||
protected textValue = 'Auriculares';
|
||||
protected numberValue = '24';
|
||||
@@ -70,5 +72,14 @@ export class ReutilizablesTestPageComponent {
|
||||
this.fileName = '';
|
||||
this.clearTrigger += 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
|
||||
if (type === 'success') {
|
||||
this.toastService.success('¡Operación exitosa! El producto se ha guardado correctamente.');
|
||||
} else if (type === 'danger') {
|
||||
this.toastService.danger('¡Alerta de error! Hubo un problema al procesar la solicitud.');
|
||||
} else if (type === 'info') {
|
||||
this.toastService.info('Información del sistema: Hay una nueva actualización disponible.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
@for (toast of toasts(); track toast.id) {
|
||||
<div
|
||||
class="toast show align-items-center border-0 shadow-sm"
|
||||
[ngClass]="getToastBgClass(toast.type)"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="d-flex p-2">
|
||||
<div class="toast-body d-flex align-items-center flex-grow-1">
|
||||
<i [class]="getToastIconClass(toast.type)" class="me-2 fs-5"></i>
|
||||
<span>{{ toast.message }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close btn-close-white me-2 m-auto"
|
||||
aria-label="Cerrar"
|
||||
(click)="dismiss(toast.id)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
.toast-container {
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
min-width: 300px;
|
||||
max-width: 450px;
|
||||
border-radius: 8px;
|
||||
animation: toast-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.15), 0 4px 6px -4px rgba(0, 0, 0, 0.15) !important;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { NgClass } from '@angular/common';
|
||||
import { ToastService, Toast } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast-container',
|
||||
imports: [NgClass],
|
||||
templateUrl: './toast-container.component.html',
|
||||
styleUrl: './toast-container.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ToastContainerComponent {
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
readonly toasts = this.toastService.toasts;
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.toastService.dismiss(id);
|
||||
}
|
||||
|
||||
getToastIconClass(type: Toast['type']): string {
|
||||
const icons: Record<Toast['type'], string> = {
|
||||
success: 'fa-solid fa-circle-check',
|
||||
danger: 'fa-solid fa-circle-exclamation',
|
||||
info: 'fa-solid fa-circle-info'
|
||||
};
|
||||
return icons[type] || 'fa-solid fa-bell';
|
||||
}
|
||||
|
||||
getToastBgClass(type: Toast['type']): string {
|
||||
const classes: Record<Toast['type'], string> = {
|
||||
success: 'text-bg-success',
|
||||
danger: 'text-bg-danger',
|
||||
info: 'text-bg-primary'
|
||||
};
|
||||
return classes[type] || 'text-bg-primary';
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
--bs-secondary-rgb: var(--tenant-secondary-rgb);
|
||||
--bs-danger: var(--tenant-danger);
|
||||
--bs-danger-rgb: var(--tenant-danger-rgb);
|
||||
--bs-success: var(--tenant-success);
|
||||
--bs-success-rgb: var(--tenant-success-rgb);
|
||||
}
|
||||
|
||||
label {
|
||||
|
||||
Reference in New Issue
Block a user