Files
marketplaces/docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md
sdarbinyan cfee91355b
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
docs: implementation plan for admin purchase notifications (item 7)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:20:32 +04:00

35 KiB
Raw Blame History

Admin Purchase Notifications Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Notify admin (toast + topbar bell badge/panel) when a new order lands on the marketplace, poll-based since the backend has no WebSocket/SSE.

Architecture: A single AdminOrderWatcherService polls AdminOrdersLocalGateway.loadOrders() on an editable interval (default 15s), diffs against a persisted "last notified" order id to fire toasts for genuinely new orders, and exposes a recentOrders/unreadCount signal pair that the existing (currently-empty) topbar bell panel renders. Poll interval is editable in the admin settings page, same pattern as the currency-rates section added previously.

Tech Stack: Angular 17+ signals, RxJS, Jasmine/Karma (ng test), existing LocalStorageService/UserNotificationService/TranslateService patterns.

Global Constraints

  • No WebSocket/SSE available — polling only (confirmed BACKEND-API-REFERENCE.md:20).
  • Persist state via LocalStorageService (getItem/setItem), never raw localStorage.
  • Reuse the existing topbar bell (admin-layout.component.html:141-157) instead of adding a new nav badge.
  • Admin backoffice price/amount displays stay in the order's raw stored currency (no currencyConvert pipe) — consistent with every other admin screen.
  • Route arrays for admin navigation use the pattern [languageService.currentLanguage(), 'backoffice', 'orders', id] (no leading /), matching admin-orders-list-page.component.ts:52.

Task 1: UserNotificationService gains an optional click-to-navigate route

Files:

  • Modify: src/app/features/website/user-experience/services/user-notification.service.ts
  • Modify: src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts
  • Modify: src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
  • Test: src/app/features/website/user-experience/services/user-notification.service.spec.ts (new)

Interfaces:

  • Produces: UserNotificationService.show(message: string, type?: UserNotificationType, durationMs?: number, route?: string[]): void

  • Produces: UserNotification.route?: string[]

  • Step 1: Write the failing test

Create src/app/features/website/user-experience/services/user-notification.service.spec.ts:

import { TestBed } from '@angular/core/testing';
import { UserNotificationService } from './user-notification.service';

