945 lines
35 KiB
Markdown
945 lines
35 KiB
Markdown
|
|
# 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.
|