Compare commits
3 Commits
9ccd807a55
...
1ab689e056
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab689e056 | ||
|
|
f1ee199d92 | ||
|
|
1032891d26 |
@@ -13,7 +13,7 @@ describe('AdminSettingsPageComponent notification interval', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
watcherStub = {
|
watcherStub = {
|
||||||
intervalMs: signal(15000),
|
intervalMs: signal(15000),
|
||||||
setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'),
|
setIntervalSeconds: jasmine.createSpy('setIntervalSeconds').and.returnValue(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -39,4 +39,31 @@ describe('AdminSettingsPageComponent notification interval', () => {
|
|||||||
|
|
||||||
expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ export class AdminSettingsPageComponent {
|
|||||||
readonly showNotificationIntervalSaved = signal(false);
|
readonly showNotificationIntervalSaved = signal(false);
|
||||||
|
|
||||||
saveNotificationInterval(): void {
|
saveNotificationInterval(): void {
|
||||||
this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
|
const applied = this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
|
||||||
this.showNotificationIntervalSaved.set(true);
|
this.notificationIntervalSecondsDraft.set(Math.round(this.orderWatcher.intervalMs() / 1000));
|
||||||
setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
|
if (applied) {
|
||||||
|
this.showNotificationIntervalSaved.set(true);
|
||||||
|
setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCompactToggle(compact: boolean): void {
|
onCompactToggle(compact: boolean): void {
|
||||||
|
|||||||
@@ -367,6 +367,8 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
width: 240px;
|
width: 240px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('AdminLayoutComponent notifications bell', () => {
|
|||||||
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
|
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
|
||||||
unreadCount: ReturnType<typeof signal<number>>;
|
unreadCount: ReturnType<typeof signal<number>>;
|
||||||
start: jasmine.Spy;
|
start: jasmine.Spy;
|
||||||
|
stop: jasmine.Spy;
|
||||||
markAllSeen: jasmine.Spy;
|
markAllSeen: jasmine.Spy;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ describe('AdminLayoutComponent notifications bell', () => {
|
|||||||
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
||||||
unreadCount: signal(1),
|
unreadCount: signal(1),
|
||||||
start: jasmine.createSpy('start'),
|
start: jasmine.createSpy('start'),
|
||||||
|
stop: jasmine.createSpy('stop'),
|
||||||
markAllSeen: jasmine.createSpy('markAllSeen'),
|
markAllSeen: jasmine.createSpy('markAllSeen'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,4 +75,11 @@ describe('AdminLayoutComponent notifications bell', () => {
|
|||||||
expect(component.notificationsOpen()).toBe(false);
|
expect(component.notificationsOpen()).toBe(false);
|
||||||
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class AdminLayoutComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.readRouteData();
|
this.readRouteData();
|
||||||
this.orderWatcher.start();
|
this.orderWatcher.start();
|
||||||
|
this.destroyRef.onDestroy(() => this.orderWatcher.stop());
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(event => event instanceof NavigationEnd),
|
filter(event => event instanceof NavigationEnd),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { AdminOrderWatcherService } from './admin-order-watcher.service';
|
import { AdminOrderWatcherService } from './admin-order-watcher.service';
|
||||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||||
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
|
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
|
||||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
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 {
|
function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
|
||||||
return {
|
return {
|
||||||
@@ -31,6 +33,7 @@ describe('AdminOrderWatcherService', () => {
|
|||||||
let pollIndex: number;
|
let pollIndex: number;
|
||||||
let notifications: UserNotificationService;
|
let notifications: UserNotificationService;
|
||||||
let service: AdminOrderWatcherService;
|
let service: AdminOrderWatcherService;
|
||||||
|
let fakeAdminAuth: { isAuthenticated: ReturnType<typeof signal<boolean>> };
|
||||||
|
|
||||||
function fakeGateway() {
|
function fakeGateway() {
|
||||||
return {
|
return {
|
||||||
@@ -48,12 +51,22 @@ describe('AdminOrderWatcherService', () => {
|
|||||||
[makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
|
[makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
|
||||||
];
|
];
|
||||||
|
|
||||||
localStorage.clear();
|
localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderId.v1');
|
||||||
|
localStorage.removeItem('adminOrderWatcher.lastNotifiedOrderCreatedAt.v1');
|
||||||
|
localStorage.removeItem('adminOrderWatcher.lastAcknowledgedOrderId.v1');
|
||||||
|
localStorage.removeItem('adminOrderWatcher.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({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
|
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
|
||||||
|
{ provide: AdminAuthService, useValue: fakeAdminAuth as unknown as AdminAuthService },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,13 +123,119 @@ describe('AdminOrderWatcherService', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
|
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
|
||||||
service.setIntervalSeconds(30);
|
expect(service.setIntervalSeconds(30)).toBe(true);
|
||||||
expect(service.intervalMs()).toBe(30000);
|
expect(service.intervalMs()).toBe(30000);
|
||||||
|
|
||||||
service.setIntervalSeconds(0);
|
expect(service.setIntervalSeconds(0)).toBe(false);
|
||||||
expect(service.intervalMs()).toBe(30000);
|
expect(service.intervalMs()).toBe(30000);
|
||||||
|
|
||||||
service.setIntervalSeconds(-5);
|
expect(service.setIntervalSeconds(-5)).toBe(false);
|
||||||
expect(service.intervalMs()).toBe(30000);
|
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);
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
import { Injectable, Signal, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
import { TranslateService } from '../../../../i18n/translate.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_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
|
||||||
|
const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1';
|
||||||
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
|
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
|
||||||
|
const LAST_ACKNOWLEDGED_AT_KEY = 'adminOrderWatcher.lastAcknowledgedOrderCreatedAt.v1';
|
||||||
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
|
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
|
||||||
export const DEFAULT_POLL_INTERVAL_MS = 15000;
|
export const DEFAULT_POLL_INTERVAL_MS = 15000;
|
||||||
const MIN_POLL_INTERVAL_MS = 1000;
|
const MIN_POLL_INTERVAL_MS = 1000;
|
||||||
@@ -21,11 +24,13 @@ export class AdminOrderWatcherService {
|
|||||||
private readonly notifications = inject(UserNotificationService);
|
private readonly notifications = inject(UserNotificationService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly i18n = inject(TranslateService);
|
private readonly i18n = inject(TranslateService);
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
|
|
||||||
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
|
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
|
||||||
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
|
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
|
||||||
|
|
||||||
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
|
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(() => {
|
readonly unreadCount = computed(() => {
|
||||||
const orders = this.recentOrdersSignal();
|
const orders = this.recentOrdersSignal();
|
||||||
@@ -37,16 +42,38 @@ export class AdminOrderWatcherService {
|
|||||||
return orders.length;
|
return orders.length;
|
||||||
}
|
}
|
||||||
const idx = orders.findIndex(order => order.id === ackId);
|
const idx = orders.findIndex(order => order.id === ackId);
|
||||||
return idx === -1 ? orders.length : idx;
|
if (idx !== -1) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
// The acknowledged order id is no longer present in the current page
|
||||||
|
// (deleted, or many orders arrived since ack) - fall back to a timestamp
|
||||||
|
// comparison instead of treating the whole page as unread.
|
||||||
|
const ackCreatedAt = this.lastAcknowledgedOrderCreatedAtSignal();
|
||||||
|
return ackCreatedAt === null ? orders.length : orders.filter(order => order.createdAt > ackCreatedAt).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
|
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
|
||||||
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
|
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
|
||||||
|
|
||||||
private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
|
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 timerId: ReturnType<typeof setInterval> | null = null;
|
||||||
private started = false;
|
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 {
|
start(): void {
|
||||||
if (this.started) {
|
if (this.started) {
|
||||||
return;
|
return;
|
||||||
@@ -56,9 +83,17 @@ export class AdminOrderWatcherService {
|
|||||||
this.scheduleNext();
|
this.scheduleNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
setIntervalSeconds(seconds: number): void {
|
stop(): void {
|
||||||
|
if (this.timerId !== null) {
|
||||||
|
clearInterval(this.timerId);
|
||||||
|
this.timerId = null;
|
||||||
|
}
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIntervalSeconds(seconds: number): boolean {
|
||||||
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
|
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const ms = Math.round(seconds * 1000);
|
const ms = Math.round(seconds * 1000);
|
||||||
this.intervalMsSignal.set(ms);
|
this.intervalMsSignal.set(ms);
|
||||||
@@ -66,14 +101,18 @@ export class AdminOrderWatcherService {
|
|||||||
if (this.started) {
|
if (this.started) {
|
||||||
this.scheduleNext();
|
this.scheduleNext();
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
markAllSeen(): void {
|
markAllSeen(): void {
|
||||||
const newestId = this.recentOrdersSignal()[0]?.id ?? null;
|
const newest = this.recentOrdersSignal()[0];
|
||||||
this.lastAcknowledgedOrderIdSignal.set(newestId);
|
if (!newest) {
|
||||||
if (newestId) {
|
return;
|
||||||
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId);
|
|
||||||
}
|
}
|
||||||
|
this.lastAcknowledgedOrderIdSignal.set(newest.id);
|
||||||
|
this.lastAcknowledgedOrderCreatedAtSignal.set(newest.createdAt);
|
||||||
|
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newest.id);
|
||||||
|
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, newest.createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleNext(): void {
|
private scheduleNext(): void {
|
||||||
@@ -99,17 +138,30 @@ export class AdminOrderWatcherService {
|
|||||||
|
|
||||||
const isFirstPoll = this.lastNotifiedOrderId === null;
|
const isFirstPoll = this.lastNotifiedOrderId === null;
|
||||||
const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
|
const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
|
||||||
const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex));
|
// When the last-notified order id is no longer present in the current page
|
||||||
|
// (deleted, or more than RECENT_ORDERS_LIMIT orders arrived since the last
|
||||||
|
// poll), fall back to a timestamp comparison instead of treating the whole
|
||||||
|
// page as new - otherwise a single missing id could fire a burst of up to
|
||||||
|
// RECENT_ORDERS_LIMIT toasts.
|
||||||
|
const newOrders = isFirstPoll
|
||||||
|
? []
|
||||||
|
: notifyIndex !== -1
|
||||||
|
? items.slice(0, notifyIndex)
|
||||||
|
: items.filter(order => this.lastNotifiedOrderCreatedAt !== null && order.createdAt > this.lastNotifiedOrderCreatedAt);
|
||||||
|
|
||||||
this.lastNotifiedOrderId = items[0].id;
|
this.lastNotifiedOrderId = items[0].id;
|
||||||
|
this.lastNotifiedOrderCreatedAt = items[0].createdAt;
|
||||||
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
|
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
|
||||||
|
this.storage.setItem(LAST_NOTIFIED_AT_KEY, this.lastNotifiedOrderCreatedAt);
|
||||||
|
|
||||||
if (isFirstPoll) {
|
if (isFirstPoll) {
|
||||||
// Nothing existed to compare against yet - treat current orders as already
|
// 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.
|
// acknowledged so a fresh admin session doesn't see the whole history as unread.
|
||||||
if (this.lastAcknowledgedOrderIdSignal() === null) {
|
if (this.lastAcknowledgedOrderIdSignal() === null) {
|
||||||
this.lastAcknowledgedOrderIdSignal.set(items[0].id);
|
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_KEY, items[0].id);
|
||||||
|
this.storage.setItem(LAST_ACKNOWLEDGED_AT_KEY, items[0].createdAt);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
border-color: color-mix(in srgb, var(--primary-color) 45%, white);
|
border-color: color-mix(in srgb, var(--primary-color) 45%, white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-note-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes note-in {
|
@keyframes note-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user