@nauticana/sail 1.1.22 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,11 +23,12 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
23
23
  | **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
24
24
  | **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
25
25
  | **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
26
+ | **Notifications** | `NotificationCenterComponent`, `NotificationInboxService` |
26
27
  | **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
27
- | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
28
+ | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
28
29
  | **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
29
30
  | **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
30
- | **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
31
+ | **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, 2FA types, etc. |
31
32
  | **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
32
33
  | **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
33
34
 
@@ -342,6 +343,9 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
342
343
  | `POST /api/user/trusted-device/revoke` | Revoke a trusted device |
343
344
  | `POST /api/user/logout-everywhere` | Sign out of all devices — **requires re-auth** |
344
345
  | `DELETE /api/user/account` | Soft-delete the caller's account — **requires re-auth** |
346
+ | `GET /api/notifications` | Notification inbox page — `?before=<id>&limit=<n>`, newest first (keel v1.2.71) |
347
+ | `POST /api/notifications/mark_read` | Mark one message read — `{"id": "<id>"}` |
348
+ | `POST /api/notifications/mark_all_read` | Mark the caller's whole inbox read |
345
349
  | `POST /api/push/register` | Register an FCM / APNs token |
346
350
  | `POST /api/push/revoke` | Revoke an FCM / APNs token |
347
351
  | `POST /api/billing/checkout` | Create provider-hosted checkout session — **JWT-gated, allowlist-validated** |
@@ -949,9 +953,9 @@ For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWith
949
953
  Horizontal building blocks for an entitlement-aware dashboard. All are presentational (signals in, events out) and ship no CSS — the app styles the documented class hooks and supplies the metric data, chart, and action logic.
950
954
 
951
955
  - **`DashboardShellComponent`** (`sail-dashboard-shell`) — responsive card-grid layout. `[heading]` + a `[dashboardActions]` projection slot for the header; project cards into the default slot. Styles: `.dashboard-shell` / `.dashboard-header` / `.dashboard-grid`.
952
- - **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked | no-source`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked/no-source it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`, `connect`, and `retry`.
956
+ - **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked | no-source`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked/no-source it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`, `connect`, and `retry`. `[connectLabel]="''"` renders the `no-source` state without a button — a source whose collector does not exist yet has nothing to connect.
953
957
  - **`LockedOverlayComponent`** (`sail-locked-overlay`) — entitlement gate. Wraps protected content given as an `<ng-template>`; when `[locked]` the template is never instantiated, so **no data exists behind the gate** (DOM, canvas, or network) — the strict free-tier rule. Emits `upgrade`.
954
- - **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state.
958
+ - **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state. Set `[ctaLabel]="''"` when there is nothing to connect, and no button renders.
955
959
  - **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
956
960
  - **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
957
961
  - **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back. `[resendAfterSeconds]` holds `Resend code` for a cooldown and `[codeExpiresInSeconds]` blocks `Confirm` once the issued code expires; both default to `0`, which keeps the button always enabled and shows no expiry. Both countdowns restart when the component emits `requestVerification`. `[disabled]` blocks all actions while the host is processing one. `[errorMessage]` also renders in the `idle` step, so a failed send is reported where it happened.
@@ -1020,6 +1024,61 @@ issued for. The component is unstyled — style `.domain-verification`,
1020
1024
  `.domain-verification-field`, `.domain-verification-verified` and the
1021
1025
  `.verification-*` hooks of the step UI it composes.
1022
1026
 