describe('UserNotificationService', () => {
  let service: UserNotificationService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(UserNotificationService);
  });

  it('stores the route on the notification when provided', () => {
    service.show('New order #1042', 'info', 4000, ['en', 'backoffice', 'orders', 'ord_1']);

    const [note] = service.notifications();
    expect(note.message).toBe('New order #1042');
    expect(note.route).toEqual(['en', 'backoffice', 'orders', 'ord_1']);
  });

  it('leaves route undefined when not provided', () => {
    service.show('Saved');

    const [note] = service.notifications();
    expect(note.route).toBeUndefined();
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- --include='**/user-notification.service.spec.ts' Expected: FAIL — show has no fourth parameter, route does not exist on UserNotification.

  • Step 3: Implement

Replace the full contents of src/app/features/website/user-experience/services/user-notification.service.ts:

import { Injectable, signal } from '@angular/core';

export type UserNotificationType = 'success' | 'info' | 'warning';

export interface UserNotification {
  id: string;
  message: string;
  type: UserNotificationType;
  /** Route to navigate to when the notification is clicked. Absent means not clickable. */
  route?: string[];
}

@Injectable({ providedIn: 'root' })
export class UserNotificationService {
  private readonly state = signal<UserNotification[]>([]);

  readonly notifications = this.state.asReadonly();

  show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500, route?: string[]): void {
    const next: UserNotification = {
      id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
      message,
      type,
      ...(route ? { route } : {}),
    };

    this.state.update(items => [next, ...items].slice(0, 4));

    setTimeout(() => this.dismiss(next.id), durationMs);
  }

  dismiss(id: string): void {
    this.state.update(items => items.filter(item => item.id !== id));
  }
}

Modify src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts — replace full contents:

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { UserNotification, UserNotificationService } from '../../services/user-notification.service';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';

@Component({
  selector: 'app-floating-notifications',
  standalone: true,
  imports: [TranslatePipe],
  templateUrl: './floating-notifications.component.html',
  styleUrls: ['./floating-notifications.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class FloatingNotificationsComponent {
  private readonly notificationsService = inject(UserNotificationService);
  private readonly router = inject(Router);

  readonly notifications = this.notificationsService.notifications;

  dismiss(id: string): void {
    this.notificationsService.dismiss(id);
  }

  navigate(note: UserNotification): void {
    if (note.route) {
      void this.router.navigate(note.route);
    }
    this.dismiss(note.id);
  }
}

Replace full contents of src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html:

@if (notifications().length > 0) {
  <aside class="floating-notifications" aria-live="polite" aria-atomic="true">
    @for (note of notifications(); track note.id) {
      <article
        class="floating-note"
        [class]="'floating-note floating-note-' + note.type"
        [class.floating-note-clickable]="!!note.route"
        (click)="note.route && navigate(note)"
      >
        <p>{{ note.message }}</p>
        <button type="button" (click)="$event.stopPropagation(); dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
      </article>
    }
  </aside>
}
  • Step 4: Run test to verify it passes

Run: npm run test -- --include='**/user-notification.service.spec.ts' Expected: PASS (2 specs)

  • Step 5: Commit
git add src/app/features/website/user-experience/services/user-notification.service.ts src/app/features/website/user-experience/services/user-notification.service.spec.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
git commit -m "feat: UserNotificationService supports click-to-navigate toasts"

Task 2: AdminOrderWatcherService — polling, diffing, toast firing

Files:

  • Create: src/app/features/admin/shell/services/admin-order-watcher.service.ts
  • Test: src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts
  • Modify: src/app/i18n/translations.ts, src/app/i18n/en.ts, src/app/i18n/ru.ts, src/app/i18n/hy.ts (one new key)

Interfaces:

  • Consumes: AdminOrdersLocalGateway.loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> (existing)

  • Consumes: UserNotificationService.show(message, type?, durationMs?, route?) (Task 1)

  • Consumes: LocalStorageService.getItem(key): string | null, .setItem(key, value): void (existing)

  • Produces: AdminOrderWatcherService.recentOrders: Signal<AdminOrder[]>

  • Produces: AdminOrderWatcherService.unreadCount: Signal<number>

  • Produces: AdminOrderWatcherService.intervalMs: Signal<number>

  • Produces: AdminOrderWatcherService.start(): void

  • Produces: AdminOrderWatcherService.markAllSeen(): void

  • Produces: AdminOrderWatcherService.setIntervalSeconds(seconds: number): void

  • Step 1: Add the i18n key first (needed by the test's translated toast message)

In src/app/i18n/translations.ts, inside the topbar: block under adminShell (next to notificationsEmpty: string;):

      notificationsEmpty: string;
      notificationNewOrder: string;

In src/app/i18n/en.ts, inside adminShell.topbar (next to notificationsEmpty:):

      notificationsEmpty: 'No new notifications',
      notificationNewOrder: 'New order #{{orderNumber}}',

In src/app/i18n/ru.ts, inside adminShell.topbar:

      notificationsEmpty: 'Нет новых уведомлений',
      notificationNewOrder: 'Новый заказ №{{orderNumber}}',

In src/app/i18n/hy.ts, inside adminShell.topbar:

      notificationsEmpty: 'Նոր ծանուցումներ չկան',
      notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',

(Match each file's existing notificationsEmpty value/indentation exactly — only add the new line after it.)

  • Step 2: Write the failing test

Create src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts:

import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AdminOrderWatcherService } from './admin-order-watcher.service';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';

function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
  return {
    id,
    orderNumber,
    status: 'pending',
    customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' },
    payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' },
    shipping: { address: '', method: '', trackingNumber: '' },
    items: [],
    total: 1000,
    currency: 'RUB',
    notes: '',
    internalNotes: '',
    timeline: [],
    archived: false,
    createdAt,
    updatedAt: createdAt,
  };
}

describe('AdminOrderWatcherService', () => {
  let ordersByPoll: AdminOrder[][];
  let pollIndex: number;
  let notifications: UserNotificationService;
  let service: AdminOrderWatcherService;

  function fakeGateway() {
    return {
      loadOrders: () => {
        const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1];
        const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 };
        return of(result);
      },
    };
  }

  beforeEach(() => {
    pollIndex = 0;
    ordersByPoll = [
      [makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
    ];

    localStorage.clear();

    TestBed.configureTestingModule({
      providers: [
        provideRouter([]),
        { provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
      ],
    });

    notifications = TestBed.inject(UserNotificationService);
    service = TestBed.inject(AdminOrderWatcherService);
  });

  it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => {
    service.start();
    tick(0);

    expect(notifications.notifications().length).toBe(0);
    expect(service.unreadCount()).toBe(0);
    expect(service.recentOrders().map(o => o.id)).toEqual(['o2', 'o1']);
  }));

  it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => {
    service.start();
    tick(0);

    pollIndex = 1;
    ordersByPoll.push([
      makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
      makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
      makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
    ]);

    tick(service.intervalMs());

    expect(notifications.notifications().length).toBe(1);
    expect(notifications.notifications()[0].message).toContain('1003');
    expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']);
    expect(service.unreadCount()).toBe(1);
  }));

  it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => {
    service.start();
    tick(0);

    pollIndex = 1;
    ordersByPoll.push([
      makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
      makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
      makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
    ]);
    tick(service.intervalMs());

    expect(service.unreadCount()).toBe(1);

    service.markAllSeen();

    expect(service.unreadCount()).toBe(0);
    expect(service.recentOrders().map(o => o.id)).toEqual(['o3', 'o2', 'o1']);
  }));

  it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
    service.setIntervalSeconds(30);
    expect(service.intervalMs()).toBe(30000);

    service.setIntervalSeconds(0);
    expect(service.intervalMs()).toBe(30000);

    service.setIntervalSeconds(-5);
    expect(service.intervalMs()).toBe(30000);
  });
});

