11 Commits

Author SHA1 Message Date
sdarbinyan
7f9bb6aae6 merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:34:59 +04:00
sdarbinyan
1ab689e056 fix: unreadCount badge falls back to timestamp when ack pointer scrolls off page
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:33:25 +04:00
sdarbinyan
f1ee199d92 fix: AdminOrderWatcherService reacts to auth state, not component lifecycle
Previous fix (1032891) stopped polling via AdminLayoutComponent's
DestroyRef, but logout() never navigates or destroys the component -
the watcher kept polling and toasting indefinitely after logout.
Now polling starts/stops off AdminAuthService.isAuthenticated() directly,
following the same effect() pattern already used by AdminDashboardFacade.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:25:51 +04:00
sdarbinyan
1032891d26 fix: address final review findings (order-notification watcher robustness)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:18:20 +04:00
sdarbinyan
9ccd807a55 feat: editable new-order poll interval in admin settings
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:04:32 +04:00
sdarbinyan
35b1c7ed27 feat: wire order watcher into admin topbar bell (badge + panel)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:59:23 +04:00
sdarbinyan
28f39a31f6 feat: AdminOrderWatcherService polls for new orders and toasts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:52:55 +04:00
sdarbinyan
5ed4936898 feat: UserNotificationService supports click-to-navigate toasts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-15 03:47:46 +04:00
sdarbinyan
55e4938a57 docs: spec update - reuse existing topbar bell instead of new sidebar badge
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:43:07 +04:00
sdarbinyan
cfee91355b docs: implementation plan for admin purchase notifications (item 7)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:20:32 +04:00
sdarbinyan
0d0f4e5f9c docs: design spec for admin purchase notifications (item 7)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 02:08:36 +04:00
20 changed files with 1786 additions and 6 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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');
}

View File

@@ -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">
@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>

View File

@@ -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;

View 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);
});
});

View File

@@ -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 ?? [])];
}

View File

@@ -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);
}));
});

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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();
});
});

View File

@@ -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));

View File

@@ -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',

View File

@@ -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: 'Խանութների միջև անցումը շուտով կհասանելի լինի',

View File

@@ -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: 'Переключение между магазинами скоро появится',

View File

@@ -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;