docs: implementation plan for admin purchase notifications (item 7)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user