Note: LanguageService defaults to 'ru' (see language.service.ts:23), which is why the expected route in the second test starts with 'ru'.

  • Step 3: Run test to verify it fails

Run: npm run test -- --include='**/admin-order-watcher.service.spec.ts' Expected: FAIL — admin-order-watcher.service.ts does not exist yet.

  • Step 4: Implement

Create src/app/features/admin/shell/services/admin-order-watcher.service.ts:

import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { AdminOrder } from '../../orders/models/admin-order.model';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
import { LanguageService } from '../../../../services/language.service';
import { TranslateService } from '../../../../i18n/translate.service';

const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
export const DEFAULT_POLL_INTERVAL_MS = 15000;
const MIN_POLL_INTERVAL_MS = 1000;
const RECENT_ORDERS_LIMIT = 20;
const TOAST_DURATION_MS = 4000;

@Injectable({ providedIn: 'root' })
export class AdminOrderWatcherService {
  private readonly gateway = inject(AdminOrdersLocalGateway);
  private readonly storage = inject(LocalStorageService);
  private readonly notifications = inject(UserNotificationService);
  private readonly languageService = inject(LanguageService);
  private readonly i18n = inject(TranslateService);

  private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
  readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();

  private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));

  readonly unreadCount = computed(() => {
    const orders = this.recentOrdersSignal();
    if (orders.length === 0) {
      return 0;
    }
    const ackId = this.lastAcknowledgedOrderIdSignal();
    if (ackId === null) {
      return orders.length;
    }
    const idx = orders.findIndex(order => order.id === ackId);
    return idx === -1 ? orders.length : idx;
  });

  private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
  readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();

  private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
  private timerId: ReturnType<typeof setInterval> | null = null;
  private started = false;

  start(): void {
    if (this.started) {
      return;
    }
    this.started = true;
    this.poll();
    this.scheduleNext();
  }

  setIntervalSeconds(seconds: number): void {
    if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
      return;
    }
    const ms = Math.round(seconds * 1000);
    this.intervalMsSignal.set(ms);
    this.storage.setItem(POLL_INTERVAL_KEY, String(ms));
    if (this.started) {
      this.scheduleNext();
    }
  }

  markAllSeen(): void {
    const newestId = this.recentOrdersSignal()[0]?.id ?? null;
    this.lastAcknowledgedOrderIdSignal.set(newestId);
    if (newestId) {
      this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId);
    }
  }

  private scheduleNext(): void {
    if (this.timerId !== null) {
      clearInterval(this.timerId);
    }
    this.timerId = setInterval(() => this.poll(), this.intervalMsSignal());
  }

  private poll(): void {
    this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({
      next: result => this.handleOrders(result.items),
      error: err => console.error('Error polling for new orders:', err),
    });
  }

  private handleOrders(items: AdminOrder[]): void {
    this.recentOrdersSignal.set(items);

    if (items.length === 0) {
      return;
    }

    const isFirstPoll = this.lastNotifiedOrderId === null;
    const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
    const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex));

    this.lastNotifiedOrderId = items[0].id;
    this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);

    if (isFirstPoll) {
      // Nothing existed to compare against yet - treat current orders as already
      // acknowledged so a fresh admin session doesn't see the whole history as unread.
      if (this.lastAcknowledgedOrderIdSignal() === null) {
        this.lastAcknowledgedOrderIdSignal.set(items[0].id);
        this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
      }
      return;
    }

    for (let i = newOrders.length - 1; i >= 0; i--) {
      const order = newOrders[i];
      this.notifications.show(
        this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }),
        'info',
        TOAST_DURATION_MS,
        [this.languageService.currentLanguage(), 'backoffice', 'orders', order.id]
      );
    }
  }

  private readStoredIntervalMs(): number {
    const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY));
    return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS;
  }
}
  • Step 5: Run test to verify it passes

Run: npm run test -- --include='**/admin-order-watcher.service.spec.ts' Expected: PASS (4 specs)

  • Step 6: Commit
git add src/app/features/admin/shell/services/admin-order-watcher.service.ts src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat: AdminOrderWatcherService polls for new orders and toasts"

Task 3: Wire the watcher into the admin topbar bell

Files:

  • Modify: src/app/features/admin/shell/admin-layout.component.ts
  • Modify: src/app/features/admin/shell/admin-layout.component.html
  • Modify: src/app/features/admin/shell/admin-layout.component.scss
  • Test: src/app/features/admin/shell/admin-layout.component.spec.ts (new)

Interfaces:

  • Consumes: AdminOrderWatcherService.{recentOrders, unreadCount, start, markAllSeen} (Task 2)

  • Step 1: Write the failing test

Create src/app/features/admin/shell/admin-layout.component.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { AdminLayoutComponent } from './admin-layout.component';
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
import { AdminOrder } from '../orders/models/admin-order.model';

function makeOrder(id: string, orderNumber: string): AdminOrder {
  return {
    id,
    orderNumber,
    status: 'pending',
    customer: { name: 'Test Customer', email: 't@example.com', phone: '' },
    payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' },
    shipping: { address: '', method: '', trackingNumber: '' },
    items: [],
    total: 500,
    currency: 'RUB',
    notes: '',
    internalNotes: '',
    timeline: [],
    archived: false,
    createdAt: '2026-08-15T10:00:00.000Z',
    updatedAt: '2026-08-15T10:00:00.000Z',
  };
}

describe('AdminLayoutComponent notifications bell', () => {
  let watcherStub: {
    recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
    unreadCount: ReturnType<typeof signal<number>>;
    start: jasmine.Spy;
    markAllSeen: jasmine.Spy;
  };

  beforeEach(() => {
    watcherStub = {
      recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
      unreadCount: signal(1),
      start: jasmine.createSpy('start'),
      markAllSeen: jasmine.createSpy('markAllSeen'),
    };

    TestBed.configureTestingModule({
      imports: [AdminLayoutComponent],
      providers: [
        provideRouter([]),
        { provide: AdminOrderWatcherService, useValue: watcherStub },
      ],
    });
  });

  it('starts the watcher once on construction', () => {
    TestBed.createComponent(AdminLayoutComponent);
    expect(watcherStub.start).toHaveBeenCalledTimes(1);
  });

  it('exposes unreadCount and recentOrders from the watcher', () => {
    const fixture = TestBed.createComponent(AdminLayoutComponent);
    const component = fixture.componentInstance;
    expect(component.unreadCount()).toBe(1);
    expect(component.recentOrders().map(o => o.id)).toEqual(['o1']);
  });

  it('marks orders seen when the notifications panel opens', () => {
    const fixture = TestBed.createComponent(AdminLayoutComponent);
    const component = fixture.componentInstance;
    component.toggleNotifications();
    expect(component.notificationsOpen()).toBe(true);
    expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);

    component.toggleNotifications();
    expect(component.notificationsOpen()).toBe(false);
    expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- --include='**/admin-layout.component.spec.ts' Expected: FAIL — AdminOrderWatcherService not referenced by the component yet, unreadCount/recentOrders don't exist on AdminLayoutComponent.

  • Step 3: Implement

In src/app/features/admin/shell/admin-layout.component.ts, add the import and field (place near the other service injections):

import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
  private readonly orderWatcher = inject(AdminOrderWatcherService);

  readonly unreadCount = this.orderWatcher.unreadCount;
  readonly recentOrders = this.orderWatcher.recentOrders;

In the constructor, after this.readRouteData();, add:

    this.orderWatcher.start();

Replace the toggleNotifications method:

  toggleNotifications(): void {
    this.notificationsOpen.update(open => !open);
    if (this.notificationsOpen()) {
      this.orderWatcher.markAllSeen();
    }
  }

Add a navigation helper next to adminLinkFor:

  goToOrder(orderId: string): void {
    void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]);
    this.notificationsOpen.set(false);
  }

In src/app/features/admin/shell/admin-layout.component.html, replace the notifications block (lines 141-157):

      <div class="admin-layout__notifications">
        <button
          type="button"
          class="admin-layout__icon-button"
          aria-haspopup="true"
          [attr.aria-expanded]="notificationsOpen()"
          [attr.aria-label]="'adminShell.topbar.notifications' | translate"
          (click)="toggleNotifications()"
        >
          <app-icon name="bell" [size]="18" />
          @if (unreadCount() > 0) {
            <span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
          }
        </button>
        @if (notificationsOpen()) {
          <div class="admin-layout__notifications-panel" role="menu">
            @if (recentOrders().length === 0) {
              <p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
            } @else {
              @for (order of recentOrders(); track order.id) {
                <button
                  type="button"
                  class="admin-layout__notification-item"
                  role="menuitem"
                  (click)="goToOrder(order.id)"
                >
                  <span class="admin-layout__notification-order">#{{ order.orderNumber }}</span>
                  <span class="admin-layout__notification-customer">{{ order.customer.name }}</span>
                  <span class="admin-layout__notification-amount">{{ order.total }} {{ order.currency }}</span>
                </button>
              }
            }
          </div>
        }
      </div>

In src/app/features/admin/shell/admin-layout.component.scss, add (near other .admin-layout__notifications* rules if any exist, otherwise at the end):

.admin-layout__notifications {
  position: relative;
}

.admin-layout__notifications-badge {
  position: absolute;
  top: 2px;
  right: 2px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 999px;
  background: var(--color-danger, #ef4444);
  color: #fff;
  font-size: 10px;
  line-height: 16px;
  text-align: center;
}

.admin-layout__notification-item {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
  padding: 8px 10px;
  border: none;
  background: none;
  text-align: left;
  cursor: pointer;
  border-radius: var(--radius-sm, 4px);
}

.admin-layout__notification-item:hover {
  background: var(--bg-secondary, #f4f6f5);
}

.admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); }
.admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
.admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); }
  • Step 4: Run test to verify it passes

Run: npm run test -- --include='**/admin-layout.component.spec.ts' Expected: PASS (3 specs)

  • Step 5: Run full build to catch template errors

Run: npx ng build --configuration development Expected: build succeeds, no template compile errors.

  • Step 6: Commit
git add src/app/features/admin/shell/admin-layout.component.ts src/app/features/admin/shell/admin-layout.component.html src/app/features/admin/shell/admin-layout.component.scss src/app/features/admin/shell/admin-layout.component.spec.ts
git commit -m "feat: wire order watcher into admin topbar bell (badge + panel)"

Task 4: Editable poll interval in admin settings

Files:

  • Modify: src/app/features/admin/settings/pages/admin-settings-page.component.ts
  • Modify: src/app/features/admin/settings/pages/admin-settings-page.component.html
  • Modify: src/app/i18n/translations.ts, src/app/i18n/en.ts, src/app/i18n/ru.ts, src/app/i18n/hy.ts
  • Test: src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts (new)

Interfaces:

  • Consumes: AdminOrderWatcherService.{intervalMs, setIntervalSeconds} (Task 2)

  • Step 1: Add i18n keys

In src/app/i18n/translations.ts, inside adminSettings: (after currencyRatesSaved: string;):

    notificationInterval: string;
    notificationIntervalExplain: string;
    notificationIntervalSave: string;
    notificationIntervalSaved: string;

In src/app/i18n/en.ts, inside adminSettings (after currencyRatesSaved: 'Rates saved',):

    notificationInterval: 'New-order check interval (seconds)',
    notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
    notificationIntervalSave: 'Save interval',
    notificationIntervalSaved: 'Interval saved',

In src/app/i18n/ru.ts, inside adminSettings (after currencyRatesSaved: 'Курсы сохранены',):

    notificationInterval: 'Интервал проверки новых заказов (сек)',
    notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
    notificationIntervalSave: 'Сохранить интервал',
    notificationIntervalSaved: 'Интервал сохранён',

