From 0d0f4e5f9cfea9b1919082004210bb4b83d0a90b Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 02:08:36 +0400 Subject: [PATCH 01/10] docs: design spec for admin purchase notifications (item 7) Co-Authored-By: Claude Sonnet 5 --- ...-15-admin-purchase-notifications-design.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md diff --git a/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md b/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md new file mode 100644 index 0000000..c07b35a --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md @@ -0,0 +1,77 @@ +# 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** + +- `unreadCount` signal (from `AdminOrderWatcherService`) rendered on the admin + sidebar's "Orders" nav item. +- Visiting the orders list marks all currently-known orders as seen → badge resets to 0. + +**Click behavior** + +- Toast click → `/admin/orders/:id` (the new order's detail page). +- Badge click → orders list. + +## 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. From cfee91355b8fa6bf36d97bd76120ffbdae01f607 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 03:20:32 +0400 Subject: [PATCH 02/10] docs: implementation plan for admin purchase notifications (item 7) Co-Authored-By: Claude Sonnet 5 --- ...2026-08-15-admin-purchase-notifications.md | 944 ++++++++++++++++++ 1 file changed, 944 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md diff --git a/docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md b/docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md new file mode 100644 index 0000000..673617c --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md @@ -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([]); + + 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) { + +} +``` + +- [ ] **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` (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` +- Produces: `AdminOrderWatcherService.unreadCount: Signal` +- Produces: `AdminOrderWatcherService.intervalMs: Signal` +- 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([]); + readonly recentOrders: Signal = this.recentOrdersSignal.asReadonly(); + + private readonly lastAcknowledgedOrderIdSignal = signal(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(this.readStoredIntervalMs()); + readonly intervalMs: Signal = this.intervalMsSignal.asReadonly(); + + private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY); + private timerId: ReturnType | 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>; + unreadCount: ReturnType>; + start: jasmine.Spy; + markAllSeen: jasmine.Spy; + }; + + beforeEach(() => { + watcherStub = { + recentOrders: signal([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 +
+ + @if (notificationsOpen()) { + + } +
+``` + +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>; + 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 ``): + +```html +
+

{{ 'adminSettings.notificationInterval' | translate }}

+

{{ 'adminSettings.notificationIntervalExplain' | translate }}

