diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 3fe03c2..50f874a 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -2,6 +2,7 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER, + ErrorHandler, } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http'; @@ -9,12 +10,17 @@ import { appRoutes } from './app.routes'; import { AuthService } from './services/auth.service'; import { AuthInterceptor } from './interceptors/auth.interceptor'; import { initializeAuth } from './initializers/auth.initializer'; +import { GlobalErrorHandler } from './services/global-error-handler.service'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(appRoutes), provideHttpClient(), + { + provide: ErrorHandler, + useClass: GlobalErrorHandler + }, { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index 4bdc0fb..f447414 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -3,3 +3,4 @@ + diff --git a/apps/frontend/src/app/app.ts b/apps/frontend/src/app/app.ts index 88e48d6..7826492 100644 --- a/apps/frontend/src/app/app.ts +++ b/apps/frontend/src/app/app.ts @@ -2,10 +2,11 @@ import { Component, inject, OnInit } from '@angular/core'; import { RouterModule } from '@angular/router'; import { HeaderComponent } from './components/header/header.component'; import { FooterComponent } from './components/footer/footer.component'; +import { ToastComponent } from './components/toast/toast.component'; import { AnalyticsService } from './services/analytics.service'; @Component({ - imports: [RouterModule, HeaderComponent, FooterComponent], + imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent], selector: 'app-root', templateUrl: './app.html', styleUrl: './app.scss', diff --git a/apps/frontend/src/app/components/toast/toast.component.css b/apps/frontend/src/app/components/toast/toast.component.css new file mode 100644 index 0000000..da88bc6 --- /dev/null +++ b/apps/frontend/src/app/components/toast/toast.component.css @@ -0,0 +1,91 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +.toast-container { + position: fixed; + top: 80px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 400px; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + animation: slideIn 0.3s ease-out; + border: 2px solid; + background-color: var(--witch-purple); + color: var(--moon-white); +} + +.toast-error { + border-color: #ff4444; + background-color: rgba(255, 68, 68, 0.4); +} + +.toast-success { + border-color: #44ff88; + background-color: rgba(68, 255, 136, 0.4); +} + +.toast-info { + border-color: var(--witch-lavender); + background-color: rgba(200, 162, 200, 0.4); +} + +.toast-warning { + border-color: #ffaa44; + background-color: rgba(255, 170, 68, 0.4); +} + +.toast-icon { + font-size: 20px; + flex-shrink: 0; +} + +.toast-message { + flex: 1; + word-wrap: break-word; +} + +.toast-close { + background: none; + border: none; + color: var(--moon-white); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.toast-close:hover { + transform: scale(1.2); +} + +@keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/apps/frontend/src/app/components/toast/toast.component.html b/apps/frontend/src/app/components/toast/toast.component.html new file mode 100644 index 0000000..8ed978b --- /dev/null +++ b/apps/frontend/src/app/components/toast/toast.component.html @@ -0,0 +1,22 @@ + + +
+ @for (toast of toastService.toastList(); track toast.id) { +
+
+ @switch (toast.type) { + @case ('error') { ❌ } + @case ('success') { ✅ } + @case ('info') { ℹ️ } + @case ('warning') { ⚠️ } + } +
+
{{ toast.message }}
+ +
+ } +
diff --git a/apps/frontend/src/app/components/toast/toast.component.ts b/apps/frontend/src/app/components/toast/toast.component.ts new file mode 100644 index 0000000..611df14 --- /dev/null +++ b/apps/frontend/src/app/components/toast/toast.component.ts @@ -0,0 +1,18 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, inject } from '@angular/core'; +import { ToastService } from '../../services/toast.service'; + +@Component({ + selector: 'app-toast', + standalone: true, + templateUrl: './toast.component.html', + styleUrls: ['./toast.component.css'] +}) +export class ToastComponent { + public toastService = inject(ToastService); +} diff --git a/apps/frontend/src/app/interceptors/auth.interceptor.ts b/apps/frontend/src/app/interceptors/auth.interceptor.ts index c61bcf3..8b8b4d4 100644 --- a/apps/frontend/src/app/interceptors/auth.interceptor.ts +++ b/apps/frontend/src/app/interceptors/auth.interceptor.ts @@ -16,11 +16,13 @@ import { import { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs'; import { Router } from '@angular/router'; import { environment } from '../../environments/environment'; +import { ToastService } from '../services/toast.service'; @Injectable() export class AuthInterceptor implements HttpInterceptor { private router = inject(Router); private http = inject(HttpClient); + private toast = inject(ToastService); private isRefreshing = false; private refreshTokenSubject = new BehaviorSubject(null); @@ -36,6 +38,9 @@ export class AuthInterceptor implements HttpInterceptor { if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) { return this.handle401Error(authReq, next); } + + // Show toast for other HTTP errors + this.showErrorToast(error); return throwError(() => error); }) ); @@ -59,6 +64,7 @@ export class AuthInterceptor implements HttpInterceptor { catchError((err) => { this.isRefreshing = false; this.refreshTokenSubject.next(false); + this.toast.error('Your session has expired. Please log in again.'); this.router.navigate(['/']); return throwError(() => err); }) @@ -76,4 +82,28 @@ export class AuthInterceptor implements HttpInterceptor { }) ); } + + private showErrorToast(error: HttpErrorResponse): void { + let message = 'Something went wrong. Please try again.'; + + switch (error.status) { + case 400: + message = error.error?.message || 'Invalid request. Please check your input.'; + break; + case 403: + message = 'You do not have permission to perform this action.'; + break; + case 404: + message = 'Resource not found.'; + break; + case 500: + message = 'Server error. Please try again later.'; + break; + case 0: + message = 'Network error. Please check your connection.'; + break; + } + + this.toast.error(message); + } } \ No newline at end of file diff --git a/apps/frontend/src/app/services/global-error-handler.service.ts b/apps/frontend/src/app/services/global-error-handler.service.ts new file mode 100644 index 0000000..d798e38 --- /dev/null +++ b/apps/frontend/src/app/services/global-error-handler.service.ts @@ -0,0 +1,43 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { ErrorHandler, Injectable, inject } from '@angular/core'; +import { ToastService } from './toast.service'; + +@Injectable() +export class GlobalErrorHandler implements ErrorHandler { + private toast = inject(ToastService); + + handleError(error: Error): void { + console.error('Global error caught:', error); + + // Show user-friendly error message + const message = this.getUserFriendlyMessage(error); + this.toast.error(message); + } + + private getUserFriendlyMessage(error: Error): string { + // Check for common error types + if (error.message.includes('Http failure')) { + return 'Network error. Please check your connection.'; + } + + if (error.message.includes('401') || error.message.includes('403')) { + return 'Your session has expired. Please refresh the page.'; + } + + if (error.message.includes('404')) { + return 'Resource not found.'; + } + + if (error.message.includes('500')) { + return 'Server error. Please try again later.'; + } + + // Generic error message + return 'Something went wrong. Please try again.'; + } +} diff --git a/apps/frontend/src/app/services/toast.service.ts b/apps/frontend/src/app/services/toast.service.ts new file mode 100644 index 0000000..a69e8af --- /dev/null +++ b/apps/frontend/src/app/services/toast.service.ts @@ -0,0 +1,70 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, signal } from '@angular/core'; + +export interface Toast { + id: number; + message: string; + type: 'error' | 'success' | 'info' | 'warning'; + duration: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class ToastService { + private toasts = signal([]); + public readonly toastList = this.toasts.asReadonly(); + private nextId = 0; + + /** + * Show an error toast notification. + */ + error(message: string, duration = 5000): void { + this.addToast(message, 'error', duration); + } + + /** + * Show a success toast notification. + */ + success(message: string, duration = 3000): void { + this.addToast(message, 'success', duration); + } + + /** + * Show an info toast notification. + */ + info(message: string, duration = 3000): void { + this.addToast(message, 'info', duration); + } + + /** + * Show a warning toast notification. + */ + warning(message: string, duration = 4000): void { + this.addToast(message, 'warning', duration); + } + + /** + * Remove a toast by ID. + */ + remove(id: number): void { + this.toasts.update(toasts => toasts.filter(t => t.id !== id)); + } + + private addToast(message: string, type: Toast['type'], duration: number): void { + const id = this.nextId++; + const toast: Toast = { id, message, type, duration }; + + this.toasts.update(toasts => [...toasts, toast]); + + // Auto-remove after duration + setTimeout(() => { + this.remove(id); + }, duration); + } +}