Files
marketplaces/docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md

945 lines
35 KiB
Markdown
Raw Permalink Normal View 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`:
```typescript
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`:
```typescript
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:
```typescript
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`:
```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**
```bash
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;`):
```typescript
notificationsEmpty: string;
notificationNewOrder: string;
```
In `src/app/i18n/en.ts`, inside `adminShell.topbar` (next to `notificationsEmpty:`):
```typescript
notificationsEmpty: 'No new notifications',
notificationNewOrder: 'New order #{{orderNumber}}',
```
In `src/app/i18n/ru.ts`, inside `adminShell.topbar`:
```typescript
notificationsEmpty: 'Нет новых уведомлений',
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
```
In `src/app/i18n/hy.ts`, inside `adminShell.topbar`:
```typescript
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`:
```typescript
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`:
```typescript
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**
```bash
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`:
```typescript
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):
```typescript
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
```
```typescript
private readonly orderWatcher = inject(AdminOrderWatcherService);
readonly unreadCount = this.orderWatcher.unreadCount;
readonly recentOrders = this.orderWatcher.recentOrders;
```
In the constructor, after `this.readRouteData();`, add:
```typescript
this.orderWatcher.start();
```
Replace the `toggleNotifications` method:
```typescript
toggleNotifications(): void {
this.notificationsOpen.update(open => !open);
if (this.notificationsOpen()) {
this.orderWatcher.markAllSeen();
}
}
```
Add a navigation helper next to `adminLinkFor`:
```typescript
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):
```html
<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):
```scss
.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**
```bash
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;`):
```typescript
notificationInterval: string;
notificationIntervalExplain: string;
notificationIntervalSave: string;
notificationIntervalSaved: string;
```
In `src/app/i18n/en.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Rates saved',`):
```typescript
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: 'Курсы сохранены',`):
```typescript
notificationInterval: 'Интервал проверки новых заказов (сек)',
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
notificationIntervalSave: 'Сохранить интервал',
notificationIntervalSaved: 'Интервал сохранён',
```
In `src/app/i18n/hy.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',`):
```typescript
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
notificationIntervalSave: 'Պահպանել ինտերվալը',
notificationIntervalSaved: 'Ինտերվալը պահպանվեց',
```
- [ ] **Step 2: Write the failing test**
Create `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts`:
```typescript
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:
```typescript
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
```
Add the field and methods to the class (alongside the currency-rates fields):
```typescript
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>`):
```html
<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**
```bash
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.