1027
+ ## Notification inbox (v1.1.23)
1028
+
1029
+ `NotificationInboxService` is the stateful client for keel's `InboxHandler` (keel
1030
+ **v1.2.71+**). `NotificationCenterComponent` (`sail-notification-center`) renders it;
1031
+ the app handles what each message means through `(selected)`.
1032
+
1033
+ The default routes match keel. Override the three `notifications*URL` values through
1034
+ `configureRestUrls` only when the handler is mounted elsewhere.
1035
+
1036
+ ```ts
1037
+ const inbox = inject(NotificationInboxService);
1038
+ inbox.load().subscribe(); // newest page, replaces the cache
1039
+ inbox.loadMore().subscribe(); // the page older than the last cached message
1040
+ inbox.markRead(id).subscribe();
1041
+ inbox.markAllRead().subscribe();
1042
+ inbox.reset(); // on logout — the cache is per-session
1043
+ ```
1044
+
1045
+ ```ts
1046
+ readonly unread = inject(NotificationInboxService).unreadCount; // Signal<number>
1047
+ ```
1048
+
1049
+ | Member | Meaning |
1050
+ |---|---|
1051
+ | `messages` | `Signal<InboxMessage[]>`, newest first. |
1052
+ | `unreadCount` | `Signal<number>` across the whole inbox, not just the loaded page. |
1053
+ | `hasMore` | `Signal<boolean>` — the last page came back full, so older messages may exist. |
1054
+ | `load(limit)` / `loadMore(limit)` | Default 20; keel caps a page at 100 and the client clamps to match. |
1055
+
1056
+ Message ids are server-side `BIGINT`s serialized as strings. Both mark-read routes
1057
+ return `204`, so the service updates its cache locally. Call `load()` to pick up new
1058
+ messages and `reset()` on logout.
1059
+
1060
+ ```html
1061
+ <sail-notification-center
1062
+ heading="Notifications"
1063
+ [pageSize]="20"
1064
+ (selected)="open($event)" />
1065
+ ```
1066
+
1067
+ | Input / output | Meaning |
1068
+ |---|---|
1069
+ | `[pageSize]` | Messages per request, default 20. |
1070
+ | `[heading]` | Rendered as an `<h2>` with the unread count; empty (default) when the page has its own heading. |
1071
+ | `[emptyMessage]`, `[markAllLabel]`, `[markReadLabel]`, `[loadMoreLabel]` | Configurable labels. |
1072
+ | `[dateFormat]` | Angular `DatePipe` format for each timestamp, default `short`. |
1073
+ | `[selectable]` | `false` renders messages as static text, default `true`. |
1074
+ | `[markReadOnSelect]` | Selecting an unread message also marks it read, default `true`. |
1075
+ | `(selected)` | The chosen `InboxMessage` — route on its `type` / `data` yourself. |
1076
+
1077
+ Ships no CSS — style
1078
+ `.notification-center`, `.notification-center__{head,title,unread,error,loading,empty,list,more}`,
1079
+ `.notification-item`, `.notification-item--unread` and
1080
+ `.notification-item__{select,static,title,body,time,read}`.
1081
+
1023
1082
  ## Grouped review actions and diffs (v1.1.19)
1024
1083
 