+
+ +
+
+ + {{ 'adminSettings.notificationIntervalSaved' | translate }} +
+
+``` + +- [ ] **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. From 55e4938a57dfc9f280c552f18b861f342306e15c Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 03:43:07 +0400 Subject: [PATCH 03/10] docs: spec update - reuse existing topbar bell instead of new sidebar badge Co-Authored-By: Claude Sonnet 5 --- ...-15-admin-purchase-notifications-design.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md b/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md index c07b35a..fd27efa 100644 --- a/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md +++ b/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md @@ -39,16 +39,25 @@ status, is plain polling), so this has to be poll-based like the rest of the app - `FloatingNotificationsComponent` gets a click handler: navigate to `route` (if set) then dismiss. -**Badge** +**Badge — reuses existing topbar bell** -- `unreadCount` signal (from `AdminOrderWatcherService`) rendered on the admin - sidebar's "Orders" nav item. -- Visiting the orders list marks all currently-known orders as seen → badge resets to 0. +`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). -- Badge click → orders list. +- Clicking an order row inside the bell panel → same, then closes the panel. ## Data flow From 5ed49368989f827590e75e42fe94af30e6b733ae Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 03:47:46 +0400 Subject: [PATCH 04/10] feat: UserNotificationService supports click-to-navigate toasts --- .../floating-notifications.component.html | 9 +++++-- .../floating-notifications.component.ts | 11 +++++++- .../user-notification.service.spec.ts | 26 +++++++++++++++++++ .../services/user-notification.service.ts | 7 +++-- 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 src/app/features/website/user-experience/services/user-notification.service.spec.ts diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html index 899418e..5d47d7d 100644 --- a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html +++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html @@ -1,9 +1,14 @@ @if (notifications().length > 0) { diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts index 822de70..39a4fe0 100644 --- a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts +++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts @@ -1,5 +1,6 @@ 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'; @Component({ @@ -12,10 +13,18 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe'; }) 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); + } } diff --git a/src/app/features/website/user-experience/services/user-notification.service.spec.ts b/src/app/features/website/user-experience/services/user-notification.service.spec.ts new file mode 100644 index 0000000..f58cdac --- /dev/null +++ b/src/app/features/website/user-experience/services/user-notification.service.spec.ts @@ -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(); + }); +}); diff --git a/src/app/features/website/user-experience/services/user-notification.service.ts b/src/app/features/website/user-experience/services/user-notification.service.ts index 05a9728..2b0ca04 100644 --- a/src/app/features/website/user-experience/services/user-notification.service.ts +++ b/src/app/features/website/user-experience/services/user-notification.service.ts @@ -6,6 +6,8 @@ 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' }) @@ -14,11 +16,12 @@ export class UserNotificationService { 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 = { id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, message, - type + type, + ...(route ? { route } : {}), }; this.state.update(items => [next, ...items].slice(0, 4)); From 28f39a31f6ad141dc9fa8c1fa6d3f0cb2ff80fb3 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 03:52:55 +0400 Subject: [PATCH 05/10] feat: AdminOrderWatcherService polls for new orders and toasts Co-Authored-By: Claude Sonnet 5 --- .../admin-order-watcher.service.spec.ts | 122 ++++++++++++++++ .../services/admin-order-watcher.service.ts | 132 ++++++++++++++++++ src/app/i18n/en.ts | 1 + src/app/i18n/hy.ts | 1 + src/app/i18n/ru.ts | 1 + src/app/i18n/translations.ts | 1 + 6 files changed, 258 insertions(+) create mode 100644 src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts create mode 100644 src/app/features/admin/shell/services/admin-order-watcher.service.ts diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts new file mode 100644 index 0000000..4873f5c --- /dev/null +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts @@ -0,0 +1,122 @@ +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: 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', () => { + service.setIntervalSeconds(30); + expect(service.intervalMs()).toBe(30000); + + service.setIntervalSeconds(0); + expect(service.intervalMs()).toBe(30000); + + service.setIntervalSeconds(-5); + expect(service.intervalMs()).toBe(30000); + }); +}); diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.ts new file mode 100644 index 0000000..6a55841 --- /dev/null +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.ts @@ -0,0 +1,132 @@ +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([]); + readonly recentOrders: Signal = this.recentOrdersSignal.asReadonly(); + + private readonly lastAcknowledgedOrderIdSignal = signal(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(this.readStoredIntervalMs()); + readonly intervalMs: Signal = this.intervalMsSignal.asReadonly(); + + private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY); + private timerId: ReturnType | 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; + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index db16bc2..fd7f88e 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -2074,6 +2074,7 @@ export const en: Translations = { searchPlaceholder: 'Search products, orders, pages...', notifications: 'Notifications', notificationsEmpty: 'No new notifications', + notificationNewOrder: 'New order #{{orderNumber}}', account: 'Admin account', tenantSelector: 'Store', tenantSelectorComingSoon: 'Switching between stores is coming soon', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 5b76a0b..144e43d 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -2069,6 +2069,7 @@ export const hy: Translations = { searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...', notifications: 'Ծանուցումներ', notificationsEmpty: 'Նոր ծանուցումներ չկան', + notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}', account: 'Ադմինիստրատորի հաշիվ', tenantSelector: 'Խանութ', tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 21174c8..4a2a39f 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -2069,6 +2069,7 @@ export const ru: Translations = { searchPlaceholder: 'Искать товары, заказы, страницы...', notifications: 'Уведомления', notificationsEmpty: 'Новых уведомлений нет', + notificationNewOrder: 'Новый заказ №{{orderNumber}}', account: 'Аккаунт администратора', tenantSelector: 'Магазин', tenantSelectorComingSoon: 'Переключение между магазинами скоро появится', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index fd940a0..59f93a2 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -2082,6 +2082,7 @@ export interface Translations { searchPlaceholder: string; notifications: string; notificationsEmpty: string; + notificationNewOrder: string; account: string; tenantSelector: string; tenantSelectorComingSoon: string; From 35b1c7ed27c98db1e464d320d8c9359a76422900 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 03:59:23 +0400 Subject: [PATCH 06/10] feat: wire order watcher into admin topbar bell (badge + panel) Co-Authored-By: Claude Sonnet 5 --- .../admin/shell/admin-layout.component.html | 20 ++++- .../admin/shell/admin-layout.component.scss | 36 +++++++++ .../shell/admin-layout.component.spec.ts | 76 +++++++++++++++++++ .../admin/shell/admin-layout.component.ts | 14 ++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/app/features/admin/shell/admin-layout.component.spec.ts diff --git a/src/app/features/admin/shell/admin-layout.component.html b/src/app/features/admin/shell/admin-layout.component.html index 9396f75..c2229e7 100644 --- a/src/app/features/admin/shell/admin-layout.component.html +++ b/src/app/features/admin/shell/admin-layout.component.html @@ -148,10 +148,28 @@ (click)="toggleNotifications()" > + @if (unreadCount() > 0) { + {{ unreadCount() }} + } @if (notificationsOpen()) { } diff --git a/src/app/features/admin/shell/admin-layout.component.scss b/src/app/features/admin/shell/admin-layout.component.scss index 0cb80fd..1bd2613 100644 --- a/src/app/features/admin/shell/admin-layout.component.scss +++ b/src/app/features/admin/shell/admin-layout.component.scss @@ -347,6 +347,21 @@ 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 { position: absolute; right: 0; @@ -366,6 +381,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 { display: flex; align-items: center; diff --git a/src/app/features/admin/shell/admin-layout.component.spec.ts b/src/app/features/admin/shell/admin-layout.component.spec.ts new file mode 100644 index 0000000..3adb993 --- /dev/null +++ b/src/app/features/admin/shell/admin-layout.component.spec.ts @@ -0,0 +1,76 @@ +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>; + unreadCount: ReturnType>; + start: jasmine.Spy; + markAllSeen: jasmine.Spy; + }; + + beforeEach(() => { + watcherStub = { + recentOrders: signal([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); + }); +}); diff --git a/src/app/features/admin/shell/admin-layout.component.ts b/src/app/features/admin/shell/admin-layout.component.ts index db12d00..db783e8 100644 --- a/src/app/features/admin/shell/admin-layout.component.ts +++ b/src/app/features/admin/shell/admin-layout.component.ts @@ -12,6 +12,7 @@ import { IconComponent } from '../../../shared/ui/icon/icon.component'; import { AdminPreferencesService } from '../settings/services/admin-preferences.service'; import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade'; import { ConfigService } from '../../../core/config/config.service'; +import { AdminOrderWatcherService } from './services/admin-order-watcher.service'; @Component({ selector: 'app-admin-layout', @@ -31,6 +32,10 @@ export class AdminLayoutComponent { private readonly preferences = inject(AdminPreferencesService); private readonly uiRuntime = inject(UiRuntimeFacade); 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; @@ -87,6 +92,7 @@ export class AdminLayoutComponent { constructor() { this.readRouteData(); + this.orderWatcher.start(); this.router.events .pipe( filter(event => event instanceof NavigationEnd), @@ -130,6 +136,9 @@ export class AdminLayoutComponent { toggleNotifications(): void { this.notificationsOpen.update(open => !open); + if (this.notificationsOpen()) { + this.orderWatcher.markAllSeen(); + } } adminLinkFor(entry: Extract): string[] { @@ -140,6 +149,11 @@ export class AdminLayoutComponent { 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[] { return ['/', this.currentLang(), 'backoffice', ...(entry.path ?? [])]; } From 9ccd807a55b433229480f5dcb01b9951fba0a2f1 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 04:04:32 +0400 Subject: [PATCH 07/10] feat: editable new-order poll interval in admin settings Co-Authored-By: Claude Sonnet 5 --- .../pages/admin-settings-page.component.html | 19 +++++++++ .../admin-settings-page.component.spec.ts | 42 +++++++++++++++++++ .../pages/admin-settings-page.component.ts | 11 +++++ src/app/i18n/en.ts | 4 ++ src/app/i18n/hy.ts | 4 ++ src/app/i18n/ru.ts | 4 ++ src/app/i18n/translations.ts | 4 ++ 7 files changed, 88 insertions(+) create mode 100644 src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.html b/src/app/features/admin/settings/pages/admin-settings-page.component.html index 00ba29e..6b415fa 100644 --- a/src/app/features/admin/settings/pages/admin-settings-page.component.html +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.html @@ -32,4 +32,23 @@ {{ 'adminSettings.currencyRatesSaved' | translate }} + +
+

