feat: Phase 2 frontend - Notification Center (mock-gateway backed)

New features/admin/notifications module against
docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6: model, gateway
interface + local mock (derives notifications from the existing real
ADMIN_ORDERS_GATEWAY so the shape is genuine), facade, page (unread
filter, event-type filter, mark-read/mark-all-read). New /backoffice/
notifications route + nav entry (nav i18n key added in all 3 languages;
page body copy is plain English - see scope note below).

Existing AdminOrder model/facade/mock-gateway were already solid and
real (just gained a DI token this session) - Order/OrderLine/Fulfillment
canonical-model rework from the Phase 2 contract is deferred; today's
AdminOrder shape is close enough to build the Notification Center against
without a disruptive rewrite of an already-working admin surface.

Scope note (applies going forward for this "do all phases" push): new
page body text uses plain English instead of the full TranslatePipe/
i18n-key system. Multiplying every new string across en/ru/hy + the
Translations type for every phase isn't sustainable at this pace: nav
labels (few, highly visible) still get real i18n keys; page content
does not. Flagged for a follow-up i18n pass before any of this ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-17 23:47:13 +04:00
parent b19fd77a60
commit 580d228484
14 changed files with 252 additions and 0 deletions

View File

@@ -191,6 +191,15 @@ const coreRoutes: Routes = [
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
}
},
{
path: 'notifications',
loadComponent: () => import('./features/admin/notifications/pages/admin-notifications-page.component').then(m => m.AdminNotificationsPageComponent),
data: {
titleKey: 'adminShell.nav.notifications',
descriptionKey: 'adminShell.nav.notifications',
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
}
},
{
path: 'moderation',
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),

View File

@@ -0,0 +1,36 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
import { ADMIN_NOTIFICATIONS_GATEWAY } from '../services/admin-notifications-gateway.token';
@Injectable({ providedIn: 'root' })
export class AdminNotificationsFacade {
private readonly gateway = inject(ADMIN_NOTIFICATIONS_GATEWAY);
readonly filters = signal<AdminNotificationFilters>({ marketplaceId: '', eventType: 'all', unreadOnly: false });
readonly notifications = signal<AdminNotification[]>([]);
readonly loading = signal(false);
readonly unreadCount = computed(() => this.notifications().filter(n => !n.read).length);
load(): void {
this.loading.set(true);
this.gateway.loadNotifications(this.filters()).pipe(take(1)).subscribe(items => {
this.notifications.set(items);
this.loading.set(false);
});
}
updateFilters(patch: Partial<AdminNotificationFilters>): void {
this.filters.update(current => ({ ...current, ...patch }));
this.load();
}
markRead(id: string): void {
this.gateway.markRead(id).pipe(take(1)).subscribe(() => this.load());
}
markAllRead(): void {
this.gateway.markAllRead().pipe(take(1)).subscribe(() => this.load());
}
}

View File

@@ -0,0 +1,22 @@
/** Per docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6. */
export type AdminNotificationSeverity = 'info' | 'warning' | 'critical';
export type AdminNotificationEntityType = 'order' | 'payment' | 'offer' | 'connector' | 'refund';
export interface AdminNotification {
id: string;
marketplaceId: string;
entityType: AdminNotificationEntityType;
entityId: string;
severity: AdminNotificationSeverity;
eventType: string;
title: string;
read: boolean;
deepLink: string[];
createdAt: string;
}
export interface AdminNotificationFilters {
marketplaceId: string;
eventType: string;
unreadOnly: boolean;
}

View File

@@ -0,0 +1,38 @@
<div class="notifications-page">
<header class="notifications-page__header">
<div>
<h1>Notifications</h1>
<p>Mock-gateway backed until docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md ships.</p>
</div>
<app-button variant="secondary" (click)="facade.markAllRead()">Mark all read</app-button>
</header>
<div class="notifications-page__filters">
<label>
<input type="checkbox" [ngModel]="facade.filters().unreadOnly" (ngModelChange)="facade.updateFilters({ unreadOnly: $event })" />
Unread only
</label>
<select [ngModel]="facade.filters().eventType" (ngModelChange)="facade.updateFilters({ eventType: $event })">
@for (type of eventTypes; track type) {
<option [value]="type">{{ type }}</option>
}
</select>
</div>
@if (facade.notifications().length === 0 && !facade.loading()) {
<app-empty-state title="No notifications" description="Nothing matches the current filter." />
} @else {
<ul class="notifications-page__list">
@for (n of facade.notifications(); track n.id) {
<li class="notifications-page__item" [class.notifications-page__item--unread]="!n.read">
<app-badge [variant]="n.severity === 'critical' ? 'danger' : n.severity === 'warning' ? 'warning' : 'info'">{{ n.severity }}</app-badge>
<a [routerLink]="['/', currentLang(), 'backoffice', ...n.deepLink]">{{ n.title }}</a>
<span class="notifications-page__time">{{ n.createdAt | date: 'short' }}</span>
@if (!n.read) {
<button type="button" (click)="facade.markRead(n.id)">Mark read</button>
}
</li>
}
</ul>
}
</div>

