diff --git a/src/app/app.html b/src/app/app.html index 210e47b..13bafce 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,5 +1,6 @@ @if (status() === 'ready') { + } @else if (status() === 'not-found') {
diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index 6d2917f..b864ad5 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -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' diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 72268d3..0547b4b 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -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' diff --git a/src/app/app.ts b/src/app/app.ts index 56774b9..9a7ee44 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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 ?? diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index 1f3e904..c0d04a6 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -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' diff --git a/src/app/core/services/tenant-ssr-cache.store.ts b/src/app/core/services/tenant-ssr-cache.store.ts index 417dbc8..fcb5688 100644 --- a/src/app/core/services/tenant-ssr-cache.store.ts +++ b/src/app/core/services/tenant-ssr-cache.store.ts @@ -17,6 +17,7 @@ export const DEFAULT_TENANT_BRANDING = { primaryColor: '#102bda', secondaryColor: '#9e9e9e', dangerColor: '#fd4c4c', + successColor: '#198754', headerFooterBgColor: '#313131' } as const; diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index f884e5c..11801d9 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -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; diff --git a/src/app/core/services/tenant.service.spec.ts b/src/app/core/services/tenant.service.spec.ts index 3695597..158fafc 100644 --- a/src/app/core/services/tenant.service.spec.ts +++ b/src/app/core/services/tenant.service.spec.ts @@ -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' diff --git a/src/app/core/services/toast.service.spec.ts b/src/app/core/services/toast.service.spec.ts new file mode 100644 index 0000000..cb28a55 --- /dev/null +++ b/src/app/core/services/toast.service.spec.ts @@ -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); + }); +}); diff --git a/src/app/core/services/toast.service.ts b/src/app/core/services/toast.service.ts new file mode 100644 index 0000000..bcdd4e4 --- /dev/null +++ b/src/app/core/services/toast.service.ts @@ -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([]); + 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)); + } +} diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index 37111d3..b81c780 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -24,6 +24,7 @@
Primary Secondary + Danger Danger secondary Cancel @@ -40,6 +41,15 @@ E
+ +
+

Toasts

+
+ Trigger Info + Trigger Success + Trigger Danger +
+

@@ -237,7 +247,7 @@

Colores de Marca

-
+
@@ -247,7 +257,7 @@
-
+
@@ -257,7 +267,7 @@
-
+
@@ -266,6 +276,16 @@
+ +
+
+
+
+ Success + {{ tenant()?.success_color || 'Default' }} +
+
+
diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index 82d081a..c840336 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -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 () => { diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 9e896ec..018e8fe 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -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.'); + } + } +} diff --git a/src/app/shared/components/toast-container/toast-container.component.html b/src/app/shared/components/toast-container/toast-container.component.html new file mode 100644 index 0000000..51b3fa9 --- /dev/null +++ b/src/app/shared/components/toast-container/toast-container.component.html @@ -0,0 +1,24 @@ +
+ @for (toast of toasts(); track toast.id) { + + } +
diff --git a/src/app/shared/components/toast-container/toast-container.component.scss b/src/app/shared/components/toast-container/toast-container.component.scss new file mode 100644 index 0000000..9a6c83f --- /dev/null +++ b/src/app/shared/components/toast-container/toast-container.component.scss @@ -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); + } +} diff --git a/src/app/shared/components/toast-container/toast-container.component.ts b/src/app/shared/components/toast-container/toast-container.component.ts new file mode 100644 index 0000000..a14596b --- /dev/null +++ b/src/app/shared/components/toast-container/toast-container.component.ts @@ -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 = { + 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 = { + success: 'text-bg-success', + danger: 'text-bg-danger', + info: 'text-bg-primary' + }; + return classes[type] || 'text-bg-primary'; + } +} diff --git a/src/styles.scss b/src/styles.scss index c67ee4d..0cbe8af 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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 {