@nauticana/sail 1.1.22 → 1.1.24

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** |
@@ -768,14 +772,20 @@ being summed into a misleading total.
768
772
  The agency URL defaults use `/api/v1/agency/*`. Apps mounting Keel elsewhere
769
773
  must override the `RestURL.agency*URL` values with `configureRestUrls()`.
770
774
 
771
- ## Off-session SCA / 3DS confirmation (v1.0.x)
775
+ ## Off-session SCA / 3DS confirmation (v1.0.x; refusals v1.1.24)
772
776
 
773
- When an app charges a rider's **vaulted** card off-session (keel `payment.ChargeClient` / `StripeChargeClient`), the provider may return `requires_action` — the cardholder must complete a Strong Customer Authentication (3DS) challenge. keel surfaces the hooks on `ChargeResult` (`ClientSecret`, `ActionURL`); sail standardizes the **client-side** confirmation so it isn't rebuilt per app.
777
+ When an app charges a rider's **vaulted** card off-session (keel `payment.ChargeClient` / `StripeChargeClient`), the cardholder may have to complete a Strong Customer Authentication (3DS) challenge. keel reports two distinct outcomes on `ChargeResult`, and they need different client operations:
774
778
 
775
- sail stays provider-agnostic: it ships **no payment-provider SDK** and **no default confirmer**. It provides a port (`ScaConfirmer`), the `SCA_CONFIRMER` injection token, and `<sail-sca-confirm>`, which:
779
+ | `status` | What happened | Client operation |
780
+ |---|---|---|
781
+ | `requires_action` | The PaymentIntent carries a `next_action` to run. | Run it — `actionUrl` redirect, or `confirm(clientSecret)`. |
782
+ | `authentication_required` | The issuer refused the off-session confirmation. The intent is back at `requires_payment_method` with **no `next_action`**. | Re-confirm the same intent on-session with the refused method — `confirmWithMethod(clientSecret, paymentMethodId)`. |
783
+
784
+ sail stays provider-agnostic: it ships **no payment-provider SDK** and **no default confirmer**. It provides a port (`ScaConfirmer`), the `SCA_CONFIRMER` injection token, and `<sail-sca-confirm>`, which dispatches on the status:
776
785
 
777
- - redirects to `ChargeResult.actionUrl` when present (provider-hosted challenge — no SDK needed); else
778
- - delegates the inline `ChargeResult.clientSecret` to the app-registered `SCA_CONFIRMER`.
786
+ - `authentication_required` → `SCA_CONFIRMER.confirmWithMethod`; else
787
+ - `actionUrl` present → redirect (provider-hosted challenge — no SDK needed); else
788
+ - inline `clientSecret` → `SCA_CONFIRMER.confirm`.
779
789
 
780
790
  ### Backend contract (app-owned)
781
791
 
@@ -784,6 +794,7 @@ Off-session charging is a worker/handler concern, so the **app** owns the charge
784
794
  ```jsonc
785
795
  // POST /api/<app>/charge → 200
786
796
  { "status": "requires_action", "clientSecret": "pi_..._secret_...", "actionUrl": "" }
797
+ // or { "status": "authentication_required", "clientSecret": "pi_..._secret_...", "paymentMethodId": "pm_..." }
787
798
  // or { "status": "succeeded", "providerChargeId": "pi_..." }
788
799
  // or { "status": "failed", "error": "card_declined" }
789
800
  ```
@@ -807,6 +818,13 @@ export class StripeScaConfirmer implements ScaConfirmer {
807
818
  const { error } = await stripe.handleNextAction({ clientSecret });
808
819
  return error ? { outcome: 'failed', error: error.message } : { outcome: 'succeeded' };
809
820
  }
821
+
822
+ async confirmWithMethod(clientSecret: string, paymentMethodId: string): Promise<ScaResult> {
823
+ const stripe = await loadStripe(/* publishable key from backend cache */);
824
+ if (!stripe) return { outcome: 'failed', error: 'Stripe.js failed to load' };
825
+ const { error } = await stripe.confirmCardPayment(clientSecret, { payment_method: paymentMethodId });
826
+ return error ? { outcome: 'failed', error: error.message } : { outcome: 'succeeded' };
827
+ }
810
828
  }
811
829
  ```
812
830
 
@@ -830,7 +848,7 @@ providers: [
830
848
  </sail-sca-confirm>
831
849
  ```
