diff --git a/myteamwallet_frontend_modern/src/app/app.routes.ts b/myteamwallet_frontend_modern/src/app/app.routes.ts index fdf4306..c2b0d30 100644 --- a/myteamwallet_frontend_modern/src/app/app.routes.ts +++ b/myteamwallet_frontend_modern/src/app/app.routes.ts @@ -123,6 +123,11 @@ export const routes: Routes = [ loadComponent: () => import('./features/team/more/guide/guide').then((m) => m.Guide), }, + { + path: 'notifications', + loadComponent: () => + import('./features/team/notifications/notifications').then((m) => m.Notifications), + }, ], }, { diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html new file mode 100644 index 0000000..c2e5252 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.html @@ -0,0 +1,29 @@ +
+

Benachrichtigungen

+ + @if (items().length === 0 && !loading()) { +

Keine Benachrichtigungen vorhanden.

+ } + + + @for (item of items(); track item.id) { + + {{ notificationIcon(item) }} + {{ notificationLabel(item) }} + + } + + + @if (loading()) { + + } + + @if (hasNextPage() && !loading()) { + + } +
diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss new file mode 100644 index 0000000..ff6fc09 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.scss @@ -0,0 +1,15 @@ +.notifications-page { + padding: 1rem; + + &__empty { + color: var(--mat-sys-on-surface-variant); + } + + &__item--unread { + font-weight: 600; + } + + &__spinner { + margin: 1rem auto; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts new file mode 100644 index 0000000..1208efe --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { BehaviorSubject, of } from 'rxjs'; +import { Notifications } from './notifications'; +import { NotificationsApi } from '../../../core/notifications/notifications-api'; +import { NotificationsStore } from '../../../core/notifications/notifications-store'; +import { NotificationItem } from '../../../models/notification.model'; + +describe('Notifications', () => { + let routeParams: BehaviorSubject; + let fixture: ComponentFixture; + let api: { loadNotifications: ReturnType }; + let store: { markRead: ReturnType }; + + const item: NotificationItem = { + id: 1, + event: 'player_creation', + actorUserId: 9, + payload: { playerId: 21, playerName: 'Ada Lovelace' }, + read: false, + createdAt: '2026-08-04T10:00:00.000Z', + }; + + beforeEach(async () => { + routeParams = new BehaviorSubject(convertToParamMap({ id: '5' })); + api = { loadNotifications: vi.fn() }; + store = { markRead: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [Notifications], + providers: [ + provideRouter([]), + { provide: NotificationsApi, useValue: api }, + { provide: NotificationsStore, useValue: store }, + { provide: ActivatedRoute, useValue: { parent: { paramMap: routeParams } } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(Notifications); + }); + + it('loads the first page for the routed team id', () => { + api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false })); + + fixture.detectChanges(); + + expect(api.loadNotifications).toHaveBeenCalledWith(5, { page: 1, limit: 20 }); + expect((fixture.componentInstance as any).items()).toEqual([item]); + }); + + it('loads the next page and appends results', () => { + api.loadNotifications + .mockReturnValueOnce(of({ data: [item], page: 1, limit: 20, total: 21, hasNextPage: true })) + .mockReturnValueOnce(of({ data: [{ ...item, id: 2 }], page: 2, limit: 20, total: 21, hasNextPage: false })); + + fixture.detectChanges(); + (fixture.componentInstance as any).loadMore(); + + expect(api.loadNotifications).toHaveBeenLastCalledWith(5, { page: 2, limit: 20 }); + expect((fixture.componentInstance as any).items().length).toBe(2); + }); + + it('marks a clicked item as read and navigates to its target', () => { + api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false })); + fixture.detectChanges(); + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate'); + + (fixture.componentInstance as any).onItemClick(item); + + expect(store.markRead).toHaveBeenCalledWith(5, 1); + expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts new file mode 100644 index 0000000..9098357 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/notifications/notifications.ts @@ -0,0 +1,92 @@ +import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { NotificationItem } from '../../../models/notification.model'; +import { NotificationsApi } from '../../../core/notifications/notifications-api'; +import { NotificationsStore } from '../../../core/notifications/notifications-store'; +import { + notificationIcon, + notificationLabel, + notificationTarget, +} from '../../../core/notifications/notification-presentation'; + +const PAGE_SIZE = 20; + +@Component({ + selector: 'app-notifications', + imports: [MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule], + templateUrl: './notifications.html', + styleUrl: './notifications.scss', +}) +export class Notifications implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly api = inject(NotificationsApi); + private readonly notificationsStore = inject(NotificationsStore); + private readonly destroyRef = inject(DestroyRef); + + protected readonly items = signal([]); + protected readonly loading = signal(false); + protected readonly hasNextPage = signal(false); + + private teamId: number | null = null; + private page = 1; + + ngOnInit(): void { + const parentRoute = this.route.parent; + if (!parentRoute) return; + + parentRoute.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + const raw = params.get('id'); + const id = raw === null ? Number.NaN : Number(raw); + if (Number.isInteger(id) && id > 0 && id !== this.teamId) { + this.teamId = id; + this.page = 1; + this.items.set([]); + this.hasNextPage.set(false); + this.loadPage(); + } + }); + } + + protected notificationLabel(item: NotificationItem): string { + return notificationLabel(item); + } + + protected notificationIcon(item: NotificationItem): string { + return notificationIcon(item.event); + } + + protected loadMore(): void { + this.page += 1; + this.loadPage(); + } + + protected onItemClick(item: NotificationItem): void { + if (this.teamId === null) return; + const teamId = this.teamId; + this.notificationsStore.markRead(teamId, item.id); + this.items.update((current) => + current.map((entry) => (entry.id === item.id ? { ...entry, read: true } : entry)), + ); + void this.router.navigate(notificationTarget(item, teamId)); + } + + private loadPage(): void { + if (this.teamId === null) return; + const teamId = this.teamId; + this.loading.set(true); + this.api.loadNotifications(teamId, { page: this.page, limit: PAGE_SIZE }).subscribe({ + next: (result) => { + this.items.update((current) => [...current, ...result.data]); + this.hasNextPage.set(result.hasNextPage); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } +}