Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
35 KiB
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 rawlocalStorage. - 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
currencyConvertpipe) — consistent with every other admin screen. - Route arrays for admin navigation use the pattern
[languageService.currentLanguage(), 'backoffice', 'orders', id](no leading/), matchingadmin-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)
npm run barry -- kb search --source cq --query "admin order notifications"if KB sharing is enabled (skip if local-only — see projectCLAUDE.md).- Start the dev server, log into
/backoffice, leave the tab open. - 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. - Trigger a new order (checkout flow in
cart.component.ts, or temporarily lowerSEED_COUNT/seed timing inadmin-orders-local.gateway.tsto simulate one — revert after testing) and confirm: toast appears with the order number, bell badge shows1, clicking either navigates to/backoffice/orders/:id. - Open the bell panel without clicking a row — confirm badge clears but the order still lists in the panel.
- Change the interval in Admin Settings, save, confirm the toast "Interval saved" message. no crash on next poll cycle.