merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
# Admin Purchase Notifications Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Notify admin (toast + topbar bell badge/panel) when a new order lands on the marketplace, poll-based since the backend has no WebSocket/SSE.
|
||||
|
||||
**Architecture:** A single `AdminOrderWatcherService` polls `AdminOrdersLocalGateway.loadOrders()` on an editable interval (default 15s), diffs against a persisted "last notified" order id to fire toasts for genuinely new orders, and exposes a `recentOrders`/`unreadCount` signal pair that the existing (currently-empty) topbar bell panel renders. Poll interval is editable in the admin settings page, same pattern as the currency-rates section added previously.
|
||||
|
||||
**Tech Stack:** Angular 17+ signals, RxJS, Jasmine/Karma (`ng test`), existing `LocalStorageService`/`UserNotificationService`/`TranslateService` patterns.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No WebSocket/SSE available — polling only (confirmed `BACKEND-API-REFERENCE.md:20`).
|
||||
- Persist state via `LocalStorageService` (`getItem`/`setItem`), never raw `localStorage`.
|
||||
- Reuse the existing topbar bell (`admin-layout.component.html:141-157`) instead of adding a new nav badge.
|
||||
- Admin backoffice price/amount displays stay in the order's raw stored currency (no `currencyConvert` pipe) — consistent with every other admin screen.
|
||||
- Route arrays for admin navigation use the pattern `[languageService.currentLanguage(), 'backoffice', 'orders', id]` (no leading `/`), matching `admin-orders-list-page.component.ts:52`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `UserNotificationService` gains an optional click-to-navigate route
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/website/user-experience/services/user-notification.service.ts`
|
||||
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts`
|
||||
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`
|
||||
- Test: `src/app/features/website/user-experience/services/user-notification.service.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `UserNotificationService.show(message: string, type?: UserNotificationType, durationMs?: number, route?: string[]): void`
|
||||
- Produces: `UserNotification.route?: string[]`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/website/user-experience/services/user-notification.service.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
|
||||
describe('UserNotificationService', () => {
|
||||
let service: UserNotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(UserNotificationService);
|
||||
});
|
||||
|
||||
it('stores the route on the notification when provided', () => {
|
||||
service.show('New order #1042', 'info', 4000, ['en', 'backoffice', 'orders', 'ord_1']);
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.message).toBe('New order #1042');
|
||||
expect(note.route).toEqual(['en', 'backoffice', 'orders', 'ord_1']);
|
||||
});
|
||||
|
||||
it('leaves route undefined when not provided', () => {
|
||||
service.show('Saved');
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.route).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
|
||||
Expected: FAIL — `show` has no fourth parameter, `route` does not exist on `UserNotification`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace the full contents of `src/app/features/website/user-experience/services/user-notification.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type UserNotificationType = 'success' | 'info' | 'warning';
|
||||
|
||||
export interface UserNotification {
|
||||
id: string;
|
||||
message: string;
|
||||
type: UserNotificationType;
|
||||
/** Route to navigate to when the notification is clicked. Absent means not clickable. */
|
||||
route?: string[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserNotificationService {
|
||||
private readonly state = signal<UserNotification[]>([]);
|
||||
|
||||
readonly notifications = this.state.asReadonly();
|
||||
|
||||
show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500, route?: string[]): void {
|
||||
const next: UserNotification = {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
message,
|
||||
type,
|
||||
...(route ? { route } : {}),
|
||||
};
|
||||
|
||||
this.state.update(items => [next, ...items].slice(0, 4));
|
||||
|
||||
setTimeout(() => this.dismiss(next.id), durationMs);
|
||||
}
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.state.update(items => items.filter(item => item.id !== id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Modify `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts` — replace full contents:
|
||||
|
||||
```typescript
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { UserNotification, UserNotificationService } from '../../services/user-notification.service';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-notifications',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './floating-notifications.component.html',
|
||||
styleUrls: ['./floating-notifications.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FloatingNotificationsComponent {
|
||||
private readonly notificationsService = inject(UserNotificationService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly notifications = this.notificationsService.notifications;
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.notificationsService.dismiss(id);
|
||||
}
|
||||
|
||||
navigate(note: UserNotification): void {
|
||||
if (note.route) {
|
||||
void this.router.navigate(note.route);
|
||||
}
|
||||
this.dismiss(note.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace full contents of `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`:
|
||||
|
||||
```html
|
||||
@if (notifications().length > 0) {
|
||||
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
|
||||
@for (note of notifications(); track note.id) {
|
||||
<article
|
||||
class="floating-note"
|
||||
[class]="'floating-note floating-note-' + note.type"
|
||||
[class.floating-note-clickable]="!!note.route"
|
||||
(click)="note.route && navigate(note)"
|
||||
>
|
||||
<p>{{ note.message }}</p>
|
||||
<button type="button" (click)="$event.stopPropagation(); dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
|
||||
</article>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
|
||||
Expected: PASS (2 specs)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/website/user-experience/services/user-notification.service.ts src/app/features/website/user-experience/services/user-notification.service.spec.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
|
||||
git commit -m "feat: UserNotificationService supports click-to-navigate toasts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `AdminOrderWatcherService` — polling, diffing, toast firing
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/features/admin/shell/services/admin-order-watcher.service.ts`
|
||||
- Test: `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`
|
||||
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts` (one new key)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrdersLocalGateway.loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult>` (existing)
|
||||
- Consumes: `UserNotificationService.show(message, type?, durationMs?, route?)` (Task 1)
|
||||
- Consumes: `LocalStorageService.getItem(key): string | null`, `.setItem(key, value): void` (existing)
|
||||
- Produces: `AdminOrderWatcherService.recentOrders: Signal<AdminOrder[]>`
|
||||
- Produces: `AdminOrderWatcherService.unreadCount: Signal<number>`
|
||||
- Produces: `AdminOrderWatcherService.intervalMs: Signal<number>`
|
||||
- Produces: `AdminOrderWatcherService.start(): void`
|
||||
- Produces: `AdminOrderWatcherService.markAllSeen(): void`
|
||||
- Produces: `AdminOrderWatcherService.setIntervalSeconds(seconds: number): void`
|
||||
|
||||
- [ ] **Step 1: Add the i18n key first (needed by the test's translated toast message)**
|
||||
|
||||
In `src/app/i18n/translations.ts`, inside the `topbar:` block under `adminShell` (next to `notificationsEmpty: string;`):
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: string;
|
||||
notificationNewOrder: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, inside `adminShell.topbar` (next to `notificationsEmpty:`):
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'No new notifications',
|
||||
notificationNewOrder: 'New order #{{orderNumber}}',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, inside `adminShell.topbar`:
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'Нет новых уведомлений',
|
||||
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, inside `adminShell.topbar`:
|
||||
|
||||
```typescript
|
||||
notificationsEmpty: 'Նոր ծանուցումներ չկան',
|
||||
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
|
||||
```
|
||||
|
||||
(Match each file's existing `notificationsEmpty` value/indentation exactly — only add the new line after it.)
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { AdminOrderWatcherService } from './admin-order-watcher.service';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' },
|
||||
payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 1000,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminOrderWatcherService', () => {
|
||||
let ordersByPoll: AdminOrder[][];
|
||||
let pollIndex: number;
|
||||
let notifications: UserNotificationService;
|
||||
let service: AdminOrderWatcherService;
|
||||
|
||||
function fakeGateway() {
|
||||
return {
|
||||
loadOrders: () => {
|
||||
const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1];
|
||||
const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 };
|
||||
return of(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pollIndex = 0;
|
||||
ordersByPoll = [
|
||||
[makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
|
||||
];
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
|
||||
],
|
||||
});
|
||||
|
||||
notifications = TestBed.inject(UserNotificationService);
|
||||
service = TestBed.inject(AdminOrderWatcherService);
|
||||
});
|
||||
|
||||
it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
expect(notifications.notifications().length).toBe(0);
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map(o => o.id)).toEqual(['o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(notifications.notifications().length).toBe(1);
|
||||
expect(notifications.notifications()[0].message).toContain('1003');
|
||||
expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']);
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
}));
|
||||
|
||||
it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
|
||||
service.markAllSeen();
|
||||
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map(o => o.id)).toEqual(['o3', 'o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
|
||||
service.setIntervalSeconds(30);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
service.setIntervalSeconds(0);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
service.setIntervalSeconds(-5);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: `LanguageService` defaults to `'ru'` (see `language.service.ts:23`), which is why the expected route in the second test starts with `'ru'`.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
|
||||
Expected: FAIL — `admin-order-watcher.service.ts` does not exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
Create `src/app/features/admin/shell/services/admin-order-watcher.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
|
||||
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
|
||||
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
|
||||
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 15000;
|
||||
const MIN_POLL_INTERVAL_MS = 1000;
|
||||
const RECENT_ORDERS_LIMIT = 20;
|
||||
const TOAST_DURATION_MS = 4000;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminOrderWatcherService {
|
||||
private readonly gateway = inject(AdminOrdersLocalGateway);
|
||||
private readonly storage = inject(LocalStorageService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly i18n = inject(TranslateService);
|
||||
|
||||
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
|
||||
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
|
||||
|
||||
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
|
||||
|
||||
readonly unreadCount = computed(() => {
|
||||
const orders = this.recentOrdersSignal();
|
||||
if (orders.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const ackId = this.lastAcknowledgedOrderIdSignal();
|
||||
if (ackId === null) {
|
||||
return orders.length;
|
||||
}
|
||||
const idx = orders.findIndex(order => order.id === ackId);
|
||||
return idx === -1 ? orders.length : idx;
|
||||
});
|
||||
|
||||
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
|
||||
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
|
||||
|
||||
private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
|
||||
private timerId: ReturnType<typeof setInterval> | null = null;
|
||||
private started = false;
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.poll();
|
||||
this.scheduleNext();
|
||||
}
|
||||
|
||||
setIntervalSeconds(seconds: number): void {
|
||||
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
|
||||
return;
|
||||
}
|
||||
const ms = Math.round(seconds * 1000);
|
||||
this.intervalMsSignal.set(ms);
|
||||
this.storage.setItem(POLL_INTERVAL_KEY, String(ms));
|
||||
if (this.started) {
|
||||
this.scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
markAllSeen(): void {
|
||||
const newestId = this.recentOrdersSignal()[0]?.id ?? null;
|
||||
this.lastAcknowledgedOrderIdSignal.set(newestId);
|
||||
if (newestId) {
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(): void {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
this.timerId = setInterval(() => this.poll(), this.intervalMsSignal());
|
||||
}
|
||||
|
||||
private poll(): void {
|
||||
this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({
|
||||
next: result => this.handleOrders(result.items),
|
||||
error: err => console.error('Error polling for new orders:', err),
|
||||
});
|
||||
}
|
||||
|
||||
private handleOrders(items: AdminOrder[]): void {
|
||||
this.recentOrdersSignal.set(items);
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstPoll = this.lastNotifiedOrderId === null;
|
||||
const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
|
||||
const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex));
|
||||
|
||||
this.lastNotifiedOrderId = items[0].id;
|
||||
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
|
||||
|
||||
if (isFirstPoll) {
|
||||
// Nothing existed to compare against yet - treat current orders as already
|
||||
// acknowledged so a fresh admin session doesn't see the whole history as unread.
|
||||
if (this.lastAcknowledgedOrderIdSignal() === null) {
|
||||
this.lastAcknowledgedOrderIdSignal.set(items[0].id);
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = newOrders.length - 1; i >= 0; i--) {
|
||||
const order = newOrders[i];
|
||||
this.notifications.show(
|
||||
this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }),
|
||||
'info',
|
||||
TOAST_DURATION_MS,
|
||||
[this.languageService.currentLanguage(), 'backoffice', 'orders', order.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readStoredIntervalMs(): number {
|
||||
const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY));
|
||||
return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
|
||||
Expected: PASS (4 specs)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/shell/services/admin-order-watcher.service.ts src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||||
git commit -m "feat: AdminOrderWatcherService polls for new orders and toasts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire the watcher into the admin topbar bell
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.ts`
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.html`
|
||||
- Modify: `src/app/features/admin/shell/admin-layout.component.scss`
|
||||
- Test: `src/app/features/admin/shell/admin-layout.component.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrderWatcherService.{recentOrders, unreadCount, start, markAllSeen}` (Task 2)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/shell/admin-layout.component.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminLayoutComponent } from './admin-layout.component';
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
import { AdminOrder } from '../orders/models/admin-order.model';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '' },
|
||||
payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 500,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt: '2026-08-15T10:00:00.000Z',
|
||||
updatedAt: '2026-08-15T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminLayoutComponent notifications bell', () => {
|
||||
let watcherStub: {
|
||||
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
|
||||
unreadCount: ReturnType<typeof signal<number>>;
|
||||
start: jasmine.Spy;
|
||||
markAllSeen: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
||||
unreadCount: signal(1),
|
||||
start: jasmine.createSpy('start'),
|
||||
markAllSeen: jasmine.createSpy('markAllSeen'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminLayoutComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the watcher once on construction', () => {
|
||||
TestBed.createComponent(AdminLayoutComponent);
|
||||
expect(watcherStub.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exposes unreadCount and recentOrders from the watcher', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.unreadCount()).toBe(1);
|
||||
expect(component.recentOrders().map(o => o.id)).toEqual(['o1']);
|
||||
});
|
||||
|
||||
it('marks orders seen when the notifications panel opens', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(true);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(false);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
|
||||
Expected: FAIL — `AdminOrderWatcherService` not referenced by the component yet, `unreadCount`/`recentOrders` don't exist on `AdminLayoutComponent`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.ts`, add the import and field (place near the other service injections):
|
||||
|
||||
```typescript
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
```
|
||||
|
||||
```typescript
|
||||
private readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||
|
||||
readonly unreadCount = this.orderWatcher.unreadCount;
|
||||
readonly recentOrders = this.orderWatcher.recentOrders;
|
||||
```
|
||||
|
||||
In the constructor, after `this.readRouteData();`, add:
|
||||
|
||||
```typescript
|
||||
this.orderWatcher.start();
|
||||
```
|
||||
|
||||
Replace the `toggleNotifications` method:
|
||||
|
||||
```typescript
|
||||
toggleNotifications(): void {
|
||||
this.notificationsOpen.update(open => !open);
|
||||
if (this.notificationsOpen()) {
|
||||
this.orderWatcher.markAllSeen();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a navigation helper next to `adminLinkFor`:
|
||||
|
||||
```typescript
|
||||
goToOrder(orderId: string): void {
|
||||
void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]);
|
||||
this.notificationsOpen.set(false);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.html`, replace the notifications block (lines 141-157):
|
||||
|
||||
```html
|
||||
<div class="admin-layout__notifications">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-layout__icon-button"
|
||||
aria-haspopup="true"
|
||||
[attr.aria-expanded]="notificationsOpen()"
|
||||
[attr.aria-label]="'adminShell.topbar.notifications' | translate"
|
||||
(click)="toggleNotifications()"
|
||||
>
|
||||
<app-icon name="bell" [size]="18" />
|
||||
@if (unreadCount() > 0) {
|
||||
<span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (notificationsOpen()) {
|
||||
<div class="admin-layout__notifications-panel" role="menu">
|
||||
@if (recentOrders().length === 0) {
|
||||
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
||||
} @else {
|
||||
@for (order of recentOrders(); track order.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="admin-layout__notification-item"
|
||||
role="menuitem"
|
||||
(click)="goToOrder(order.id)"
|
||||
>
|
||||
<span class="admin-layout__notification-order">#{{ order.orderNumber }}</span>
|
||||
<span class="admin-layout__notification-customer">{{ order.customer.name }}</span>
|
||||
<span class="admin-layout__notification-amount">{{ order.total }} {{ order.currency }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
In `src/app/features/admin/shell/admin-layout.component.scss`, add (near other `.admin-layout__notifications*` rules if any exist, otherwise at the end):
|
||||
|
||||
```scss
|
||||
.admin-layout__notifications {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-layout__notifications-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-layout__notification-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.admin-layout__notification-item:hover {
|
||||
background: var(--bg-secondary, #f4f6f5);
|
||||
}
|
||||
|
||||
.admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); }
|
||||
.admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
|
||||
.admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
|
||||
Expected: PASS (3 specs)
|
||||
|
||||
- [ ] **Step 5: Run full build to catch template errors**
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds, no template compile errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/shell/admin-layout.component.ts src/app/features/admin/shell/admin-layout.component.html src/app/features/admin/shell/admin-layout.component.scss src/app/features/admin/shell/admin-layout.component.spec.ts
|
||||
git commit -m "feat: wire order watcher into admin topbar bell (badge + panel)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Editable poll interval in admin settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.ts`
|
||||
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.html`
|
||||
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts`
|
||||
- Test: `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AdminOrderWatcherService.{intervalMs, setIntervalSeconds}` (Task 2)
|
||||
|
||||
- [ ] **Step 1: Add i18n keys**
|
||||
|
||||
In `src/app/i18n/translations.ts`, inside `adminSettings:` (after `currencyRatesSaved: string;`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: string;
|
||||
notificationIntervalExplain: string;
|
||||
notificationIntervalSave: string;
|
||||
notificationIntervalSaved: string;
|
||||
```
|
||||
|
||||
In `src/app/i18n/en.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Rates saved',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'New-order check interval (seconds)',
|
||||
notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
|
||||
notificationIntervalSave: 'Save interval',
|
||||
notificationIntervalSaved: 'Interval saved',
|
||||
```
|
||||
|
||||
In `src/app/i18n/ru.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Курсы сохранены',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'Интервал проверки новых заказов (сек)',
|
||||
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
|
||||
notificationIntervalSave: 'Сохранить интервал',
|
||||
notificationIntervalSaved: 'Интервал сохранён',
|
||||
```
|
||||
|
||||
In `src/app/i18n/hy.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',`):
|
||||
|
||||
```typescript
|
||||
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
|
||||
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
|
||||
notificationIntervalSave: 'Պահպանել ինտերվալը',
|
||||
notificationIntervalSaved: 'Ինտերվալը պահպանվեց',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminSettingsPageComponent } from './admin-settings-page.component';
|
||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||
|
||||
describe('AdminSettingsPageComponent notification interval', () => {
|
||||
let watcherStub: {
|
||||
intervalMs: ReturnType<typeof signal<number>>;
|
||||
setIntervalSeconds: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
intervalMs: signal(15000),
|
||||
setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminSettingsPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes the draft from the current interval in seconds', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15);
|
||||
});
|
||||
|
||||
it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.notificationIntervalSecondsDraft.set(30);
|
||||
component.saveNotificationInterval();
|
||||
|
||||
expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
|
||||
Expected: FAIL — `notificationIntervalSecondsDraft`/`saveNotificationInterval` don't exist yet.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
In `src/app/features/admin/settings/pages/admin-settings-page.component.ts`, add the import:
|
||||
|
||||
```typescript
|
||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||
```
|
||||
|
||||
Add the field and methods to the class (alongside the currency-rates fields):
|
||||
|
||||
```typescript
|
||||
readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||
readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
|
||||
readonly showNotificationIntervalSaved = signal(false);
|
||||
|
||||
saveNotificationInterval(): void {
|
||||
this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
|
||||
this.showNotificationIntervalSaved.set(true);
|
||||
setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
|
||||
}
|
||||
```
|
||||
|
||||
In `src/app/features/admin/settings/pages/admin-settings-page.component.html`, add a new `.settings-card` block after the currency-rates one (before the closing `</section>`):
|
||||
|
||||
```html
|
||||
<div class="settings-card">
|
||||
<h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
|
||||
<p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
|
||||
<div class="rate-row">
|
||||
<input
|
||||
class="rate-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
[ngModel]="notificationIntervalSecondsDraft()"
|
||||
(ngModelChange)="notificationIntervalSecondsDraft.set($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rate-actions">
|
||||
<button type="button" class="save-button" (click)="saveNotificationInterval()">{{ 'adminSettings.notificationIntervalSave' | translate }}</button>
|
||||
<span class="saved-message" *ngIf="showNotificationIntervalSaved()">{{ 'adminSettings.notificationIntervalSaved' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
|
||||
Expected: PASS (2 specs)
|
||||
|
||||
- [ ] **Step 6: Full verification**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.json`
|
||||
Expected: no errors.
|
||||
|
||||
Run: `npx ng build --configuration development`
|
||||
Expected: build succeeds.
|
||||
|
||||
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts' --include='**/admin-layout.component.spec.ts' --include='**/admin-settings-page.component.spec.ts' --include='**/user-notification.service.spec.ts'`
|
||||
Expected: all specs PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/features/admin/settings/pages/admin-settings-page.component.ts src/app/features/admin/settings/pages/admin-settings-page.component.html src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
|
||||
git commit -m "feat: editable new-order poll interval in admin settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Verification (after all tasks)
|
||||
|
||||
1. `npm run barry -- kb search --source cq --query "admin order notifications"` if KB sharing is enabled (skip if local-only — see project `CLAUDE.md`).
|
||||
2. Start the dev server, log into `/backoffice`, leave the tab open.
|
||||
3. In another tab (or via `admin-orders-local.gateway.ts`'s seed data timing), wait for the poll interval — confirm no toast fires on first load.
|
||||
4. Trigger a new order (checkout flow in `cart.component.ts`, or temporarily lower `SEED_COUNT`/seed timing in `admin-orders-local.gateway.ts` to simulate one — revert after testing) and confirm: toast appears with the order number, bell badge shows `1`, clicking either navigates to `/backoffice/orders/:id`.
|
||||
5. Open the bell panel without clicking a row — confirm badge clears but the order still lists in the panel.
|
||||
6. Change the interval in Admin Settings, save, confirm the toast "Interval saved" message. no crash on next poll cycle.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Admin purchase notifications — design
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-15
|
||||
**Related backlog item:** #7 (marked ВАЖНО — important)
|
||||
|
||||
## Problem
|
||||
|
||||
Admin has no signal when a purchase happens on the marketplace. Orders only surface if
|
||||
someone manually opens the Orders list and refreshes. Backend exposes no WebSocket/SSE
|
||||
(confirmed in `BACKEND-API-REFERENCE.md:20` — every "live" feature today, e.g. payment
|
||||
status, is plain polling), so this has to be poll-based like the rest of the app.
|
||||
|
||||
## Architecture
|
||||
|
||||
**`AdminOrderWatcherService`** (new, `providedIn: root`, admin-scoped)
|
||||
|
||||
- Polls `AdminOrdersLocalGateway.loadOrders()` (sorted `createdAt` desc, already the
|
||||
default sort) on an interval.
|
||||
- Diffs the newest order's `id`/`createdAt` against the last-seen value, kept in memory
|
||||
and persisted via `LocalStorageService` (survives page reload, same pattern as
|
||||
`AdminPreferencesService`).
|
||||
- On finding order(s) newer than last-seen: fires one toast per new order and
|
||||
increments an `unreadCount` signal.
|
||||
- Started once at the admin shell root, so it keeps polling regardless of which admin
|
||||
page is open.
|
||||
|
||||
**Poll interval**
|
||||
|
||||
- Editable by admin, default 15s.
|
||||
- Setting lives in the same admin-settings page as currency rates
|
||||
(`admin-settings-page.component.ts`), persisted via `LocalStorageService`.
|
||||
|
||||
**Toast delivery**
|
||||
|
||||
- Reuses the existing `UserNotificationService` / `FloatingNotificationsComponent`
|
||||
(already global — `providedIn: root`, mounted once in `app.html`). No new toast UI.
|
||||
- `UserNotification` gains an optional `route: string[]` field.
|
||||
- `FloatingNotificationsComponent` gets a click handler: navigate to `route` (if set)
|
||||
then dismiss.
|
||||
|
||||
**Badge — reuses existing topbar bell**
|
||||
|
||||
`admin-layout.component.html:141-157` already has an unused bell icon +
|
||||
dropdown panel (currently hardcoded to always show "no notifications").
|
||||
Wire the watcher's data into it instead of adding a new indicator:
|
||||
|
||||
- `unreadCount` signal (from `AdminOrderWatcherService`) rendered as a badge
|
||||
on the bell icon (`admin-layout__icon-button`).
|
||||
- Opening the panel (`notificationsOpen()`, already wired to the bell click)
|
||||
lists the unread new orders instead of the static "notificationsEmpty"
|
||||
text.
|
||||
- Opening the panel marks all currently-known orders as seen → badge resets
|
||||
to 0 (same trigger `AdminLayoutComponent.toggleNotifications()` already
|
||||
has).
|
||||
|
||||
**Click behavior**
|
||||
|
||||
- Toast click → `/admin/orders/:id` (the new order's detail page).
|
||||
- Clicking an order row inside the bell panel → same, then closes the panel.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
AdminOrderWatcherService (interval timer)
|
||||
-> AdminOrdersLocalGateway.loadOrders()
|
||||
-> diff against last-seen order id/createdAt (LocalStorageService)
|
||||
-> new order(s) found?
|
||||
-> UserNotificationService.show(message, 'info', { route: ['/admin/orders', id] })
|
||||
-> unreadCount.update(n => n + 1)
|
||||
-> admin clicks toast/badge -> router navigate -> orders-list visit resets unreadCount
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
Poll failures are silent/logged only (`console.error`), consistent with existing
|
||||
polling code (payment status polling in `cart.component.ts`). No toast spam on
|
||||
transient network errors — watcher just retries on the next interval.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Native OS push notifications (tab not focused) — user explicitly chose in-app
|
||||
toast/badge only, not browser Notification API.
|
||||
- Sound alerts — not selected.
|
||||
- Telegram/email alerts to staff — not selected, would need backend bot/mail
|
||||
integration.
|
||||
@@ -32,4 +32,23 @@
|
||||
<span class="saved-message" *ngIf="showSavedMessage()">{{ 'adminSettings.currencyRatesSaved' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
|
||||
<p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
|
||||
<div class="rate-row">
|
||||
<input
|
||||
class="rate-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
[ngModel]="notificationIntervalSecondsDraft()"
|
||||
(ngModelChange)="notificationIntervalSecondsDraft.set($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rate-actions">
|
||||
<button type="button" class="save-button" (click)="saveNotificationInterval()">{{ 'adminSettings.notificationIntervalSave' | translate }}</button>
|
||||
<span class="saved-message" *ngIf="showNotificationIntervalSaved()">{{ 'adminSettings.notificationIntervalSaved' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminSettingsPageComponent } from './admin-settings-page.component';
|
||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||
|
||||
describe('AdminSettingsPageComponent notification interval', () => {
|
||||
let watcherStub: {
|
||||
intervalMs: ReturnType<typeof signal<number>>;
|
||||
setIntervalSeconds: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
intervalMs: signal(15000),
|
||||
setIntervalSeconds: jasmine.createSpy('setIntervalSeconds').and.returnValue(true),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminSettingsPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes the draft from the current interval in seconds', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15);
|
||||
});
|
||||
|
||||
it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => {
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.notificationIntervalSecondsDraft.set(30);
|
||||
component.saveNotificationInterval();
|
||||
|
||||
expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30);
|
||||
});
|
||||
|
||||
it('shows the saved message and keeps the draft when the interval is applied', () => {
|
||||
watcherStub.setIntervalSeconds.and.callFake((seconds: number) => {
|
||||
watcherStub.intervalMs.set(seconds * 1000);
|
||||
return true;
|
||||
});
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.notificationIntervalSecondsDraft.set(30);
|
||||
component.saveNotificationInterval();
|
||||
|
||||
expect(component.showNotificationIntervalSaved()).toBe(true);
|
||||
expect(component.notificationIntervalSecondsDraft()).toBe(30);
|
||||
});
|
||||
|
||||
it('does not show the saved message and reverts the draft when the interval is rejected', () => {
|
||||
watcherStub.setIntervalSeconds.and.returnValue(false);
|
||||
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.notificationIntervalSecondsDraft.set(0);
|
||||
component.saveNotificationInterval();
|
||||
|
||||
expect(component.showNotificationIntervalSaved()).toBe(false);
|
||||
expect(component.notificationIntervalSecondsDraft()).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
|
||||
import { 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<Record<string, number>>({ ...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');
|
||||
}
|
||||
|
||||
@@ -148,10 +148,28 @@
|
||||
(click)="toggleNotifications()"
|
||||
>
|
||||
<app-icon name="bell" [size]="18" />
|
||||
@if (unreadCount() > 0) {
|
||||
<span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (notificationsOpen()) {
|
||||
<div class="admin-layout__notifications-panel" role="menu">
|
||||
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
||||
@if (recentOrders().length === 0) {
|
||||
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
||||
} @else {
|
||||
@for (order of recentOrders(); track order.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="admin-layout__notification-item"
|
||||
role="menuitem"
|
||||
(click)="goToOrder(order.id)"
|
||||
>
|
||||
<span class="admin-layout__notification-order">#{{ order.orderNumber }}</span>
|
||||
<span class="admin-layout__notification-customer">{{ order.customer.name }}</span>
|
||||
<span class="admin-layout__notification-amount">{{ order.total }} {{ order.currency }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
85
src/app/features/admin/shell/admin-layout.component.spec.ts
Normal file
85
src/app/features/admin/shell/admin-layout.component.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AdminLayoutComponent } from './admin-layout.component';
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
import { AdminOrder } from '../orders/models/admin-order.model';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '' },
|
||||
payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 500,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt: '2026-08-15T10:00:00.000Z',
|
||||
updatedAt: '2026-08-15T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminLayoutComponent notifications bell', () => {
|
||||
let watcherStub: {
|
||||
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
|
||||
unreadCount: ReturnType<typeof signal<number>>;
|
||||
start: jasmine.Spy;
|
||||
stop: jasmine.Spy;
|
||||
markAllSeen: jasmine.Spy;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
watcherStub = {
|
||||
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
||||
unreadCount: signal(1),
|
||||
start: jasmine.createSpy('start'),
|
||||
stop: jasmine.createSpy('stop'),
|
||||
markAllSeen: jasmine.createSpy('markAllSeen'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AdminLayoutComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the watcher once on construction', () => {
|
||||
TestBed.createComponent(AdminLayoutComponent);
|
||||
expect(watcherStub.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exposes unreadCount and recentOrders from the watcher', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.unreadCount()).toBe(1);
|
||||
expect(component.recentOrders().map(o => o.id)).toEqual(['o1']);
|
||||
});
|
||||
|
||||
it('marks orders seen when the notifications panel opens', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(true);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
|
||||
component.toggleNotifications();
|
||||
expect(component.notificationsOpen()).toBe(false);
|
||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the watcher when the component is destroyed', () => {
|
||||
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.destroy();
|
||||
expect(watcherStub.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
||||
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
||||
import { 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<AdminNavEntry, { type: 'link' }>): 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 ?? [])];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { AdminOrderWatcherService } from './admin-order-watcher.service';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
|
||||
return {
|
||||
id,
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' },
|
||||
payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' },
|
||||
shipping: { address: '', method: '', trackingNumber: '' },
|
||||
items: [],
|
||||
total: 1000,
|
||||
currency: 'RUB',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
timeline: [],
|
||||
archived: false,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminOrderWatcherService', () => {
|
||||
let ordersByPoll: AdminOrder[][];
|
||||
let pollIndex: number;
|
||||
let notifications: UserNotificationService;
|
||||
let service: AdminOrderWatcherService;
|
||||
let fakeAdminAuth: { isAuthenticated: ReturnType<typeof signal<boolean>> };
|
||||
|
||||
function fakeGateway() {
|
||||
return {
|
||||
loadOrders: () => {
|
||||
const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1];
|
||||
const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 };
|
||||
return of(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pollIndex = 0;
|
||||
ordersByPoll = [
|
||||
[makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
|
||||
];
|
||||
|
||||
localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderId.v1');
|
||||
localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderCreatedAt.v1');
|
||||
localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.v1');
|
||||
localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1');
|
||||
localStorage.removeItem('adminOrderWatcher.pollIntervalMs.v1');
|
||||
|
||||
// Defaults to unauthenticated so the reactive effect stays a no-op and the
|
||||
// pre-existing tests below (which drive polling via explicit start()/stop()
|
||||
// calls) keep their original behavior unchanged.
|
||||
fakeAdminAuth = { isAuthenticated: signal(false) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
|
||||
{ provide: AdminAuthService, useValue: fakeAdminAuth as unknown as AdminAuthService },
|
||||
],
|
||||
});
|
||||
|
||||
notifications = TestBed.inject(UserNotificationService);
|
||||
service = TestBed.inject(AdminOrderWatcherService);
|
||||
});
|
||||
|
||||
it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
expect(notifications.notifications().length).toBe(0);
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map((o: AdminOrder) => o.id)).toEqual(['o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(notifications.notifications().length).toBe(1);
|
||||
expect(notifications.notifications()[0].message).toContain('1003');
|
||||
expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']);
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
}));
|
||||
|
||||
it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(service.unreadCount()).toBe(1);
|
||||
|
||||
service.markAllSeen();
|
||||
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
expect(service.recentOrders().map((o: AdminOrder) => o.id)).toEqual(['o3', 'o2', 'o1']);
|
||||
}));
|
||||
|
||||
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
|
||||
expect(service.setIntervalSeconds(30)).toBe(true);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
expect(service.setIntervalSeconds(0)).toBe(false);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
|
||||
expect(service.setIntervalSeconds(-5)).toBe(false);
|
||||
expect(service.intervalMs()).toBe(30000);
|
||||
});
|
||||
|
||||
it('falls back to a timestamp comparison (not the whole page) when the last-notified order id disappears', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
// Second poll: 'o2' (the last-notified id) is gone. Page contains orders
|
||||
// both older and newer than o2's createdAt (2026-08-15T10:00:00.000Z).
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o5', '1005', '2026-08-15T12:00:00.000Z'),
|
||||
makeOrder('o4', '1004', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o3', '1003', '2026-08-15T10:30:00.000Z'),
|
||||
makeOrder('o0', '1000', '2026-08-15T08:00:00.000Z'),
|
||||
]);
|
||||
|
||||
tick(service.intervalMs());
|
||||
|
||||
// Only o5, o4, o3 are newer than o2's createdAt - o0 must not toast.
|
||||
expect(notifications.notifications().length).toBe(3);
|
||||
const messages = notifications.notifications().map(n => n.message);
|
||||
expect(messages.some(m => m.includes('1005'))).toBe(true);
|
||||
expect(messages.some(m => m.includes('1004'))).toBe(true);
|
||||
expect(messages.some(m => m.includes('1003'))).toBe(true);
|
||||
expect(messages.some(m => m.includes('1000'))).toBe(false);
|
||||
}));
|
||||
|
||||
it('falls back to a timestamp comparison (not the whole page) for unreadCount when the acknowledged order id disappears', fakeAsync(() => {
|
||||
service.start();
|
||||
tick(0);
|
||||
|
||||
// Second poll introduces 'o3' and pushes 'o2' to index 1.
|
||||
pollIndex = 1;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
|
||||
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
|
||||
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
|
||||
]);
|
||||
tick(service.intervalMs());
|
||||
|
||||
// Acknowledge everything up to and including 'o3' (createdAt 11:00:00).
|
||||
service.markAllSeen();
|
||||
expect(service.unreadCount()).toBe(0);
|
||||
|
||||
// Third poll: 'o3' (the acknowledged id) is gone. Page contains orders
|
||||
// both older and newer than o3's createdAt (2026-08-15T11:00:00.000Z).
|
||||
pollIndex = 2;
|
||||
ordersByPoll.push([
|
||||
makeOrder('o6', '1006', '2026-08-15T13:00:00.000Z'),
|
||||
makeOrder('o5', '1005', '2026-08-15T12:00:00.000Z'),
|
||||
makeOrder('o4', '1004', '2026-08-15T10:30:00.000Z'),
|
||||
makeOrder('o0', '1000', '2026-08-15T08:00:00.000Z'),
|
||||
]);
|
||||
tick(service.intervalMs());
|
||||
|
||||
// Only o6 and o5 are newer than o3's ack createdAt - the buggy behavior
|
||||
// would report the full page length (4) instead of the correct count (2).
|
||||
expect(service.unreadCount()).toBe(2);
|
||||
}));
|
||||
|
||||
it('stop() clears the interval so no further polls occur', fakeAsync(() => {
|
||||
const gateway = TestBed.inject(AdminOrdersLocalGateway);
|
||||
const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough();
|
||||
|
||||
service.start();
|
||||
tick(0);
|
||||
const callsAfterStart = loadOrdersSpy.calls.count();
|
||||
|
||||
tick(service.intervalMs() / 2);
|
||||
service.stop();
|
||||
tick(service.intervalMs() * 2);
|
||||
|
||||
expect(loadOrdersSpy.calls.count()).toBe(callsAfterStart);
|
||||
}));
|
||||
|
||||
it('reacts to AdminAuthService.isAuthenticated: starts, stops on logout, and resumes on re-login', fakeAsync(() => {
|
||||
const gateway = TestBed.inject(AdminOrdersLocalGateway);
|
||||
const loadOrdersSpy = spyOn(gateway, 'loadOrders').and.callThrough();
|
||||
|
||||
// Starts authenticated -> the effect should start polling on its own,
|
||||
// with no explicit service.start() call from a component.
|
||||
fakeAdminAuth.isAuthenticated.set(true);
|
||||
TestBed.flushEffects();
|
||||
tick(0);
|
||||
|
||||
expect(loadOrdersSpy.calls.count()).toBeGreaterThan(0);
|
||||
const callsWhileAuthenticated = loadOrdersSpy.calls.count();
|
||||
|
||||
// Logout: flip the signal to false. Polling must actually stop - this is
|
||||
// the gap the previous DestroyRef-based fix failed to close, since nothing
|
||||
// destroys AdminLayoutComponent on logout.
|
||||
fakeAdminAuth.isAuthenticated.set(false);
|
||||
TestBed.flushEffects();
|
||||
tick(0);
|
||||
const callsRightAfterLogout = loadOrdersSpy.calls.count();
|
||||
|
||||
tick(service.intervalMs() * 3);
|
||||
expect(loadOrdersSpy.calls.count()).toBe(callsRightAfterLogout);
|
||||
|
||||
// Re-login: polling should resume, not stay permanently stopped.
|
||||
fakeAdminAuth.isAuthenticated.set(true);
|
||||
TestBed.flushEffects();
|
||||
tick(0);
|
||||
tick(service.intervalMs());
|
||||
|
||||
expect(loadOrdersSpy.calls.count()).toBeGreaterThan(callsWhileAuthenticated);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Injectable, Signal, computed, effect, inject, signal } from '@angular/core';
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
|
||||
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
|
||||
const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1';
|
||||
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
|
||||
const LAST_ACKNOWLEDGED_AT_KEY = 'adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1';
|
||||
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 15000;
|
||||
const MIN_POLL_INTERVAL_MS = 1000;
|
||||
const RECENT_ORDERS_LIMIT = 20;
|
||||
const TOAST_DURATION_MS = 4000;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminOrderWatcherService {
|
||||
private readonly gateway = inject(AdminOrdersLocalGateway);
|
||||
private readonly storage = inject(LocalStorageService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly i18n = inject(TranslateService);
|
||||
private readonly adminAuth = inject(AdminAuthService);
|
||||
|
||||
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
|
||||
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
|
||||
|
||||
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
|
||||
private readonly lastAcknowledgedOrderCreatedAtSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_AT_KEY));
|
||||
|
||||
readonly unreadCount = computed(() => {
|
||||
const orders = this.recentOrdersSignal();
|
||||
if (orders.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const ackId = this.lastAcknowledgedOrderIdSignal();
|
||||
if (ackId === null) {
|
||||
return orders.length;
|
||||
}
|
||||
const idx = orders.findIndex(order => order.id === ackId);
|
||||
if (idx !== -1) {
|
||||
return idx;
|
||||
}
|
||||
// The acknowledged order id is no longer present in the current page
|
||||
// (deleted, or many orders arrived since ack) - fall back to a timestamp
|
||||
// comparison instead of treating the whole page as unread.
|
||||
const ackCreatedAt = this.lastAcknowledgedOrderCreatedAtSignal();
|
||||
return ackCreatedAt === null ? orders.length : orders.filter(order => order.createdAt > ackCreatedAt).length;
|
||||
});
|
||||
|
||||
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
|
||||
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
|
||||
|
||||
private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
|
||||
private lastNotifiedOrderCreatedAt: string | null = this.storage.getItem(LAST_NOTIFIED_AT_KEY);
|
||||
private timerId: ReturnType<typeof setInterval> | null = null;
|
||||
private started = false;
|
||||
|
||||
constructor() {
|
||||
// Reacts to the auth signal itself rather than component lifecycle: nothing
|
||||
// reliably destroys AdminLayoutComponent on logout (no navigation happens),
|
||||
// so polling must stop/start off isAuthenticated directly to avoid leaking
|
||||
// admin order toasts onto the public storefront after logout.
|
||||
effect(() => {
|
||||
if (this.adminAuth.isAuthenticated()) {
|
||||
this.start();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.poll();
|
||||
this.scheduleNext();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
this.timerId = null;
|
||||
}
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
setIntervalSeconds(seconds: number): boolean {
|
||||
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
|
||||
return false;
|
||||
}
|
||||
const ms = Math.round(seconds * 1000);
|
||||
this.intervalMsSignal.set(ms);
|
||||
this.storage.setItem(POLL_INTERVAL_KEY, String(ms));
|
||||
if (this.started) {
|
||||
this.scheduleNext();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
markAllSeen(): void {
|
||||
const newest = this.recentOrdersSignal()[0];
|
||||
if (!newest) {
|
||||
return;
|
||||
}
|
||||
this.lastAcknowledgedOrderIdSignal.set(newest.id);
|
||||
this.lastAcknowledgedOrderCreatedAtSignal.set(newest.createdAt);
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newest.id);
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, newest.createdAt);
|
||||
}
|
||||
|
||||
private scheduleNext(): void {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
this.timerId = setInterval(() => this.poll(), this.intervalMsSignal());
|
||||
}
|
||||
|
||||
private poll(): void {
|
||||
this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({
|
||||
next: result => this.handleOrders(result.items),
|
||||
error: err => console.error('Error polling for new orders:', err),
|
||||
});
|
||||
}
|
||||
|
||||
private handleOrders(items: AdminOrder[]): void {
|
||||
this.recentOrdersSignal.set(items);
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstPoll = this.lastNotifiedOrderId === null;
|
||||
const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
|
||||
// When the last-notified order id is no longer present in the current page
|
||||
// (deleted, or more than RECENT_ORDERS_LIMIT orders arrived since the last
|
||||
// poll), fall back to a timestamp comparison instead of treating the whole
|
||||
// page as new - otherwise a single missing id could fire a burst of up to
|
||||
// RECENT_ORDERS_LIMIT toasts.
|
||||
const newOrders = isFirstPoll
|
||||
? []
|
||||
: notifyIndex !== -1
|
||||
? items.slice(0, notifyIndex)
|
||||
: items.filter(order => this.lastNotifiedOrderCreatedAt !== null && order.createdAt > this.lastNotifiedOrderCreatedAt);
|
||||
|
||||
this.lastNotifiedOrderId = items[0].id;
|
||||
this.lastNotifiedOrderCreatedAt = items[0].createdAt;
|
||||
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
|
||||
this.storage.setItem(LAST_NOTIFIED_AT_KEY, this.lastNotifiedOrderCreatedAt);
|
||||
|
||||
if (isFirstPoll) {
|
||||
// Nothing existed to compare against yet - treat current orders as already
|
||||
// acknowledged so a fresh admin session doesn't see the whole history as unread.
|
||||
if (this.lastAcknowledgedOrderIdSignal() === null) {
|
||||
this.lastAcknowledgedOrderIdSignal.set(items[0].id);
|
||||
this.lastAcknowledgedOrderCreatedAtSignal.set(items[0].createdAt);
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
|
||||
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, items[0].createdAt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = newOrders.length - 1; i >= 0; i--) {
|
||||
const order = newOrders[i];
|
||||
this.notifications.show(
|
||||
this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }),
|
||||
'info',
|
||||
TOAST_DURATION_MS,
|
||||
[this.languageService.currentLanguage(), 'backoffice', 'orders', order.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readStoredIntervalMs(): number {
|
||||
const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY));
|
||||
return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
@if (notifications().length > 0) {
|
||||
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
|
||||
@for (note of notifications(); track note.id) {
|
||||
<article class="floating-note" [class]="'floating-note floating-note-' + note.type">
|
||||
<article
|
||||
class="floating-note"
|
||||
[class]="'floating-note floating-note-' + note.type"
|
||||
[class.floating-note-clickable]="!!note.route"
|
||||
(click)="note.route && navigate(note)"
|
||||
>
|
||||
<p>{{ note.message }}</p>
|
||||
<button type="button" (click)="dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
|
||||
<button type="button" (click)="$event.stopPropagation(); dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
|
||||
</article>
|
||||
}
|
||||
</aside>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { UserNotificationService } from './user-notification.service';
|
||||
|
||||
describe('UserNotificationService', () => {
|
||||
let service: UserNotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(UserNotificationService);
|
||||
});
|
||||
|
||||
it('stores the route on the notification when provided', () => {
|
||||
service.show('New order #1042', 'info', 4000, ['en', 'backoffice', 'orders', 'ord_1']);
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.message).toBe('New order #1042');
|
||||
expect(note.route).toEqual(['en', 'backoffice', 'orders', 'ord_1']);
|
||||
});
|
||||
|
||||
it('leaves route undefined when not provided', () => {
|
||||
service.show('Saved');
|
||||
|
||||
const [note] = service.notifications();
|
||||
expect(note.route).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ export interface UserNotification {
|
||||
id: string;
|
||||
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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Խանութների միջև անցումը շուտով կհասանելի լինի',
|
||||
|
||||
@@ -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: 'Переключение между магазинами скоро появится',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user