In src/app/i18n/hy.ts, inside adminSettings (after currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',):

    notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
    notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
    notificationIntervalSave: 'Պահպանել ինտերվալը',
    notificationIntervalSaved: 'Ինտերվալը պահպանվեց',
  • Step 2: Write the failing test

Create src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts:

import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { AdminSettingsPageComponent } from './admin-settings-page.component';
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';

describe('AdminSettingsPageComponent notification interval', () => {
  let watcherStub: {
    intervalMs: ReturnType<typeof signal<number>>;
    setIntervalSeconds: jasmine.Spy;
  };

  beforeEach(() => {
    watcherStub = {
      intervalMs: signal(15000),
      setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'),
    };

    TestBed.configureTestingModule({
      imports: [AdminSettingsPageComponent],
      providers: [
        provideRouter([]),
        { provide: AdminOrderWatcherService, useValue: watcherStub },
      ],
    });
  });

  it('initializes the draft from the current interval in seconds', () => {
    const fixture = TestBed.createComponent(AdminSettingsPageComponent);
    expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15);
  });

  it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => {
    const fixture = TestBed.createComponent(AdminSettingsPageComponent);
    const component = fixture.componentInstance;

    component.notificationIntervalSecondsDraft.set(30);
    component.saveNotificationInterval();

    expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30);
  });
});
  • Step 3: Run test to verify it fails

Run: npm run test -- --include='**/admin-settings-page.component.spec.ts' Expected: FAIL — notificationIntervalSecondsDraft/saveNotificationInterval don't exist yet.

  • Step 4: Implement

In src/app/features/admin/settings/pages/admin-settings-page.component.ts, add the import:

import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';

Add the field and methods to the class (alongside the currency-rates fields):

  readonly orderWatcher = inject(AdminOrderWatcherService);
  readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
  readonly showNotificationIntervalSaved = signal(false);

  saveNotificationInterval(): void {
    this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
    this.showNotificationIntervalSaved.set(true);
    setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
  }

In src/app/features/admin/settings/pages/admin-settings-page.component.html, add a new .settings-card block after the currency-rates one (before the closing </section>):

  <div class="settings-card">
    <h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
    <p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
    <div class="rate-row">
      <input
        class="rate-input"
        type="number"
        min="1"
        step="1"
        [ngModel]="notificationIntervalSecondsDraft()"
        (ngModelChange)="notificationIntervalSecondsDraft.set($event)"
      />
    </div>
    <div class="rate-actions">
      <button type="button" class="save-button" (click)="saveNotificationInterval()">{{ 'adminSettings.notificationIntervalSave' | translate }}</button>
      <span class="saved-message" *ngIf="showNotificationIntervalSaved()">{{ 'adminSettings.notificationIntervalSaved' | translate }}</span>
    </div>
  </div>
  • Step 5: Run test to verify it passes

Run: npm run test -- --include='**/admin-settings-page.component.spec.ts' Expected: PASS (2 specs)

  • Step 6: Full verification

Run: npx tsc --noEmit -p tsconfig.json Expected: no errors.

Run: npx ng build --configuration development Expected: build succeeds.

Run: npm run test -- --include='**/admin-order-watcher.service.spec.ts' --include='**/admin-layout.component.spec.ts' --include='**/admin-settings-page.component.spec.ts' --include='**/user-notification.service.spec.ts' Expected: all specs PASS.

  • Step 7: Commit
git add src/app/features/admin/settings/pages/admin-settings-page.component.ts src/app/features/admin/settings/pages/admin-settings-page.component.html src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat: editable new-order poll interval in admin settings"

Manual Verification (after all tasks)

  1. npm run barry -- kb search --source cq --query "admin order notifications" if KB sharing is enabled (skip if local-only — see project CLAUDE.md).
  2. Start the dev server, log into /backoffice, leave the tab open.
  3. In another tab (or via admin-orders-local.gateway.ts's seed data timing), wait for the poll interval — confirm no toast fires on first load.
  4. Trigger a new order (checkout flow in cart.component.ts, or temporarily lower SEED_COUNT/seed timing in admin-orders-local.gateway.ts to simulate one — revert after testing) and confirm: toast appears with the order number, bell badge shows 1, clicking either navigates to /backoffice/orders/:id.
  5. Open the bell panel without clicking a row — confirm badge clears but the order still lists in the panel.
  6. Change the interval in Admin Settings, save, confirm the toast "Interval saved" message. no crash on next poll cycle.