832
850
 
833
- `<sail-sca-confirm>` renders nothing for `succeeded`, the decline message for `failed`, and a confirm button for `requires_action`. The publishable key is backend-authoritative — read it from the auth/app-data cache, never hardcode a fallback.
851
+ `<sail-sca-confirm>` renders nothing for `succeeded`, the decline message for `failed`, and a confirm button for both action states. The publishable key is backend-authoritative — read it from the auth/app-data cache, never hardcode a fallback.
834
852
 
835
853
  ## Consent capture
836
854
 
@@ -949,9 +967,9 @@ For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWith
949
967
  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
968
 
951
969
  - **`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`.
970
+ - **`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
971
  - **`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.
972
+ - **`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
973
  - **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
956
974
  - **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
957
975
  - **`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.
@@ -969,6 +987,13 @@ Analytics clients that need this distinction can call the protected
969
987
  `{ rows, source: { status, id } }`, populated from `X-Data-Source-Status` and
970
988
  `X-Data-Source`; the existing `analytic<T>()` remains `Observable<T[]>`.
971
989
 
990
+ `sourceState(result, sourceName, connectLabel?)` maps an analytic result to
991
+ `{ state, message, cta }` for `[state]`, `[noSourceMessage]`, and `[connectLabel]`.
992
+ `NOT_CONNECTED` offers Connect; `NOT_COLLECTED` explains that collection is not
993
+ live and leaves the CTA empty; `READY` or no header yields `ready` / `empty` from
994
+ the rows. The caller supplies the source name and handles
995
+ the click route.
996
+
972
997
  When the app and its API are on different origins the browser hides both headers
973
998
  unless the backend sends
974
999
  `Access-Control-Expose-Headers: X-Data-Source-Status, X-Data-Source`; without it
@@ -1020,6 +1045,61 @@ issued for. The component is unstyled — style `.domain-verification`,
1020
1045
  `.domain-verification-field`, `.domain-verification-verified` and the
1021
1046
  `.verification-*` hooks of the step UI it composes.
1022
1047
 
1048
+ ## Notification inbox (v1.1.23)
1049
+
1050
+ `NotificationInboxService` is the stateful client for keel's `InboxHandler` (keel
1051
+ **v1.2.71+**). `NotificationCenterComponent` (`sail-notification-center`) renders it;
1052
+ the app handles what each message means through `(selected)`.
1053
+
1054
+ The default routes match keel. Override the three `notifications*URL` values through
1055
+ `configureRestUrls` only when the handler is mounted elsewhere.
1056
+
1057
+ ```ts
1058
+ const inbox = inject(NotificationInboxService);
1059
+ inbox.load().subscribe(); // newest page, replaces the cache
1060
+ inbox.loadMore().subscribe(); // the page older than the last cached message
1061
+ inbox.markRead(id).subscribe();
1062
+ inbox.markAllRead().subscribe();
1063
+ inbox.reset(); // on logout — the cache is per-session
1064
+ ```
1065
+
1066
+ ```ts
1067
+ readonly unread = inject(NotificationInboxService).unreadCount; // Signal<number>
1068
+ ```
1069
+
1070
+ | Member | Meaning |
1071
+ |---|---|
1072
+ | `messages` | `Signal<InboxMessage[]>`, newest first. |
1073
+ | `unreadCount` | `Signal<number>` across the whole inbox, not just the loaded page. |
1074
+ | `hasMore` | `Signal<boolean>` — the last page came back full, so older messages may exist. |
1075
+ | `load(limit)` / `loadMore(limit)` | Default 20; keel caps a page at 100 and the client clamps to match. |
1076
+
1077
+ Message ids are server-side `BIGINT`s serialized as strings. Both mark-read routes
1078
+ return `204`, so the service updates its cache locally. Call `load()` to pick up new
1079
+ messages and `reset()` on logout.
1080
+
1081
+ ```html
1082
+ <sail-notification-center
1083
+ heading="Notifications"
1084
+ [pageSize]="20"
1085
+ (selected)="open($event)" />
1086
+ ```
1087
+
1088
+ | Input / output | Meaning |
1089
+ |---|---|
1090
+ | `[pageSize]` | Messages per request, default 20. |
1091
+ | `[heading]` | Rendered as an `<h2>` with the unread count; empty (default) when the page has its own heading. |
1092
+ | `[emptyMessage]`, `[markAllLabel]`, `[markReadLabel]`, `[loadMoreLabel]` | Configurable labels. |
1093
+ | `[dateFormat]` | Angular `DatePipe` format for each timestamp, default `short`. |
1094
+ | `[selectable]` | `false` renders messages as static text, default `true`. |
1095
+ | `[markReadOnSelect]` | Selecting an unread message also marks it read, default `true`. |
1096
+ | `(selected)` | The chosen `InboxMessage` — route on its `type` / `data` yourself. |
1097
+
1098
+ Ships no CSS — style
1099
+ `.notification-center`, `.notification-center__{head,title,unread,error,loading,empty,list,more}`,
1100
+ `.notification-item`, `.notification-item--unread` and
1101
+ `.notification-item__{select,static,title,body,time,read}`.
1102
+
1023
1103
  ## Grouped review actions and diffs (v1.1.19)
