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