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. 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..fd27efa --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md @@ -0,0 +1,86 @@ +# Admin purchase notifications — design + +**Status:** Approved +**Date:** 2026-08-15 +**Related backlog item:** #7 (marked ВАЖНО — important) + +## Problem + +Admin has no signal when a purchase happens on the marketplace. Orders only surface if +someone manually opens the Orders list and refreshes. Backend exposes no WebSocket/SSE +(confirmed in `BACKEND-API-REFERENCE.md:20` — every "live" feature today, e.g. payment +status, is plain polling), so this has to be poll-based like the rest of the app. + +## Architecture + +**`AdminOrderWatcherService`** (new, `providedIn: root`, admin-scoped) + +- Polls `AdminOrdersLocalGateway.loadOrders()` (sorted `createdAt` desc, already the + default sort) on an interval. +- Diffs the newest order's `id`/`createdAt` against the last-seen value, kept in memory + and persisted via `LocalStorageService` (survives page reload, same pattern as + `AdminPreferencesService`). +- On finding order(s) newer than last-seen: fires one toast per new order and + increments an `unreadCount` signal. +- Started once at the admin shell root, so it keeps polling regardless of which admin + page is open. + +**Poll interval** + +- Editable by admin, default 15s. +- Setting lives in the same admin-settings page as currency rates + (`admin-settings-page.component.ts`), persisted via `LocalStorageService`. + +**Toast delivery** + +- Reuses the existing `UserNotificationService` / `FloatingNotificationsComponent` + (already global — `providedIn: root`, mounted once in `app.html`). No new toast UI. +- `UserNotification` gains an optional `route: string[]` field. +- `FloatingNotificationsComponent` gets a click handler: navigate to `route` (if set) + then dismiss. + +**Badge — reuses existing topbar bell** + +`admin-layout.component.html:141-157` already has an unused bell icon + +dropdown panel (currently hardcoded to always show "no notifications"). +Wire the watcher's data into it instead of adding a new indicator: + +- `unreadCount` signal (from `AdminOrderWatcherService`) rendered as a badge + on the bell icon (`admin-layout__icon-button`). +- Opening the panel (`notificationsOpen()`, already wired to the bell click) + lists the unread new orders instead of the static "notificationsEmpty" + text. +- Opening the panel marks all currently-known orders as seen → badge resets + to 0 (same trigger `AdminLayoutComponent.toggleNotifications()` already + has). + +**Click behavior** + +- Toast click → `/admin/orders/:id` (the new order's detail page). +- Clicking an order row inside the bell panel → same, then closes the panel. + +## Data flow + +``` +AdminOrderWatcherService (interval timer) + -> AdminOrdersLocalGateway.loadOrders() + -> diff against last-seen order id/createdAt (LocalStorageService) + -> new order(s) found? + -> UserNotificationService.show(message, 'info', { route: ['/admin/orders', id] }) + -> unreadCount.update(n => n + 1) + -> admin clicks toast/badge -> router navigate -> orders-list visit resets unreadCount +``` + +## Error handling + +Poll failures are silent/logged only (`console.error`), consistent with existing +polling code (payment status polling in `cart.component.ts`). No toast spam on +transient network errors — watcher just retries on the next interval. + +## Out of scope + +- Native OS push notifications (tab not focused) — user explicitly chose in-app + toast/badge only, not browser Notification API. +- Sound alerts — not selected. +- Telegram/email alerts to staff — not selected, would need backend bot/mail + integration. 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..4865ae9 --- /dev/null +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import { AdminSettingsPageComponent } from './admin-settings-page.component'; +import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service'; + +describe('AdminSettingsPageComponent notification interval', () => { + let watcherStub: { + intervalMs: ReturnType>; + setIntervalSeconds: jasmine.Spy; + }; + + beforeEach(() => { + watcherStub = { + intervalMs: signal(15000), + setIntervalSeconds: jasmine.createSpy('setIntervalSeconds').and.returnValue(true), + }; + + TestBed.configureTestingModule({ + imports: [AdminSettingsPageComponent], + providers: [ + provideRouter([]), + { provide: AdminOrderWatcherService, useValue: watcherStub }, + ], + }); + }); + + it('initializes the draft from the current interval in seconds', () => { + const fixture = TestBed.createComponent(AdminSettingsPageComponent); + expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15); + }); + + it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => { + const fixture = TestBed.createComponent(AdminSettingsPageComponent); + const component = fixture.componentInstance; + + component.notificationIntervalSecondsDraft.set(30); + component.saveNotificationInterval(); + + expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30); + }); + + it('shows the saved message and keeps the draft when the interval is applied', () => { + watcherStub.setIntervalSeconds.and.callFake((seconds: number) => { + watcherStub.intervalMs.set(seconds * 1000); + return true; + }); + const fixture = TestBed.createComponent(AdminSettingsPageComponent); + const component = fixture.componentInstance; + + component.notificationIntervalSecondsDraft.set(30); + component.saveNotificationInterval(); + + expect(component.showNotificationIntervalSaved()).toBe(true); + expect(component.notificationIntervalSecondsDraft()).toBe(30); + }); + + it('does not show the saved message and reverts the draft when the interval is rejected', () => { + watcherStub.setIntervalSeconds.and.returnValue(false); + const fixture = TestBed.createComponent(AdminSettingsPageComponent); + const component = fixture.componentInstance; + + component.notificationIntervalSecondsDraft.set(0); + component.saveNotificationInterval(); + + expect(component.showNotificationIntervalSaved()).toBe(false); + expect(component.notificationIntervalSecondsDraft()).toBe(15); + }); +}); 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..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 @@ -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,19 @@ 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 { + 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 { this.preferences.setDensity(compact ? 'compact' : 'comfortable'); } 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..16a75e2 100644 --- a/src/app/features/admin/shell/admin-layout.component.scss +++ b/src/app/features/admin/shell/admin-layout.component.scss @@ -347,11 +347,28 @@ 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; 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); @@ -366,6 +383,27 @@ } } +.admin-layout__notification-item { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 8px 10px; + border: none; + background: none; + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm, 4px); +} + +.admin-layout__notification-item:hover { + background: var(--bg-secondary, #f4f6f5); +} + +.admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); } +.admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); } +.admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); } + .admin-layout__account { 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..5423c1a --- /dev/null +++ b/src/app/features/admin/shell/admin-layout.component.spec.ts @@ -0,0 +1,85 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import { AdminLayoutComponent } from './admin-layout.component'; +import { AdminOrderWatcherService } from './services/admin-order-watcher.service'; +import { AdminOrder } from '../orders/models/admin-order.model'; + +function makeOrder(id: string, orderNumber: string): AdminOrder { + return { + id, + orderNumber, + status: 'pending', + customer: { name: 'Test Customer', email: 't@example.com', phone: '' }, + payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' }, + shipping: { address: '', method: '', trackingNumber: '' }, + items: [], + total: 500, + currency: 'RUB', + notes: '', + internalNotes: '', + timeline: [], + archived: false, + createdAt: '2026-08-15T10:00:00.000Z', + updatedAt: '2026-08-15T10:00:00.000Z', + }; +} + +describe('AdminLayoutComponent notifications bell', () => { + let watcherStub: { + recentOrders: ReturnType>; + unreadCount: ReturnType>; + start: jasmine.Spy; + stop: jasmine.Spy; + markAllSeen: jasmine.Spy; + }; + + beforeEach(() => { + watcherStub = { + recentOrders: signal([makeOrder('o1', '1001')]), + unreadCount: signal(1), + start: jasmine.createSpy('start'), + stop: jasmine.createSpy('stop'), + markAllSeen: jasmine.createSpy('markAllSeen'), + }; + + TestBed.configureTestingModule({ + imports: [AdminLayoutComponent], + providers: [ + provideRouter([]), + { provide: AdminOrderWatcherService, useValue: watcherStub }, + ], + }); + }); + + it('starts the watcher once on construction', () => { + TestBed.createComponent(AdminLayoutComponent); + expect(watcherStub.start).toHaveBeenCalledTimes(1); + }); + + it('exposes unreadCount and recentOrders from the watcher', () => { + const fixture = TestBed.createComponent(AdminLayoutComponent); + const component = fixture.componentInstance; + expect(component.unreadCount()).toBe(1); + expect(component.recentOrders().map(o => o.id)).toEqual(['o1']); + }); + + it('marks orders seen when the notifications panel opens', () => { + const fixture = TestBed.createComponent(AdminLayoutComponent); + const component = fixture.componentInstance; + component.toggleNotifications(); + expect(component.notificationsOpen()).toBe(true); + expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1); + + component.toggleNotifications(); + expect(component.notificationsOpen()).toBe(false); + expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1); + }); + + it('stops the watcher when the component is destroyed', () => { + const fixture = TestBed.createComponent(AdminLayoutComponent); + fixture.detectChanges(); + fixture.destroy(); + expect(watcherStub.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/admin/shell/admin-layout.component.ts b/src/app/features/admin/shell/admin-layout.component.ts index db12d00..c1649f4 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,8 @@ export class AdminLayoutComponent { constructor() { this.readRouteData(); + this.orderWatcher.start(); + this.destroyRef.onDestroy(() => this.orderWatcher.stop()); this.router.events .pipe( filter(event => event instanceof NavigationEnd), @@ -130,6 +137,9 @@ export class AdminLayoutComponent { toggleNotifications(): void { this.notificationsOpen.update(open => !open); + if (this.notificationsOpen()) { + this.orderWatcher.markAllSeen(); + } } adminLinkFor(entry: Extract): string[] { @@ -140,6 +150,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 ?? [])]; } 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..7970045 --- /dev/null +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts @@ -0,0 +1,241 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { AdminOrderWatcherService } from './admin-order-watcher.service'; +import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; +import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model'; +import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; +import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service'; + +function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder { + return { + id, + orderNumber, + status: 'pending', + customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' }, + payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' }, + shipping: { address: '', method: '', trackingNumber: '' }, + items: [], + total: 1000, + currency: 'RUB', + notes: '', + internalNotes: '', + timeline: [], + archived: false, + createdAt, + updatedAt: createdAt, + }; +} + +describe('AdminOrderWatcherService', () => { + let ordersByPoll: AdminOrder[][]; + let pollIndex: number; + let notifications: UserNotificationService; + let service: AdminOrderWatcherService; + let fakeAdminAuth: { isAuthenticated: ReturnType> }; + + function fakeGateway() { + return { + loadOrders: () => { + const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1]; + const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 }; + return of(result); + }, + }; + } + + beforeEach(() => { + pollIndex = 0; + ordersByPoll = [ + [makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')], + ]; + + localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderId.v1'); + localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderCreatedAt.v1'); + localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.v1'); + localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1'); + localStorage.removeItem('adminOrderWatcher.pollIntervalMs.v1'); + + // Defaults to unauthenticated so the reactive effect stays a no-op and the + // pre-existing tests below (which drive polling via explicit start()/stop() + // calls) keep their original behavior unchanged. + fakeAdminAuth = { isAuthenticated: signal(false) }; + + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway }, + { provide: AdminAuthService, useValue: fakeAdminAuth as unknown as AdminAuthService }, + ], + }); + + notifications = TestBed.inject(UserNotificationService); + service = TestBed.inject(AdminOrderWatcherService); + }); + + it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => { + service.start(); + tick(0); + + expect(notifications.notifications().length).toBe(0); + expect(service.unreadCount()).toBe(0); + expect(service.recentOrders().map((o: AdminOrder) => o.id)).toEqual(['o2', 'o1']); + })); + + it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => { + service.start(); + tick(0); + + pollIndex = 1; + ordersByPoll.push([ + makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'), + makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), + makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'), + ]); + + tick(service.intervalMs()); + + expect(notifications.notifications().length).toBe(1); + expect(notifications.notifications()[0].message).toContain('1003'); + expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']); + expect(service.unreadCount()).toBe(1); + })); + + it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => { + service.start(); + tick(0); + + pollIndex = 1; + ordersByPoll.push([ + makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'), + makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), + makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'), + ]); + tick(service.intervalMs()); + + expect(service.unreadCount()).toBe(1); + + service.markAllSeen(); + + expect(service.unreadCount()).toBe(0); + expect(service.recentOrders().map((o: AdminOrder) => o.id)).toEqual(['o3', 'o2', 'o1']); + })); + + it('setIntervalSeconds updates intervalMs and rejects invalid values', () => { + expect(service.setIntervalSeconds(30)).toBe(true); + expect(service.intervalMs()).toBe(30000); + + expect(service.setIntervalSeconds(0)).toBe(false); + expect(service.intervalMs()).toBe(30000); + + expect(service.setIntervalSeconds(-5)).toBe(false); + expect(service.intervalMs()).toBe(30000); + }); + + it('falls back to a timestamp comparison (not the whole page) when the last-notified order id disappears', fakeAsync(() => { + service.start(); + tick(0); + + // Second poll: 'o2' (the last-notified id) is gone. Page contains orders + // both older and newer than o2's createdAt (2026-08-15T10:00:00.000Z). + pollIndex = 1; + ordersByPoll.push([ + makeOrder('o5', '1005', '2026-08-15T12:00:00.000Z'), + makeOrder('o4', '1004', '2026-08-15T11:00:00.000Z'), + makeOrder('o3', '1003', '2026-08-15T10:30:00.000Z'), + makeOrder('o0', '1000', '2026-08-15T08:00:00.000Z'), + ]); + + tick(service.intervalMs()); + + // Only o5, o4, o3 are newer than o2's createdAt - o0 must not toast. + expect(notifications.notifications().length).toBe(3); + const messages = notifications.notifications().map(n => n.message); + expect(messages.some(m => m.includes('1005'))).toBe(true); + expect(messages.some(m => m.includes('1004'))).toBe(true); + expect(messages.some(m => m.includes('1003'))).toBe(true); + expect(messages.some(m => m.includes('1000'))).toBe(false); + })); + + it('falls back to a timestamp comparison (not the whole page) for unreadCount when the acknowledged order id disappears', fakeAsync(() => { + service.start(); + tick(0); + + // Second poll introduces 'o3' and pushes 'o2' to index 1. + pollIndex = 1; + ordersByPoll.push([ + makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'), + makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), + makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'), + ]); + tick(service.intervalMs()); + + // Acknowledge everything up to and including 'o3' (createdAt 11:00:00). + service.markAllSeen(); + expect(service.unreadCount()).toBe(0); + + // Third poll: 'o3' (the acknowledged id) is gone. Page contains orders + // both older and newer than o3's createdAt (2026-08-15T11:00:00.000Z). + pollIndex = 2; + ordersByPoll.push([ + makeOrder('o6', '1006', '2026-08-15T13:00:00.000Z'), + makeOrder('o5', '1005', '2026-08-15T12:00:00.000Z'), + makeOrder('o4', '1004', '2026-08-15T10:30:00.000Z'), + makeOrder('o0', '1000', '2026-08-15T08:00:00.000Z'), + ]); + tick(service.intervalMs()); + + // Only o6 and o5 are newer than o3's ack createdAt - the buggy behavior + // would report the full page length (4) instead of the correct count (2). + expect(service.unreadCount()).toBe(2); + })); + + it('stop() clears the interval so no further polls occur', fakeAsync(() => { + const gateway = TestBed.inject(AdminOrdersLocalGateway); + const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough(); + + service.start(); + tick(0); + const callsAfterStart = loadOrdersSpy.calls.count(); + + tick(service.intervalMs() / 2); + service.stop(); + tick(service.intervalMs() * 2); + + expect(loadOrdersSpy.calls.count()).toBe(callsAfterStart); + })); + + it('reacts to AdminAuthService.isAuthenticated: starts, stops on logout, and resumes on re-login', fakeAsync(() => { + const gateway = TestBed.inject(AdminOrdersLocalGateway); + const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough(); + + // Starts authenticated -> the effect should start polling on its own, + // with no explicit service.start() call from a component. + fakeAdminAuth.isAuthenticated.set(true); + TestBed.flushEffects(); + tick(0); + + expect(loadOrdersSpy.calls.count()).toBeGreaterThan(0); + const callsWhileAuthenticated = loadOrdersSpy.calls.count(); + + // Logout: flip the signal to false. Polling must actually stop - this is + // the gap the previous DestroyRef-based fix failed to close, since nothing + // destroys AdminLayoutComponent on logout. + fakeAdminAuth.isAuthenticated.set(false); + TestBed.flushEffects(); + tick(0); + const callsRightAfterLogout = loadOrdersSpy.calls.count(); + + tick(service.intervalMs() * 3); + expect(loadOrdersSpy.calls.count()).toBe(callsRightAfterLogout); + + // Re-login: polling should resume, not stay permanently stopped. + fakeAdminAuth.isAuthenticated.set(true); + TestBed.flushEffects(); + tick(0); + tick(service.intervalMs()); + + expect(loadOrdersSpy.calls.count()).toBeGreaterThan(callsWhileAuthenticated); + })); +}); 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..f9a8472 --- /dev/null +++ b/src/app/features/admin/shell/services/admin-order-watcher.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Signal, computed, effect, inject, signal } from '@angular/core'; +import { AdminOrder } from '../../orders/models/admin-order.model'; +import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; +import { LocalStorageService } from '../../../../core/storage/local-storage.service'; +import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service'; +import { LanguageService } from '../../../../services/language.service'; +import { TranslateService } from '../../../../i18n/translate.service'; +import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service'; + +const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1'; +const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1'; +const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1'; +const LAST_ACKNOWLEDGED_AT_KEY = 'adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1'; +const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1'; +export const DEFAULT_POLL_INTERVAL_MS = 15000; +const MIN_POLL_INTERVAL_MS = 1000; +const RECENT_ORDERS_LIMIT = 20; +const TOAST_DURATION_MS = 4000; + +@Injectable({ providedIn: 'root' }) +export class AdminOrderWatcherService { + private readonly gateway = inject(AdminOrdersLocalGateway); + private readonly storage = inject(LocalStorageService); + private readonly notifications = inject(UserNotificationService); + private readonly languageService = inject(LanguageService); + private readonly i18n = inject(TranslateService); + private readonly adminAuth = inject(AdminAuthService); + + private readonly recentOrdersSignal = signal([]); + 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(); + if (orders.length === 0) { + return 0; + } + const ackId = this.lastAcknowledgedOrderIdSignal(); + if (ackId === null) { + return orders.length; + } + const idx = orders.findIndex(order => order.id === ackId); + if (idx !== -1) { + return idx; + } + // The acknowledged order id is no longer present in the current page + // (deleted, or many orders arrived since ack) - fall back to a timestamp + // comparison instead of treating the whole page as unread. + const ackCreatedAt = this.lastAcknowledgedOrderCreatedAtSignal(); + return ackCreatedAt === null ? orders.length : orders.filter(order => order.createdAt > ackCreatedAt).length; + }); + + private readonly intervalMsSignal = signal(this.readStoredIntervalMs()); + 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; + + constructor() { + // Reacts to the auth signal itself rather than component lifecycle: nothing + // reliably destroys AdminLayoutComponent on logout (no navigation happens), + // so polling must stop/start off isAuthenticated directly to avoid leaking + // admin order toasts onto the public storefront after logout. + effect(() => { + if (this.adminAuth.isAuthenticated()) { + this.start(); + } else { + this.stop(); + } + }); + } + + start(): void { + if (this.started) { + return; + } + this.started = true; + this.poll(); + this.scheduleNext(); + } + + stop(): void { + if (this.timerId !== null) { + clearInterval(this.timerId); + this.timerId = null; + } + this.started = false; + } + + setIntervalSeconds(seconds: number): boolean { + if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) { + return false; + } + const ms = Math.round(seconds * 1000); + this.intervalMsSignal.set(ms); + this.storage.setItem(POLL_INTERVAL_KEY, String(ms)); + if (this.started) { + this.scheduleNext(); + } + return true; + } + + markAllSeen(): void { + const newest = this.recentOrdersSignal()[0]; + if (!newest) { + return; + } + this.lastAcknowledgedOrderIdSignal.set(newest.id); + this.lastAcknowledgedOrderCreatedAtSignal.set(newest.createdAt); + this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newest.id); + this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, newest.createdAt); + } + + private scheduleNext(): void { + if (this.timerId !== null) { + clearInterval(this.timerId); + } + this.timerId = setInterval(() => this.poll(), this.intervalMsSignal()); + } + + private poll(): void { + this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({ + next: result => this.handleOrders(result.items), + error: err => console.error('Error polling for new orders:', err), + }); + } + + private handleOrders(items: AdminOrder[]): void { + this.recentOrdersSignal.set(items); + + if (items.length === 0) { + return; + } + + const isFirstPoll = this.lastNotifiedOrderId === null; + const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId); + // When the last-notified order id is no longer present in the current page + // (deleted, or more than RECENT_ORDERS_LIMIT orders arrived since the last + // poll), fall back to a timestamp comparison instead of treating the whole + // page as new - otherwise a single missing id could fire a burst of up to + // RECENT_ORDERS_LIMIT toasts. + const newOrders = isFirstPoll + ? [] + : notifyIndex !== -1 + ? items.slice(0, notifyIndex) + : items.filter(order => this.lastNotifiedOrderCreatedAt !== null && order.createdAt > this.lastNotifiedOrderCreatedAt); + + this.lastNotifiedOrderId = items[0].id; + this.lastNotifiedOrderCreatedAt = items[0].createdAt; + this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId); + this.storage.setItem(LAST_NOTIFIED_AT_KEY, this.lastNotifiedOrderCreatedAt); + + if (isFirstPoll) { + // Nothing existed to compare against yet - treat current orders as already + // acknowledged so a fresh admin session doesn't see the whole history as unread. + if (this.lastAcknowledgedOrderIdSignal() === null) { + this.lastAcknowledgedOrderIdSignal.set(items[0].id); + this.lastAcknowledgedOrderCreatedAtSignal.set(items[0].createdAt); + this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id); + this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, items[0].createdAt); + } + return; + } + + for (let i = newOrders.length - 1; i >= 0; i--) { + const order = newOrders[i]; + this.notifications.show( + this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }), + 'info', + TOAST_DURATION_MS, + [this.languageService.currentLanguage(), 'backoffice', 'orders', order.id] + ); + } + } + + private readStoredIntervalMs(): number { + const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY)); + return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS; + } +} 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.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; 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)); diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index db16bc2..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', @@ -2074,6 +2078,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..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: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա', @@ -2069,6 +2073,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..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: 'Нет продаж товаров за этот период', @@ -2069,6 +2073,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..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; @@ -2082,6 +2086,7 @@ export interface Translations { searchPlaceholder: string; notifications: string; notificationsEmpty: string; + notificationNewOrder: string; account: string; tenantSelector: string; tenantSelectorComingSoon: string;