1024
1104
 
1025
1105
  `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.24",
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
+ }
@@ -4,19 +4,7 @@ import { BaseAsync } from '../abstract/base_async';
4
4
  import { ChargeResult } from '../../model/appdata';
5
5
  import { SCA_CONFIRMER } from '../../service/sca_confirmer';
6
6
 
7
- /**
8
- * Drives the SCA / 3DS confirmation for an off-session `ChargeResult`.
9
- *
10
- * Two confirmation paths, mirroring keel's `ChargeResult`:
11
- * - `actionUrl` present → redirect to the provider-hosted challenge
12
- * (provider-agnostic; the page navigates away).
13
- * - otherwise `clientSecret` → delegate to the injected `SCA_CONFIRMER`
14
- * (the app's provider SDK wrapper). No confirmer is bundled in sail.
15
- *
16
- * Renders nothing for a `succeeded` result; shows the decline message for
17
- * `failed`; shows a confirm button for `requires_action`. Emits `(confirmed)`
18
- * on success and `(failed)` with a message on any error — never silently.
19
- */
7
+ /** Confirms an off-session charge through a redirect or the injected provider. */
20
8
  @Component({
21
9
  selector: 'sail-sca-confirm',
22
10
  templateUrl: './sca_confirm.html',
@@ -32,23 +20,30 @@ export class ScaConfirmComponent extends BaseAsync {
32
20
  readonly confirmed = output<void>();
33
21
  readonly failed = output<string>();
34
22
 
35
- readonly needsAction = computed(() => this.result().status === 'requires_action');
23
+ readonly needsAction = computed(() => {
24
+ const status = this.result().status;
25
+ return status === 'requires_action' || status === 'authentication_required';
26
+ });
36
27
  readonly declined = computed(() => this.result().status === 'failed');
37
28
 
38
29
  async confirm(): Promise<void> {
30
+ if (!this.needsAction() || this.loading()) return;
39
31
  const res = this.result();
32
+ const reconfirm = res.status === 'authentication_required';
40
33
 
41
- // Provider-hosted redirect branch — provider-agnostic; navigates away.
42
- if (res.actionUrl) {
34
+ if (!reconfirm && res.actionUrl) {
43
35
  window.location.href = res.actionUrl;
44
36
  return;
45
37
  }
46
38
 
47
- // Inline confirmation branch — delegates to the app's provider impl.
48
39
  if (!res.clientSecret) {
49
40
  this.fail('Missing client secret for SCA confirmation.');
50
41
  return;
51
42
  }
43
+ if (reconfirm && !res.paymentMethodId) {
44
+ this.fail('Missing payment method for re-confirmation.');
45
+ return;
46
+ }
52
47
  if (!this.confirmer) {
53
48
  this.fail('No SCA confirmer is configured. Provide SCA_CONFIRMER (e.g. a Stripe.js wrapper).');
54
49
  return;
@@ -57,7 +52,9 @@ export class ScaConfirmComponent extends BaseAsync {
57
52
  this.clearMessages();
58
53
  this.loading.set(true);
59
54
  try {
60
- const out = await this.confirmer.confirm(res.clientSecret);
55
+ const out = reconfirm
56
+ ? await this.confirmer.confirmWithMethod(res.clientSecret, res.paymentMethodId!)
57
+ : await this.confirmer.confirm(res.clientSecret);
61
58
  this.loading.set(false);
62
59
  if (out.outcome === 'succeeded') {
63
60
  this.confirmed.emit();
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';
@@ -325,18 +325,21 @@ export interface UsageMeter {
325
325
  // returns from its own charge endpoint, mirroring keel `payment.ChargeResult`.
326
326
  // 'requires_action' means the rider must complete an SCA / 3DS confirmation
327
327
  // (drive it with `<sail-sca-confirm>`).
328
- export type ChargeStatus = 'succeeded' | 'requires_action' | 'failed';
328
+ // 'authentication_required' is an off-session refusal: no next_action to run —
329
+ // re-confirm the same intent on-session with paymentMethodId.
330
+ export type ChargeStatus = 'succeeded' | 'requires_action' | 'authentication_required' | 'failed';
329
331
 
330
332
  export interface ChargeResult {
331
333
  status: ChargeStatus;
332
334
  providerChargeId?: string; // PaymentIntent / charge id (pi_xxx)
333
- // Provider client secret — present when status === 'requires_action' and the
334
- // SCA challenge must be confirmed inline (provider SDK). Mirrors keel
335
- // ChargeResult.ClientSecret.
335
+ // Provider client secret for inline confirmation or on-session re-confirmation.
336
336
  clientSecret?: string;
337
337
  // Provider-hosted redirect URL when one exists (often empty for off-session
338
338
  // 3DS). When present it's the simplest, provider-agnostic confirmation path.
339
339
  actionUrl?: string;
340
+ // The refused stored method, to re-confirm with when
341
+ // status === 'authentication_required'. Mirrors keel ChargeResult.PaymentMethodID.
342
+ paymentMethodId?: string;
340
343
  error?: string; // decline / error message when status === 'failed'
341
344
  }
342
345
 
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { HttpClient, HttpParams } from '@angular/common/http';
2
2
  import { inject } from '@angular/core';
3
3
  import { map, Observable } from 'rxjs';
4
+ import type { DataCardState } from '../model/dashboard';
4
5
  import { RestURL } from './rest_url';
5
6
 
6
7
  /**
@@ -64,3 +65,20 @@ export interface AnalyticSourceStatus {
64
65
  status: string | null;
65
66
  id: string | null;
66
67
  }
68
+
69
+ export interface SourceCardState {
70
+ state: DataCardState;
71
+ message: string;
72
+ cta: string;
73
+ }
74
+
75
+ /** Only keel's two not-ready states gate the card; `READY` or no header falls through to the rows. */
76
+ export function sourceState<T>(result: AnalyticResult<T>, sourceName: string, connectLabel = 'Connect'): SourceCardState {
77
+ if (result.source.status === 'NOT_COLLECTED') {
78
+ return { state: 'no-source', message: `${sourceName} collection is not live yet.`, cta: '' };
79
+ }
80
+ if (result.source.status === 'NOT_CONNECTED') {
81
+ return { state: 'no-source', message: `Connect ${sourceName} to see this.`, cta: connectLabel };
82
+ }
83
+ return { state: result.rows.length ? 'ready' : 'empty', message: '', cta: '' };
84
+ }
@@ -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)
@@ -20,10 +20,17 @@ export interface ScaResult {
20
20
  * { provide: SCA_CONFIRMER, useClass: StripeScaConfirmer }
21
21
  *
22
22
  * The `actionUrl` (provider-hosted redirect) branch needs no confirmer — only
23
- * the inline `clientSecret` branch delegates here.
23
+ * the inline `clientSecret` branches delegate here.
24
24
  */
25
25
  export interface ScaConfirmer {
26
+ /** Run the intent's `next_action` (Stripe.js `handleNextAction`). */
26
27
  confirm(clientSecret: string): Promise<ScaResult>;
28
+ /**
29
+ * Re-confirm the intent on-session with the method the issuer refused
30
+ * off-session (Stripe.js `confirmCardPayment`, stripe-ios
31
+ * `STPPaymentHandler.confirmPayment`) — there is no `next_action` to run.
32
+ */
33
+ confirmWithMethod(clientSecret: string, paymentMethodId: string): Promise<ScaResult>;
27
34
  }
28
35
 
29
36
  export const SCA_CONFIRMER = new InjectionToken<ScaConfirmer>('SailScaConfirmer');