merge: B2B into main
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.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Admin purchase notifications — design
|
||||||
|
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-15
|
||||||
|
**Related backlog item:** #7 (marked ВАЖНО — important)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Admin has no signal when a purchase happens on the marketplace. Orders only surface if
|
||||||
|
someone manually opens the Orders list and refreshes. Backend exposes no WebSocket/SSE
|
||||||
|
(confirmed in `BACKEND-API-REFERENCE.md:20` — every "live" feature today, e.g. payment
|
||||||
|
status, is plain polling), so this has to be poll-based like the rest of the app.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**`AdminOrderWatcherService`** (new, `providedIn: root`, admin-scoped)
|
||||||
|
|
||||||
|
- Polls `AdminOrdersLocalGateway.loadOrders()` (sorted `createdAt` desc, already the
|
||||||
|
default sort) on an interval.
|
||||||
|
- Diffs the newest order's `id`/`createdAt` against the last-seen value, kept in memory
|
||||||
|
and persisted via `LocalStorageService` (survives page reload, same pattern as
|
||||||
|
`AdminPreferencesService`).
|
||||||
|
- On finding order(s) newer than last-seen: fires one toast per new order and
|
||||||
|
increments an `unreadCount` signal.
|
||||||
|
- Started once at the admin shell root, so it keeps polling regardless of which admin
|
||||||
|
page is open.
|
||||||
|
|
||||||
|
**Poll interval**
|
||||||
|
|
||||||
|
- Editable by admin, default 15s.
|
||||||
|
- Setting lives in the same admin-settings page as currency rates
|
||||||
|
(`admin-settings-page.component.ts`), persisted via `LocalStorageService`.
|
||||||
|
|
||||||
|
**Toast delivery**
|
||||||
|
|
||||||
|
- Reuses the existing `UserNotificationService` / `FloatingNotificationsComponent`
|
||||||
|
(already global — `providedIn: root`, mounted once in `app.html`). No new toast UI.
|
||||||
|
- `UserNotification` gains an optional `route: string[]` field.
|
||||||
|
- `FloatingNotificationsComponent` gets a click handler: navigate to `route` (if set)
|
||||||
|
then dismiss.
|
||||||
|
|
||||||
|
**Badge — reuses existing topbar bell**
|
||||||
|
|
||||||
|
`admin-layout.component.html:141-157` already has an unused bell icon +
|
||||||
|
dropdown panel (currently hardcoded to always show "no notifications").
|
||||||
|
Wire the watcher's data into it instead of adding a new indicator:
|
||||||
|
|
||||||
|
- `unreadCount` signal (from `AdminOrderWatcherService`) rendered as a badge
|
||||||
|
on the bell icon (`admin-layout__icon-button`).
|
||||||
|
- Opening the panel (`notificationsOpen()`, already wired to the bell click)
|
||||||
|
lists the unread new orders instead of the static "notificationsEmpty"
|
||||||
|
text.
|
||||||
|
- Opening the panel marks all currently-known orders as seen → badge resets
|
||||||
|
to 0 (same trigger `AdminLayoutComponent.toggleNotifications()` already
|
||||||
|
has).
|
||||||
|
|
||||||
|
**Click behavior**
|
||||||
|
|
||||||
|
- Toast click → `/admin/orders/:id` (the new order's detail page).
|
||||||
|
- Clicking an order row inside the bell panel → same, then closes the panel.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
AdminOrderWatcherService (interval timer)
|
||||||
|
-> AdminOrdersLocalGateway.loadOrders()
|
||||||
|
-> diff against last-seen order id/createdAt (LocalStorageService)
|
||||||
|
-> new order(s) found?
|
||||||
|
-> UserNotificationService.show(message, 'info', { route: ['/admin/orders', id] })
|
||||||
|
-> unreadCount.update(n => n + 1)
|
||||||
|
-> admin clicks toast/badge -> router navigate -> orders-list visit resets unreadCount
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Poll failures are silent/logged only (`console.error`), consistent with existing
|
||||||
|
polling code (payment status polling in `cart.component.ts`). No toast spam on
|
||||||
|
transient network errors — watcher just retries on the next interval.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Native OS push notifications (tab not focused) — user explicitly chose in-app
|
||||||
|
toast/badge only, not browser Notification API.
|
||||||
|
- Sound alerts — not selected.
|
||||||
|
- Telegram/email alerts to staff — not selected, would need backend bot/mail
|
||||||
|
integration.
|
||||||
@@ -32,4 +32,23 @@
|
|||||||
<span class="saved-message" *ngIf="showSavedMessage()">{{ 'adminSettings.currencyRatesSaved' | translate }}</span>
|
<span class="saved-message" *ngIf="showSavedMessage()">{{ 'adminSettings.currencyRatesSaved' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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').and.returnValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the saved message and keeps the draft when the interval is applied', () => {
|
||||||
|
watcherStub.setIntervalSeconds.and.callFake((seconds: number) => {
|
||||||
|
watcherStub.intervalMs.set(seconds * 1000);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component.notificationIntervalSecondsDraft.set(30);
|
||||||
|
component.saveNotificationInterval();
|
||||||
|
|
||||||
|
expect(component.showNotificationIntervalSaved()).toBe(true);
|
||||||
|
expect(component.notificationIntervalSecondsDraft()).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the saved message and reverts the draft when the interval is rejected', () => {
|
||||||
|
watcherStub.setIntervalSeconds.and.returnValue(false);
|
||||||
|
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component.notificationIntervalSecondsDraft.set(0);
|
||||||
|
component.saveNotificationInterval();
|
||||||
|
|
||||||
|
expect(component.showNotificationIntervalSaved()).toBe(false);
|
||||||
|
expect(component.notificationIntervalSecondsDraft()).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
|||||||
import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
|
import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
|
||||||
import { CurrencyRatesService } from '../../../../services/currency-rates.service';
|
import { CurrencyRatesService } from '../../../../services/currency-rates.service';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||||
|
|
||||||
const SAVED_MESSAGE_DURATION_MS = 2000;
|
const SAVED_MESSAGE_DURATION_MS = 2000;
|
||||||
|
|
||||||
@@ -25,6 +26,19 @@ export class AdminSettingsPageComponent {
|
|||||||
readonly rateDrafts = signal<Record<string, number>>({ ...this.currencyRates.rates() });
|
readonly rateDrafts = signal<Record<string, number>>({ ...this.currencyRates.rates() });
|
||||||
readonly showSavedMessage = signal(false);
|
readonly showSavedMessage = signal(false);
|
||||||
|
|
||||||
|
readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||||
|
readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
|
||||||
|
readonly showNotificationIntervalSaved = signal(false);
|
||||||
|
|
||||||
|
saveNotificationInterval(): void {
|
||||||
|
const applied = this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
|
||||||
|
this.notificationIntervalSecondsDraft.set(Math.round(this.orderWatcher.intervalMs() / 1000));
|
||||||
|
if (applied) {
|
||||||
|
this.showNotificationIntervalSaved.set(true);
|
||||||
|
setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onCompactToggle(compact: boolean): void {
|
onCompactToggle(compact: boolean): void {
|
||||||
this.preferences.setDensity(compact ? 'compact' : 'comfortable');
|
this.preferences.setDensity(compact ? 'compact' : 'comfortable');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,10 +148,28 @@
|
|||||||
(click)="toggleNotifications()"
|
(click)="toggleNotifications()"
|
||||||
>
|
>
|
||||||
<app-icon name="bell" [size]="18" />
|
<app-icon name="bell" [size]="18" />
|
||||||
|
@if (unreadCount() > 0) {
|
||||||
|
<span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (notificationsOpen()) {
|
@if (notificationsOpen()) {
|
||||||
<div class="admin-layout__notifications-panel" role="menu">
|
<div class="admin-layout__notifications-panel" role="menu">
|
||||||
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
@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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -347,11 +347,28 @@
|
|||||||
position: relative;
|
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__notifications-panel {
|
.admin-layout__notifications-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
width: 240px;
|
width: 240px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -366,6 +383,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
.admin-layout__account {
|
.admin-layout__account {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
85
src/app/features/admin/shell/admin-layout.component.spec.ts
Normal file
85
src/app/features/admin/shell/admin-layout.component.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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;
|
||||||
|
stop: jasmine.Spy;
|
||||||
|
markAllSeen: jasmine.Spy;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
watcherStub = {
|
||||||
|
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
||||||
|
unreadCount: signal(1),
|
||||||
|
start: jasmine.createSpy('start'),
|
||||||
|
stop: jasmine.createSpy('stop'),
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the watcher when the component is destroyed', () => {
|
||||||
|
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.destroy();
|
||||||
|
expect(watcherStub.stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
|||||||
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
||||||
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
|
||||||
import { ConfigService } from '../../../core/config/config.service';
|
import { ConfigService } from '../../../core/config/config.service';
|
||||||
|
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-layout',
|
selector: 'app-admin-layout',
|
||||||
@@ -31,6 +32,10 @@ export class AdminLayoutComponent {
|
|||||||
private readonly preferences = inject(AdminPreferencesService);
|
private readonly preferences = inject(AdminPreferencesService);
|
||||||
private readonly uiRuntime = inject(UiRuntimeFacade);
|
private readonly uiRuntime = inject(UiRuntimeFacade);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
|
private readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||||
|
|
||||||
|
readonly unreadCount = this.orderWatcher.unreadCount;
|
||||||
|
readonly recentOrders = this.orderWatcher.recentOrders;
|
||||||
|
|
||||||
readonly density = this.preferences.density;
|
readonly density = this.preferences.density;
|
||||||
|
|
||||||
@@ -87,6 +92,8 @@ export class AdminLayoutComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.readRouteData();
|
this.readRouteData();
|
||||||
|
this.orderWatcher.start();
|
||||||
|
this.destroyRef.onDestroy(() => this.orderWatcher.stop());
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(event => event instanceof NavigationEnd),
|
filter(event => event instanceof NavigationEnd),
|
||||||
@@ -130,6 +137,9 @@ export class AdminLayoutComponent {
|
|||||||
|
|
||||||
toggleNotifications(): void {
|
toggleNotifications(): void {
|
||||||
this.notificationsOpen.update(open => !open);
|
this.notificationsOpen.update(open => !open);
|
||||||
|
if (this.notificationsOpen()) {
|
||||||
|
this.orderWatcher.markAllSeen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adminLinkFor(entry: Extract<AdminNavEntry, { type: 'link' }>): string[] {
|
adminLinkFor(entry: Extract<AdminNavEntry, { type: 'link' }>): string[] {
|
||||||
@@ -140,6 +150,11 @@ export class AdminLayoutComponent {
|
|||||||
return ['/', lang, 'backoffice', ...(entry.path ?? [])];
|
return ['/', lang, 'backoffice', ...(entry.path ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goToOrder(orderId: string): void {
|
||||||
|
void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]);
|
||||||
|
this.notificationsOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
breadcrumbLink(entry: AdminBreadcrumbEntry): string[] {
|
breadcrumbLink(entry: AdminBreadcrumbEntry): string[] {
|
||||||
return ['/', this.currentLang(), 'backoffice', ...(entry.path ?? [])];
|
return ['/', this.currentLang(), 'backoffice', ...(entry.path ?? [])];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
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';
|
||||||
|
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.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;
|
||||||
|
let fakeAdminAuth: { isAuthenticated: ReturnType<typeof signal<boolean>> };
|
||||||
|
|
||||||
|
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.removeItem('adminOrderWatcher.lastNotifiedOrderId.v1');
|
||||||
|
localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderCreatedAt.v1');
|
||||||
|
localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.v1');
|
||||||
|
localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1');
|
||||||
|
localStorage.removeItem('adminOrderWatcher.pollIntervalMs.v1');
|
||||||
|
|
||||||
|
// Defaults to unauthenticated so the reactive effect stays a no-op and the
|
||||||
|
// pre-existing tests below (which drive polling via explicit start()/stop()
|
||||||
|
// calls) keep their original behavior unchanged.
|
||||||
|
fakeAdminAuth = { isAuthenticated: signal(false) };
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
|
||||||
|
{ provide: AdminAuthService, useValue: fakeAdminAuth as unknown as AdminAuthService },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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: AdminOrder) => 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: AdminOrder) => o.id)).toEqual(['o3', 'o2', 'o1']);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
|
||||||
|
expect(service.setIntervalSeconds(30)).toBe(true);
|
||||||
|
expect(service.intervalMs()).toBe(30000);
|
||||||
|
|
||||||
|
expect(service.setIntervalSeconds(0)).toBe(false);
|
||||||
|
expect(service.intervalMs()).toBe(30000);
|
||||||
|
|
||||||
|
expect(service.setIntervalSeconds(-5)).toBe(false);
|
||||||
|
expect(service.intervalMs()).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a timestamp comparison (not the whole page) when the last-notified order id disappears', fakeAsync(() => {
|
||||||
|
service.start();
|
||||||
|
tick(0);
|
||||||
|
|
||||||
|
// Second poll: 'o2' (the last-notified id) is gone. Page contains orders
|
||||||
|
// both older and newer than o2's createdAt (2026-08-15T10:00:00.000Z).
|
||||||
|
pollIndex = 1;
|
||||||
|
ordersByPoll.push([
|
||||||
|
makeOrder('o5', '1005', '2026-08-15T12:00:00.000Z'),
|
||||||
|
makeOrder('o4', '1004', '2026-08-15T11:00:00.000Z'),
|
||||||
|
makeOrder('o3', '1003', '2026-08-15T10:30:00.000Z'),
|
||||||
|
makeOrder('o0', '1000', '2026-08-15T08:00:00.000Z'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
tick(service.intervalMs());
|
||||||
|
|
||||||
|
// Only o5, o4, o3 are newer than o2's createdAt - o0 must not toast.
|
||||||
|
expect(notifications.notifications().length).toBe(3);
|
||||||
|
const messages = notifications.notifications().map(n => n.message);
|
||||||
|
expect(messages.some(m => m.includes('1005'))).toBe(true);
|
||||||
|
expect(messages.some(m => m.includes('1004'))).toBe(true);
|
||||||
|
expect(messages.some(m => m.includes('1003'))).toBe(true);
|
||||||
|
expect(messages.some(m => m.includes('1000'))).toBe(false);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('falls back to a timestamp comparison (not the whole page) for unreadCount when the acknowledged order id disappears', fakeAsync(() => {
|
||||||
|
service.start();
|
||||||
|
tick(0);
|
||||||
|
|
||||||
|
// Second poll introduces 'o3' and pushes 'o2' to index 1.
|
||||||
|
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());
|
||||||
|
|
||||||
|
// Acknowledge everything up to and including 'o3' (createdAt 11:00:00).
|
||||||
|
service.markAllSeen();
|
||||||
|
expect(service.unreadCount()).toBe(0);
|
||||||
|
|
||||||
|
// Third poll: 'o3' (the acknowledged id) is gone. Page contains orders
|
||||||
|
// both older and newer than o3's createdAt (2026-08-15T11:00:00.000Z).
|
||||||
|
pollIndex = 2;
|
||||||
|
ordersByPoll.push([
|
||||||
|
makeOrder('o6', '1006', '2026-08-15T13:00:00.000Z'),
|
||||||
|
makeOrder('o5', '1005', '2026-08-15T12:00:00.000Z'),
|
||||||
|
makeOrder('o4', '1004', '2026-08-15T10:30:00.000Z'),
|
||||||
|
makeOrder('o0', '1000', '2026-08-15T08:00:00.000Z'),
|
||||||
|
]);
|
||||||
|
tick(service.intervalMs());
|
||||||
|
|
||||||
|
// Only o6 and o5 are newer than o3's ack createdAt - the buggy behavior
|
||||||
|
// would report the full page length (4) instead of the correct count (2).
|
||||||
|
expect(service.unreadCount()).toBe(2);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('stop() clears the interval so no further polls occur', fakeAsync(() => {
|
||||||
|
const gateway = TestBed.inject(AdminOrdersLocalGateway);
|
||||||
|
const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough();
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
tick(0);
|
||||||
|
const callsAfterStart = loadOrdersSpy.calls.count();
|
||||||
|
|
||||||
|
tick(service.intervalMs() / 2);
|
||||||
|
service.stop();
|
||||||
|
tick(service.intervalMs() * 2);
|
||||||
|
|
||||||
|
expect(loadOrdersSpy.calls.count()).toBe(callsAfterStart);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('reacts to AdminAuthService.isAuthenticated: starts, stops on logout, and resumes on re-login', fakeAsync(() => {
|
||||||
|
const gateway = TestBed.inject(AdminOrdersLocalGateway);
|
||||||
|
const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough();
|
||||||
|
|
||||||
|
// Starts authenticated -> the effect should start polling on its own,
|
||||||
|
// with no explicit service.start() call from a component.
|
||||||
|
fakeAdminAuth.isAuthenticated.set(true);
|
||||||
|
TestBed.flushEffects();
|
||||||
|
tick(0);
|
||||||
|
|
||||||
|
expect(loadOrdersSpy.calls.count()).toBeGreaterThan(0);
|
||||||
|
const callsWhileAuthenticated = loadOrdersSpy.calls.count();
|
||||||
|
|
||||||
|
// Logout: flip the signal to false. Polling must actually stop - this is
|
||||||
|
// the gap the previous DestroyRef-based fix failed to close, since nothing
|
||||||
|
// destroys AdminLayoutComponent on logout.
|
||||||
|
fakeAdminAuth.isAuthenticated.set(false);
|
||||||
|
TestBed.flushEffects();
|
||||||
|
tick(0);
|
||||||
|
const callsRightAfterLogout = loadOrdersSpy.calls.count();
|
||||||
|
|
||||||
|
tick(service.intervalMs() * 3);
|
||||||
|
expect(loadOrdersSpy.calls.count()).toBe(callsRightAfterLogout);
|
||||||
|
|
||||||
|
// Re-login: polling should resume, not stay permanently stopped.
|
||||||
|
fakeAdminAuth.isAuthenticated.set(true);
|
||||||
|
TestBed.flushEffects();
|
||||||
|
tick(0);
|
||||||
|
tick(service.intervalMs());
|
||||||
|
|
||||||
|
expect(loadOrdersSpy.calls.count()).toBeGreaterThan(callsWhileAuthenticated);
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { Injectable, Signal, computed, effect, 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';
|
||||||
|
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||||
|
|
||||||
|
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
|
||||||
|
const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1';
|
||||||
|
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
|
||||||
|
const LAST_ACKNOWLEDGED_AT_KEY = 'adminOrderWatcher.lastAcknowledgedOrderCreatedAt.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 adminAuth = inject(AdminAuthService);
|
||||||
|
|
||||||
|
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
|
||||||
|
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
|
||||||
|
|
||||||
|
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
|
||||||
|
private readonly lastAcknowledgedOrderCreatedAtSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_AT_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);
|
||||||
|
if (idx !== -1) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
// The acknowledged order id is no longer present in the current page
|
||||||
|
// (deleted, or many orders arrived since ack) - fall back to a timestamp
|
||||||
|
// comparison instead of treating the whole page as unread.
|
||||||
|
const ackCreatedAt = this.lastAcknowledgedOrderCreatedAtSignal();
|
||||||
|
return ackCreatedAt === null ? orders.length : orders.filter(order => order.createdAt > ackCreatedAt).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 lastNotifiedOrderCreatedAt: string | null = this.storage.getItem(LAST_NOTIFIED_AT_KEY);
|
||||||
|
private timerId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Reacts to the auth signal itself rather than component lifecycle: nothing
|
||||||
|
// reliably destroys AdminLayoutComponent on logout (no navigation happens),
|
||||||
|
// so polling must stop/start off isAuthenticated directly to avoid leaking
|
||||||
|
// admin order toasts onto the public storefront after logout.
|
||||||
|
effect(() => {
|
||||||
|
if (this.adminAuth.isAuthenticated()) {
|
||||||
|
this.start();
|
||||||
|
} else {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
this.poll();
|
||||||
|
this.scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timerId !== null) {
|
||||||
|
clearInterval(this.timerId);
|
||||||
|
this.timerId = null;
|
||||||
|
}
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntervalSeconds(seconds: number): boolean {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ms = Math.round(seconds * 1000);
|
||||||
|
this.intervalMsSignal.set(ms);
|
||||||
|
this.storage.setItem(POLL_INTERVAL_KEY, String(ms));
|
||||||
|
if (this.started) {
|
||||||
|
this.scheduleNext();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllSeen(): void {
|
||||||
|
const newest = this.recentOrdersSignal()[0];
|
||||||
|
if (!newest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastAcknowledgedOrderIdSignal.set(newest.id);
|
||||||
|
this.lastAcknowledgedOrderCreatedAtSignal.set(newest.createdAt);
|
||||||
|
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newest.id);
|
||||||
|
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, newest.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
// When the last-notified order id is no longer present in the current page
|
||||||
|
// (deleted, or more than RECENT_ORDERS_LIMIT orders arrived since the last
|
||||||
|
// poll), fall back to a timestamp comparison instead of treating the whole
|
||||||
|
// page as new - otherwise a single missing id could fire a burst of up to
|
||||||
|
// RECENT_ORDERS_LIMIT toasts.
|
||||||
|
const newOrders = isFirstPoll
|
||||||
|
? []
|
||||||
|
: notifyIndex !== -1
|
||||||
|
? items.slice(0, notifyIndex)
|
||||||
|
: items.filter(order => this.lastNotifiedOrderCreatedAt !== null && order.createdAt > this.lastNotifiedOrderCreatedAt);
|
||||||
|
|
||||||
|
this.lastNotifiedOrderId = items[0].id;
|
||||||
|
this.lastNotifiedOrderCreatedAt = items[0].createdAt;
|
||||||
|
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
|
||||||
|
this.storage.setItem(LAST_NOTIFIED_AT_KEY, this.lastNotifiedOrderCreatedAt);
|
||||||
|
|
||||||
|
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.lastAcknowledgedOrderCreatedAtSignal.set(items[0].createdAt);
|
||||||
|
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
|
||||||
|
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, items[0].createdAt);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
@if (notifications().length > 0) {
|
@if (notifications().length > 0) {
|
||||||
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
|
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
|
||||||
@for (note of notifications(); track note.id) {
|
@for (note of notifications(); track note.id) {
|
||||||
<article class="floating-note" [class]="'floating-note floating-note-' + note.type">
|
<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>
|
<p>{{ note.message }}</p>
|
||||||
<button type="button" (click)="dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
|
<button type="button" (click)="$event.stopPropagation(); dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
border-color: color-mix(in srgb, var(--primary-color) 45%, white);
|
border-color: color-mix(in srgb, var(--primary-color) 45%, white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-note-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes note-in {
|
@keyframes note-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { UserNotificationService } from '../../services/user-notification.service';
|
import { Router } from '@angular/router';
|
||||||
|
import { UserNotification, UserNotificationService } from '../../services/user-notification.service';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -12,10 +13,18 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
})
|
})
|
||||||
export class FloatingNotificationsComponent {
|
export class FloatingNotificationsComponent {
|
||||||
private readonly notificationsService = inject(UserNotificationService);
|
private readonly notificationsService = inject(UserNotificationService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
readonly notifications = this.notificationsService.notifications;
|
readonly notifications = this.notificationsService.notifications;
|
||||||
|
|
||||||
dismiss(id: string): void {
|
dismiss(id: string): void {
|
||||||
this.notificationsService.dismiss(id);
|
this.notificationsService.dismiss(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
navigate(note: UserNotification): void {
|
||||||
|
if (note.route) {
|
||||||
|
void this.router.navigate(note.route);
|
||||||
|
}
|
||||||
|
this.dismiss(note.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ export interface UserNotification {
|
|||||||
id: string;
|
id: string;
|
||||||
message: string;
|
message: string;
|
||||||
type: UserNotificationType;
|
type: UserNotificationType;
|
||||||
|
/** Route to navigate to when the notification is clicked. Absent means not clickable. */
|
||||||
|
route?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -14,11 +16,12 @@ export class UserNotificationService {
|
|||||||
|
|
||||||
readonly notifications = this.state.asReadonly();
|
readonly notifications = this.state.asReadonly();
|
||||||
|
|
||||||
show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500): void {
|
show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500, route?: string[]): void {
|
||||||
const next: UserNotification = {
|
const next: UserNotification = {
|
||||||
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
message,
|
message,
|
||||||
type
|
type,
|
||||||
|
...(route ? { route } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.state.update(items => [next, ...items].slice(0, 4));
|
this.state.update(items => [next, ...items].slice(0, 4));
|
||||||
|
|||||||
@@ -1951,6 +1951,10 @@ export const en: Translations = {
|
|||||||
currencyRatesExplain: 'Rates relative to 1 RUB, used to convert storefront prices while the backend does not return prices per currency.',
|
currencyRatesExplain: 'Rates relative to 1 RUB, used to convert storefront prices while the backend does not return prices per currency.',
|
||||||
currencyRatesSave: 'Save rates',
|
currencyRatesSave: 'Save rates',
|
||||||
currencyRatesSaved: 'Rates saved',
|
currencyRatesSaved: 'Rates saved',
|
||||||
|
notificationInterval: 'New-order check interval (seconds)',
|
||||||
|
notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
|
||||||
|
notificationIntervalSave: 'Save interval',
|
||||||
|
notificationIntervalSaved: 'Interval saved',
|
||||||
},
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'No product sales in this period',
|
topProductsEmptyTitle: 'No product sales in this period',
|
||||||
@@ -2074,6 +2078,7 @@ export const en: Translations = {
|
|||||||
searchPlaceholder: 'Search products, orders, pages...',
|
searchPlaceholder: 'Search products, orders, pages...',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
notificationsEmpty: 'No new notifications',
|
notificationsEmpty: 'No new notifications',
|
||||||
|
notificationNewOrder: 'New order #{{orderNumber}}',
|
||||||
account: 'Admin account',
|
account: 'Admin account',
|
||||||
tenantSelector: 'Store',
|
tenantSelector: 'Store',
|
||||||
tenantSelectorComingSoon: 'Switching between stores is coming soon',
|
tenantSelectorComingSoon: 'Switching between stores is coming soon',
|
||||||
|
|||||||
@@ -1946,6 +1946,10 @@ export const hy: Translations = {
|
|||||||
currencyRatesExplain: 'Փոխարժեքներ՝ 1 RUB-ի նկատմամբ, օգտագործվում են կայքի գները փոխարկելու համար, քանի դեռ բեքենդը գներ չի վերադարձնում ըստ արժույթի։',
|
currencyRatesExplain: 'Փոխարժեքներ՝ 1 RUB-ի նկատմամբ, օգտագործվում են կայքի գները փոխարկելու համար, քանի դեռ բեքենդը գներ չի վերադարձնում ըստ արժույթի։',
|
||||||
currencyRatesSave: 'Պահպանել փոխարժեքները',
|
currencyRatesSave: 'Պահպանել փոխարժեքները',
|
||||||
currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',
|
currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',
|
||||||
|
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
|
||||||
|
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
|
||||||
|
notificationIntervalSave: 'Պահպանել ինտերվալը',
|
||||||
|
notificationIntervalSaved: 'Ինտերվալը պահպանվեց',
|
||||||
},
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
||||||
@@ -2069,6 +2073,7 @@ export const hy: Translations = {
|
|||||||
searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...',
|
searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...',
|
||||||
notifications: 'Ծանուցումներ',
|
notifications: 'Ծանուցումներ',
|
||||||
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
||||||
|
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
|
||||||
account: 'Ադմինիստրատորի հաշիվ',
|
account: 'Ադմինիստրատորի հաշիվ',
|
||||||
tenantSelector: 'Խանութ',
|
tenantSelector: 'Խանութ',
|
||||||
tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի',
|
tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի',
|
||||||
|
|||||||
@@ -1946,6 +1946,10 @@ export const ru: Translations = {
|
|||||||
currencyRatesExplain: 'Курсы относительно 1 RUB, используются для конвертации цен на сайте, пока бэкенд не возвращает цены в разных валютах.',
|
currencyRatesExplain: 'Курсы относительно 1 RUB, используются для конвертации цен на сайте, пока бэкенд не возвращает цены в разных валютах.',
|
||||||
currencyRatesSave: 'Сохранить курсы',
|
currencyRatesSave: 'Сохранить курсы',
|
||||||
currencyRatesSaved: 'Курсы сохранены',
|
currencyRatesSaved: 'Курсы сохранены',
|
||||||
|
notificationInterval: 'Интервал проверки новых заказов (сек)',
|
||||||
|
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
|
||||||
|
notificationIntervalSave: 'Сохранить интервал',
|
||||||
|
notificationIntervalSaved: 'Интервал сохранён',
|
||||||
},
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
||||||
@@ -2069,6 +2073,7 @@ export const ru: Translations = {
|
|||||||
searchPlaceholder: 'Искать товары, заказы, страницы...',
|
searchPlaceholder: 'Искать товары, заказы, страницы...',
|
||||||
notifications: 'Уведомления',
|
notifications: 'Уведомления',
|
||||||
notificationsEmpty: 'Новых уведомлений нет',
|
notificationsEmpty: 'Новых уведомлений нет',
|
||||||
|
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
|
||||||
account: 'Аккаунт администратора',
|
account: 'Аккаунт администратора',
|
||||||
tenantSelector: 'Магазин',
|
tenantSelector: 'Магазин',
|
||||||
tenantSelectorComingSoon: 'Переключение между магазинами скоро появится',
|
tenantSelectorComingSoon: 'Переключение между магазинами скоро появится',
|
||||||
|
|||||||
@@ -1959,6 +1959,10 @@ export interface Translations {
|
|||||||
currencyRatesExplain: string;
|
currencyRatesExplain: string;
|
||||||
currencyRatesSave: string;
|
currencyRatesSave: string;
|
||||||
currencyRatesSaved: string;
|
currencyRatesSaved: string;
|
||||||
|
notificationInterval: string;
|
||||||
|
notificationIntervalExplain: string;
|
||||||
|
notificationIntervalSave: string;
|
||||||
|
notificationIntervalSaved: string;
|
||||||
};
|
};
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: string;
|
topProductsEmptyTitle: string;
|
||||||
@@ -2082,6 +2086,7 @@ export interface Translations {
|
|||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
notifications: string;
|
notifications: string;
|
||||||
notificationsEmpty: string;
|
notificationsEmpty: string;
|
||||||
|
notificationNewOrder: string;
|
||||||
account: string;
|
account: string;
|
||||||
tenantSelector: string;
|
tenantSelector: string;
|
||||||
tenantSelectorComingSoon: string;
|
tenantSelectorComingSoon: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user