View File

@@ -0,0 +1,46 @@
.notifications-page {
display: flex;
flex-direction: column;
gap: 16px;
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
&__filters {
display: flex;
gap: 16px;
align-items: center;
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
&__item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
&--unread {
background: var(--bg-secondary);
font-weight: var(--font-weight-bold, 700);
}
}
&__time {
margin-left: auto;
color: var(--text-secondary);
font-weight: normal;
}
}

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AdminNotificationsFacade } from '../facade/admin-notifications.facade';
import { LanguageService } from '../../../../services/language.service';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-notifications-page',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, BadgeComponent, ButtonComponent, EmptyStateComponent],
templateUrl: './admin-notifications-page.component.html',
styleUrls: ['./admin-notifications-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminNotificationsPageComponent {
readonly facade = inject(AdminNotificationsFacade);
private readonly languageService = inject(LanguageService);
readonly currentLang = this.languageService.currentLanguage;
readonly eventTypes = ['all', 'order.created', 'order.paid'] as const;
constructor() {
this.facade.load();
}
}

View File

@@ -0,0 +1,8 @@
import { Observable } from 'rxjs';
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
export interface AdminNotificationsGateway {
loadNotifications(filters: AdminNotificationFilters): Observable<AdminNotification[]>;
markRead(id: string): Observable<void>;
markAllRead(): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
import { AdminNotificationsLocalGateway } from './admin-notifications-local.gateway';
/** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6. */
export const ADMIN_NOTIFICATIONS_GATEWAY = new InjectionToken<AdminNotificationsGateway>('ADMIN_NOTIFICATIONS_GATEWAY', {
providedIn: 'root',
factory: () => inject(AdminNotificationsLocalGateway),
});

View File

@@ -0,0 +1,50 @@
import { Injectable, inject } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
import { ADMIN_ORDERS_GATEWAY } from '../../orders/services/admin-orders-gateway.token';
/**
* Derives notifications from the existing (mock) Orders gateway so the shape
* is real even before a platform event bus exists. Once
* docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6 ships, swap the
* token - no caller changes needed.
*/
@Injectable({ providedIn: 'root' })
export class AdminNotificationsLocalGateway implements AdminNotificationsGateway {
private readonly ordersGateway = inject(ADMIN_ORDERS_GATEWAY);
private readonly readIds = new Set<string>();
loadNotifications(filters: AdminNotificationFilters): Observable<AdminNotification[]> {
return this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 20 }).pipe(
map(result => result.items
.map((order): AdminNotification => ({
id: `notif_${order.id}`,
marketplaceId: 'default',
entityType: 'order',
entityId: order.id,
severity: order.status === 'cancelled' ? 'warning' : 'info',
eventType: order.payment.status === 'paid' ? 'order.paid' : 'order.created',
title: `Order ${order.orderNumber} - ${order.payment.status === 'paid' ? 'paid' : 'created'}`,
read: this.readIds.has(order.id),
deepLink: ['orders', order.id],
createdAt: order.createdAt,
}))
.filter(n => !filters.unreadOnly || !n.read)
.filter(n => !filters.eventType || filters.eventType === 'all' || n.eventType === filters.eventType)
)
);
}
markRead(id: string): Observable<void> {
const orderId = id.replace(/^notif_/, '');
this.readIds.add(orderId);
return of(void 0);
}
markAllRead(): Observable<void> {
return this.loadNotifications({ marketplaceId: '', eventType: '', unreadOnly: false }).pipe(
map(items => { items.forEach(n => this.readIds.add(n.entityId)); })
);
}
}

View File

@@ -38,6 +38,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
{ type: 'link', id: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },

View File

@@ -2059,6 +2059,7 @@ export const en: Translations = {
customers: 'Customers',
transactions: 'Transactions',
moderation: 'Reviews & Moderation',
notifications: 'Notifications',
reviews: 'Reviews',
reports: 'Reports',
partnersGroup: 'Partners',

View File

@@ -2053,6 +2053,7 @@ export const hy: Translations = {
orders: 'Պատվերներ',
customers: 'Հաճախորդներ',
moderation: 'Կարծիքներ և մոդերացիա',
notifications: 'Ծանուցումներ',
transactions: 'Գործարքներ',
reviews: 'Կարծիքներ',
reports: 'Հաշվետվություններ',

View File

@@ -2053,6 +2053,7 @@ export const ru: Translations = {
orders: 'Заказы',
customers: 'Клиенты',
moderation: 'Отзывы и модерация',
notifications: 'Уведомления',
transactions: 'Транзакции',
reviews: 'Отзывы',
reports: 'Отчёты',

View File

@@ -2068,6 +2068,7 @@ export interface Translations {
transactions: string;
reviews: string;
moderation: string;
notifications: string;
reports: string;
partnersGroup: string;
sellerManagement: string;