1025
1084
  `GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.22",
3
+ "version": "1.1.23",
4
4
  "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -37,7 +37,9 @@
37
37
  <div class="data-card-no-source" role="group" [attr.aria-label]="(heading() || 'Insight') + ' — source unavailable'">
38
38
  <p class="no-source-name">{{ sourceName() }}</p>
39
39
  <p class="no-source-message">{{ noSourceMessage() }}</p>
40
- <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ connectLabel() }}</button>
40
+ @if (connectLabel()) {
41
+ <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ connectLabel() }}</button>
42
+ }
41
43
  </div>
42
44
  }
43
45
  }
@@ -2,7 +2,9 @@
2
2
  <div class="no-source-overlay" role="group" [attr.aria-label]="sourceName() + ' — source unavailable'">
3
3
  <p class="no-source-name">{{ sourceName() }}</p>
4
4
  <p class="no-source-message">{{ message() }}</p>
5
- <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ ctaLabel() }}</button>
5
+ @if (ctaLabel()) {
6
+ <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ ctaLabel() }}</button>
7
+ }
6
8
  </div>
7
9
  } @else if (content(); as tpl) {
8
10
  <ng-container [ngTemplateOutlet]="tpl"></ng-container>
@@ -0,0 +1,60 @@
1
+ <section class="notification-center" [attr.aria-busy]="loading()">
2
+ <div class="notification-center__head">
3
+ @if (heading()) {
4
+ <h2 class="notification-center__title">
5
+ {{ heading() }}
6
+ @if (unreadCount()) { <span class="notification-center__unread">{{ unreadCount() }}</span> }
7
+ </h2>
8
+ }
9
+ @if (unreadCount()) {
10
+ <button matButton type="button" [disabled]="loading()" (click)="markAllRead()">{{ markAllLabel() }}</button>
11
+ }
12
+ </div>
13
+
14
+ @if (errorMessage()) {
15
+ <p class="notification-center__error" role="alert">{{ errorMessage() }}</p>
16
+ }
17
+
18
+ @if (loading() && !messages().length) {
19
+ <div class="notification-center__loading">
20
+ <mat-spinner diameter="28" aria-label="Loading notifications"></mat-spinner>
21
+ </div>
22
+ }
23
+
24
+ @if (empty()) {
25
+ <p class="notification-center__empty">{{ emptyMessage() }}</p>
26
+ } @else if (messages().length) {
27
+ <ul class="notification-center__list">
28
+ @for (message of messages(); track message.id) {
29
+ <li class="notification-item" [class.notification-item--unread]="!message.readAt">
30
+ @if (selectable()) {
31
+ <button class="notification-item__select" type="button" [disabled]="loading()" (click)="select(message)">
32
+ <span class="notification-item__title">{{ message.title }}</span>
33
+ <span class="notification-item__body">{{ message.body }}</span>
34
+ <time class="notification-item__time" [attr.datetime]="message.createdAt">{{ message.createdAt | date: dateFormat() }}</time>
35
+ </button>
36
+ } @else {
37
+ <div class="notification-item__static">
38
+ <span class="notification-item__title">{{ message.title }}</span>
39
+ <span class="notification-item__body">{{ message.body }}</span>
40
+ <time class="notification-item__time" [attr.datetime]="message.createdAt">{{ message.createdAt | date: dateFormat() }}</time>
41
+ </div>
42
+ }
43
+ @if (!message.readAt) {
44
+ <button matIconButton type="button" class="notification-item__read"
45
+ [attr.aria-label]="markReadLabel() + ': ' + message.title"
46
+ [disabled]="loading()" (click)="markRead(message)">
47
+ <mat-icon>done</mat-icon>
48
+ </button>
49
+ }
50
+ </li>
51
+ }
52
+ </ul>
53
+
54
+ @if (hasMore()) {
55
+ <button matButton type="button" class="notification-center__more" [disabled]="loading()" (click)="loadMore()">
56
+ {{ loadMoreLabel() }}
57
+ </button>
58
+ }
59
+ }
60
+ </section>
@@ -0,0 +1,69 @@
1
+ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, output } from '@angular/core';
2
+ import { DatePipe } from '@angular/common';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatIconModule } from '@angular/material/icon';
5
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
6
+ import { BaseAsync } from '../abstract/base_async';
7
+ import { NotificationInboxService, INBOX_DEFAULT_PAGE_SIZE } from '../../service/notification_inbox.service';
8
+ import { InboxMessage } from '../../model/notification';
9
+
10
+ /** In-app inbox over keel's `InboxHandler`; message routing stays with the app. */
11
+ @Component({
12
+ selector: 'sail-notification-center',
13
+ templateUrl: './notification_center.html',
14
+ changeDetection: ChangeDetectionStrategy.OnPush,
15
+ encapsulation: ViewEncapsulation.None,
16
+ imports: [DatePipe, MatButtonModule, MatIconModule, MatProgressSpinnerModule],
17
+ })
18
+ export class NotificationCenterComponent extends BaseAsync implements OnInit {
19
+ readonly pageSize = input(INBOX_DEFAULT_PAGE_SIZE);
20
+ readonly heading = input('');
21
+ readonly emptyMessage = input('You have no notifications.');
22
+ readonly markAllLabel = input('Mark all as read');
23
+ readonly markReadLabel = input('Mark as read');
24
+ readonly loadMoreLabel = input('Load older');
25
+ readonly dateFormat = input('short');
26
+ readonly selectable = input(true);
27
+ readonly markReadOnSelect = input(true);
28
+
29
+ readonly selected = output<InboxMessage>();
30
+
31
+ private readonly inbox = inject(NotificationInboxService);
32
+
33
+ readonly messages = this.inbox.messages;
34
+ readonly unreadCount = this.inbox.unreadCount;
35
+ readonly hasMore = this.inbox.hasMore;
36
+ readonly empty = computed(() => !this.loading() && !this.errorMessage() && this.messages().length === 0);
37
+
38
+ ngOnInit(): void {
39
+ this.reload();
40
+ }
41
+
42
+ reload(): void {
43
+ this.run(this.inbox.load(this.pageSize()), () => undefined, 'Could not load your notifications.');
44
+ }
45
+
46
+ loadMore(): void {
47
+ this.run(this.inbox.loadMore(this.pageSize()), () => undefined, 'Could not load older notifications.');
48
+ }
49
+
50
+ select(message: InboxMessage): void {
51
+ // Not via run(): the app may navigate away on select, and teardown would cancel the POST.
52
+ if (this.markReadOnSelect() && !message.readAt) {
53
+ this.inbox.markRead(message.id).subscribe({
54
+ error: (err) => this.setError(err, 'Could not mark that notification read.'),
55
+ });
56
+ }
57
+ this.selected.emit(message);
58
+ }
59
+
60
+ markRead(message: InboxMessage): void {
61
+ if (message.readAt) return;
62
+ this.run(this.inbox.markRead(message.id), () => undefined, 'Could not mark that notification read.');
63
+ }
64
+
65
+ markAllRead(): void {
66
+ if (!this.unreadCount()) return;
67
+ this.run(this.inbox.markAllRead(), () => undefined, 'Could not mark your notifications read.');
68
+ }
69
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './model/auth';
4
4
  export * from './model/common';
5
5
  export * from './model/dashboard';
6
6
  export * from './model/oauth_connection';
7
+ export * from './model/notification';
7
8
  export * from './model/agency';
8
9
  export * from './model/decorator';
9
10
  export * from './component/abstract/base_table';
@@ -15,6 +16,7 @@ export * from './component/dashboard/action_center';
15
16
  export * from './component/dashboard/entity_selector';
16
17
  export * from './component/dashboard/verification_flow';
17
18
  export * from './component/verification/domain_verification';
19
+ export * from './component/notification/notification_center';
18
20
  export * from './component/oauth_connection/oauth_connection_panel';
19
21
  export * from './component/abstract/base_form';
20
22
  export * from './component/abstract/base_view';
@@ -74,6 +76,7 @@ export * from './service/billing.service';
74
76
  export * from './service/payout.service';
75
77
  export * from './service/agency.service';
76
78
  export * from './service/oauth_connection.service';
79
+ export * from './service/notification_inbox.service';
77
80
  export * from './service/user_payment_method.service';
78
81
  export * from './service/script_loader';
79
82
  export * from './service/auth.interceptor';
@@ -0,0 +1,20 @@
1
+ /** In-app notification returned by keel's `InboxHandler`. */
2
+ export interface InboxMessage {
3
+ /** Server-side `BIGINT`, serialized as a string to preserve precision. */
4
+ id: string;
5
+ /** App-defined category (keel `notification_type`); '' when the sender set none. */
6
+ type: string;
7
+ title: string;
8
+ body: string;
9
+ /** Sender payload the app routes on (`{}` when the sender supplied none). */
10
+ data: Record<string, string>;
11
+ /** ISO timestamp, or null while unread. */
12
+ readAt: string | null;
13
+ createdAt: string;
14
+ }
15
+
16
+ /** One page of the caller's inbox, newest first, plus the whole-inbox unread total. */
17
+ export interface InboxPage {
18
+ messages: InboxMessage[];
19
+ unreadCount: number;
20
+ }
@@ -0,0 +1,78 @@
1
+ import { Injectable, Signal, signal } from '@angular/core';
2
+ import { HttpParams } from '@angular/common/http';
3
+ import { Observable, tap } from 'rxjs';
4
+ import { BaseRestService } from './base_rest.service';
5
+ import { RestURL } from './rest_url';
6
+ import { InboxMessage, InboxPage } from '../model/notification';
7
+
8
+ export const INBOX_MAX_PAGE_SIZE = 100;
9
+ export const INBOX_DEFAULT_PAGE_SIZE = 20;
10
+
11
+ /** Stateful client for keel's `InboxHandler` (v1.2.71+). Call `reset()` on logout. */
12
+ @Injectable({ providedIn: 'root' })
13
+ export class NotificationInboxService extends BaseRestService {
14
+ private readonly cached = signal<InboxMessage[]>([]);
15
+ private readonly unread = signal(0);
16
+ private readonly older = signal(false);
17
+
18
+ readonly messages: Signal<InboxMessage[]> = this.cached.asReadonly();
19
+ readonly unreadCount: Signal<number> = this.unread.asReadonly();
20
+ readonly hasMore: Signal<boolean> = this.older.asReadonly();
21
+
22
+ load(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
23
+ return this.fetch('', limit).pipe(tap((page) => this.cached.set(page.messages)));
24
+ }
25
+
26
+ loadMore(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
27
+ const loaded = this.cached();
28
+ const oldest = loaded.length ? loaded[loaded.length - 1].id : '';
29
+ return this.fetch(oldest, limit).pipe(
30
+ tap((page) => this.cached.update((rows) => {
31
+ const ids = new Set(rows.map((message) => message.id));
32
+ return rows.concat(page.messages.filter((message) => !ids.has(message.id)));
33
+ })),
34
+ );
35
+ }
36
+
37
+ markRead(id: string): Observable<void> {
38
+ return this.http.post<void>(this.url(RestURL.notificationsMarkReadURL), { id }).pipe(
39
+ tap(() => {
40
+ const wasUnread = this.cached().some((m) => m.id === id && !m.readAt);
41
+ this.cached.update((rows) => rows.map((m) => (m.id === id ? { ...m, readAt: m.readAt ?? nowIso() } : m)));
42
+ if (wasUnread) this.unread.update((n) => Math.max(0, n - 1));
43
+ }),
44
+ );
45
+ }
46
+
47
+ markAllRead(): Observable<void> {
48
+ return this.http.post<void>(this.url(RestURL.notificationsMarkAllReadURL), {}).pipe(
49
+ tap(() => {
50
+ this.cached.update((rows) => rows.map((m) => (m.readAt ? m : { ...m, readAt: nowIso() })));
51
+ this.unread.set(0);
52
+ }),
53
+ );
54
+ }
55
+
56
+ reset(): void {
57
+ this.cached.set([]);
58
+ this.unread.set(0);
59
+ this.older.set(false);
60
+ }
61
+
62
+ private fetch(before: string, limit: number): Observable<InboxPage> {
63
+ const requested = Number.isFinite(limit) ? Math.trunc(limit) : INBOX_DEFAULT_PAGE_SIZE;
64
+ const size = Math.min(Math.max(1, requested), INBOX_MAX_PAGE_SIZE);
65
+ let params = new HttpParams().set('limit', size);
66
+ if (before) params = params.set('before', before);
67
+ return this.http.get<InboxPage>(this.url(RestURL.notificationsURL), { params }).pipe(
68
+ tap((page) => {
69
+ this.unread.set(page.unreadCount);
70
+ this.older.set(page.messages.length >= size);
71
+ }),
72
+ );
73
+ }
74
+ }
75
+
76
+ function nowIso(): string {
77
+ return new Date().toISOString();
78
+ }
@@ -46,6 +46,10 @@ export const RestURL = {
46
46
  profileEmailConfirmURL: '/api/user/profile/email/confirm',
47
47
  profilePhoneURL: '/api/user/profile/phone',
48
48
  profilePhoneConfirmURL: '/api/user/profile/phone/confirm',
49
+ // Notification inbox (keel InboxHandler, v1.2.71+).
50
+ notificationsURL: '/api/notifications',
51
+ notificationsMarkReadURL: '/api/notifications/mark_read',
52
+ notificationsMarkAllReadURL: '/api/notifications/mark_all_read',
49
53
  pushRegisterURL: '/api/push/register',
50
54
  pushRevokeURL: '/api/push/revoke',
51
55
  // Payout — keel/payout endpoints. keel mounts PayoutHandler.Routes(prefix)