{{ 'adminSettings.notificationInterval' | translate }}

+

{{ 'adminSettings.notificationIntervalExplain' | translate }}

+
+ +
+
+ + {{ 'adminSettings.notificationIntervalSaved' | translate }} +
+
diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts b/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts new file mode 100644 index 0000000..32859cb --- /dev/null +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts @@ -0,0 +1,42 @@ +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>; + 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); + }); +}); diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.ts b/src/app/features/admin/settings/pages/admin-settings-page.component.ts index da46840..b32a6a4 100644 --- a/src/app/features/admin/settings/pages/admin-settings-page.component.ts +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.ts @@ -6,6 +6,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component'; import { CurrencyRatesService } from '../../../../services/currency-rates.service'; import { LanguageService } from '../../../../services/language.service'; +import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service'; const SAVED_MESSAGE_DURATION_MS = 2000; @@ -25,6 +26,16 @@ export class AdminSettingsPageComponent { readonly rateDrafts = signal>({ ...this.currencyRates.rates() }); readonly showSavedMessage = signal(false); + 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); + } + onCompactToggle(compact: boolean): void { this.preferences.setDensity(compact ? 'compact' : 'comfortable'); } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index fd7f88e..d543aac 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -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.', currencyRatesSave: 'Save rates', 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: { topProductsEmptyTitle: 'No product sales in this period', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 144e43d..68ff444 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1946,6 +1946,10 @@ export const hy: Translations = { currencyRatesExplain: 'Փոխարժեքներ՝ 1 RUB-ի նկատմամբ, օգտագործվում են կայքի գները փոխարկելու համար, քանի դեռ բեքենդը գներ չի վերադարձնում ըստ արժույթի։', currencyRatesSave: 'Պահպանել փոխարժեքները', currencyRatesSaved: 'Փոխարժեքները պահպանվեցին', + notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)', + notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։', + notificationIntervalSave: 'Պահպանել ինտերվալը', + notificationIntervalSaved: 'Ինտերվալը պահպանվեց', }, adminAnalytics: { topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 4a2a39f..a2963a9 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1946,6 +1946,10 @@ export const ru: Translations = { currencyRatesExplain: 'Курсы относительно 1 RUB, используются для конвертации цен на сайте, пока бэкенд не возвращает цены в разных валютах.', currencyRatesSave: 'Сохранить курсы', currencyRatesSaved: 'Курсы сохранены', + notificationInterval: 'Интервал проверки новых заказов (сек)', + notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.', + notificationIntervalSave: 'Сохранить интервал', + notificationIntervalSaved: 'Интервал сохранён', }, adminAnalytics: { topProductsEmptyTitle: 'Нет продаж товаров за этот период', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 59f93a2..4227d80 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1959,6 +1959,10 @@ export interface Translations { currencyRatesExplain: string; currencyRatesSave: string; currencyRatesSaved: string; + notificationInterval: string; + notificationIntervalExplain: string; + notificationIntervalSave: string; + notificationIntervalSaved: string; }; adminAnalytics: { topProductsEmptyTitle: string; From 1032891d262365f68831d043136e047d08b38077 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 04:18:20 +0400 Subject: [PATCH 08/10] fix: address final review findings (order-notification watcher robustness) Co-Authored-By: Claude Sonnet 5 --- .../admin-settings-page.component.spec.ts | 29 ++++++++++- .../pages/admin-settings-page.component.ts | 9 ++-- .../admin/shell/admin-layout.component.scss | 2 + .../shell/admin-layout.component.spec.ts | 9 ++++ .../admin/shell/admin-layout.component.ts | 1 + .../admin-order-watcher.service.spec.ts | 51 +++++++++++++++++-- .../services/admin-order-watcher.service.ts | 37 +++++++++++--- .../floating-notifications.component.scss | 4 ++ 8 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts b/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts index 32859cb..4865ae9 100644 --- a/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts @@ -13,7 +13,7 @@ describe('AdminSettingsPageComponent notification interval', () => { beforeEach(() => { watcherStub = { intervalMs: signal(15000), - setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'), + setIntervalSeconds: jasmine.createSpy('setIntervalSeconds').and.returnValue(true), }; TestBed.configureTestingModule({ @@ -39,4 +39,31 @@ describe('AdminSettingsPageComponent notification interval', () => { 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); + }); }); diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.ts b/src/app/features/admin/settings/pages/admin-settings-page.component.ts index b32a6a4..223b155 100644 --- a/src/app/features/admin/settings/pages/admin-settings-page.component.ts +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.ts @@ -31,9 +31,12 @@ export class AdminSettingsPageComponent { readonly showNotificationIntervalSaved = signal(false); saveNotificationInterval(): void { - this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft()); - this.showNotificationIntervalSaved.set(true); - setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS); + 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 { diff --git a/src/app/features/admin/shell/admin-layout.component.scss b/src/app/features/admin/shell/admin-layout.component.scss index 1bd2613..16a75e2 100644 --- a/src/app/features/admin/shell/admin-layout.component.scss +++ b/src/app/features/admin/shell/admin-layout.component.scss @@ -367,6 +367,8 @@ right: 0; top: calc(100% + 8px); width: 240px; + max-height: 60vh; + overflow-y: auto; padding: var(--space-md); background: var(--bg-primary); border: 1px solid var(--border-color); diff --git a/src/app/features/admin/shell/admin-layout.component.spec.ts b/src/app/features/admin/shell/admin-layout.component.spec.ts index 3adb993..5423c1a 100644 --- a/src/app/features/admin/shell/admin-layout.component.spec.ts +++ b/src/app/features/admin/shell/admin-layout.component.spec.ts @@ -30,6 +30,7 @@ describe('AdminLayoutComponent notifications bell', () => { recentOrders: ReturnType>; unreadCount: ReturnType>; start: jasmine.Spy; + stop: jasmine.Spy; markAllSeen: jasmine.Spy; }; @@ -38,6 +39,7 @@ describe('AdminLayoutComponent notifications bell', () => { recentOrders: signal([makeOrder('o1', '1001')]), unreadCount: signal(1), start: jasmine.createSpy('start'), + stop: jasmine.createSpy('stop'), markAllSeen: jasmine.createSpy('markAllSeen'), }; @@ -73,4 +75,11 @@ describe('AdminLayoutComponent notifications bell', () => { 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); + }); }); diff --git a/src/app/features/admin/shell/admin-layout.component.ts b/src/app/features/admin/shell/admin-layout.component.ts index db783e8..c1649f4 100644 --- a/src/app/features/admin/shell/admin-layout.component.ts +++ b/src/app/features/admin/shell/admin-layout.component.ts @@ -93,6 +93,7 @@ export class AdminLayoutComponent { constructor() { this.readRouteData(); this.orderWatcher.start(); + this.destroyRef.onDestroy(() => this.orderWatcher.stop()); this.router.events .pipe( filter(event => event instanceof NavigationEnd), diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts index 4873f5c..6220b18 100644 --- a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts @@ -48,7 +48,10 @@ describe('AdminOrderWatcherService', () => { [makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')], ]; - localStorage.clear(); + localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderId.v1'); + localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderCreatedAt.v1'); + localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.v1'); + localStorage.removeItem('adminOrderWatcher.pollIntervalMs.v1'); TestBed.configureTestingModule({ providers: [ @@ -110,13 +113,53 @@ describe('AdminOrderWatcherService', () => { })); it('setIntervalSeconds updates intervalMs and rejects invalid values', () => { - service.setIntervalSeconds(30); + expect(service.setIntervalSeconds(30)).toBe(true); expect(service.intervalMs()).toBe(30000); - service.setIntervalSeconds(0); + expect(service.setIntervalSeconds(0)).toBe(false); expect(service.intervalMs()).toBe(30000); - service.setIntervalSeconds(-5); + 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('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); + })); }); diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.ts index 6a55841..7e5efe0 100644 --- a/src/app/features/admin/shell/services/admin-order-watcher.service.ts +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.ts @@ -7,6 +7,7 @@ import { LanguageService } from '../../../../services/language.service'; import { TranslateService } from '../../../../i18n/translate.service'; const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1'; +const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1'; const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1'; const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1'; export const DEFAULT_POLL_INTERVAL_MS = 15000; @@ -44,6 +45,7 @@ export class AdminOrderWatcherService { readonly intervalMs: Signal = 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 | null = null; private started = false; @@ -56,9 +58,17 @@ export class AdminOrderWatcherService { this.scheduleNext(); } - setIntervalSeconds(seconds: number): void { + 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; + return false; } const ms = Math.round(seconds * 1000); this.intervalMsSignal.set(ms); @@ -66,14 +76,16 @@ export class AdminOrderWatcherService { if (this.started) { this.scheduleNext(); } + return true; } markAllSeen(): void { - const newestId = this.recentOrdersSignal()[0]?.id ?? null; - this.lastAcknowledgedOrderIdSignal.set(newestId); - if (newestId) { - this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId); + const newestId = this.recentOrdersSignal()[0]?.id; + if (!newestId) { + return; } + this.lastAcknowledgedOrderIdSignal.set(newestId); + this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId); } private scheduleNext(): void { @@ -99,10 +111,21 @@ export class AdminOrderWatcherService { 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)); + // 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 diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss index 124d69d..02c1a5d 100644 --- a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss +++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss @@ -49,6 +49,10 @@ border-color: color-mix(in srgb, var(--primary-color) 45%, white); } +.floating-note-clickable { + cursor: pointer; +} + @keyframes note-in { from { opacity: 0; From f1ee199d92be444c218eb4157a68fbe19838e9aa Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 04:25:51 +0400 Subject: [PATCH 09/10] fix: AdminOrderWatcherService reacts to auth state, not component lifecycle Previous fix (1032891) stopped polling via AdminLayoutComponent's DestroyRef, but logout() never navigates or destroys the component - the watcher kept polling and toasting indefinitely after logout. Now polling starts/stops off AdminAuthService.isAuthenticated() directly, following the same effect() pattern already used by AdminDashboardFacade. Co-Authored-By: Claude Sonnet 5 --- .../admin-order-watcher.service.spec.ts | 42 +++++++++++++++++++ .../services/admin-order-watcher.service.ts | 18 +++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts index 6220b18..b67274f 100644 --- a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts @@ -1,10 +1,12 @@ 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 { @@ -31,6 +33,7 @@ describe('AdminOrderWatcherService', () => { let pollIndex: number; let notifications: UserNotificationService; let service: AdminOrderWatcherService; + let fakeAdminAuth: { isAuthenticated: ReturnType> }; function fakeGateway() { return { @@ -53,10 +56,16 @@ describe('AdminOrderWatcherService', () => { localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.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 }, ], }); @@ -162,4 +171,37 @@ describe('AdminOrderWatcherService', () => { 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); + })); }); diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.ts index 7e5efe0..0223842 100644 --- a/src/app/features/admin/shell/services/admin-order-watcher.service.ts +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.ts @@ -1,10 +1,11 @@ -import { Injectable, Signal, computed, inject, signal } from '@angular/core'; +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'; @@ -22,6 +23,7 @@ export class AdminOrderWatcherService { private readonly notifications = inject(UserNotificationService); private readonly languageService = inject(LanguageService); private readonly i18n = inject(TranslateService); + private readonly adminAuth = inject(AdminAuthService); private readonly recentOrdersSignal = signal([]); readonly recentOrders: Signal = this.recentOrdersSignal.asReadonly(); @@ -49,6 +51,20 @@ export class AdminOrderWatcherService { private timerId: ReturnType | 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; From 1ab689e056f73bdb4091ef6046ca03ef01691b22 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 15 Aug 2026 04:33:25 +0400 Subject: [PATCH 10/10] fix: unreadCount badge falls back to timestamp when ack pointer scrolls off page Co-Authored-By: Claude Sonnet 5 --- .../admin-order-watcher.service.spec.ts | 34 +++++++++++++++++++ .../services/admin-order-watcher.service.ts | 23 ++++++++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts index b67274f..7970045 100644 --- a/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts @@ -54,6 +54,7 @@ describe('AdminOrderWatcherService', () => { 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 @@ -157,6 +158,39 @@ describe('AdminOrderWatcherService', () => { 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(); diff --git a/src/app/features/admin/shell/services/admin-order-watcher.service.ts b/src/app/features/admin/shell/services/admin-order-watcher.service.ts index 0223842..f9a8472 100644 --- a/src/app/features/admin/shell/services/admin-order-watcher.service.ts +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.ts @@ -10,6 +10,7 @@ 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; @@ -29,6 +30,7 @@ export class AdminOrderWatcherService { readonly recentOrders: Signal = this.recentOrdersSignal.asReadonly(); private readonly lastAcknowledgedOrderIdSignal = signal(this.storage.getItem(LAST_ACKNOWLEDGED_KEY)); + private readonly lastAcknowledgedOrderCreatedAtSignal = signal(this.storage.getItem(LAST_ACKNOWLEDGED_AT_KEY)); readonly unreadCount = computed(() => { const orders = this.recentOrdersSignal(); @@ -40,7 +42,14 @@ export class AdminOrderWatcherService { return orders.length; } const idx = orders.findIndex(order => order.id === ackId); - return idx === -1 ? orders.length : idx; + 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(this.readStoredIntervalMs()); @@ -96,12 +105,14 @@ export class AdminOrderWatcherService { } markAllSeen(): void { - const newestId = this.recentOrdersSignal()[0]?.id; - if (!newestId) { + const newest = this.recentOrdersSignal()[0]; + if (!newest) { return; } - this.lastAcknowledgedOrderIdSignal.set(newestId); - this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId); + 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 { @@ -148,7 +159,9 @@ export class AdminOrderWatcherService { // 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; }