@nauticana/sail 1.1.23 → 1.1.25
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 +34 -8
- package/package.json +1 -1
- package/src/component/abstract/base_table.ts +19 -4
- package/src/component/dashboard/stat_tile.html +29 -0
- package/src/component/dashboard/stat_tile.ts +46 -0
- package/src/component/payment/sca_confirm.ts +15 -18
- package/src/component/table/action_params_dialog.html +40 -0
- package/src/component/table/action_params_dialog.ts +99 -0
- package/src/index.ts +1 -0
- package/src/model/appdata.ts +17 -4
- package/src/service/base_rest.service.ts +18 -0
- package/src/service/sca_confirmer.ts +8 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
20
20
|
| **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
|
|
21
21
|
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
|
|
22
22
|
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
23
|
-
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
23
|
+
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `StatTileComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
24
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
25
25
|
| **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
|
|
26
26
|
| **Notifications** | `NotificationCenterComponent`, `NotificationInboxService` |
|
|
@@ -28,7 +28,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
28
28
|
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
29
29
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
30
30
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
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
|
+
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, 2FA types, etc. |
|
|
32
32
|
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
33
33
|
| **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
|
|
34
34
|
|
|
@@ -568,6 +568,10 @@ A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes
|
|
|
568
568
|
|
|
569
569
|
> **Response-shape contract.** The generic redirect-action path reads a bare `url` because it's polymorphic — one dispatch for checkout / portal / export / etc., where the action's identity lives in the metadata, not the payload. keel's `WrapTableAction` enforces no shape; your inner handler must return `{ url }`. The *typed* billing endpoints keep specific names instead (`createCheckout()` → `checkoutUrl`, `createPortalSession()` → `portalUrl`). So the same portal concept has two response shapes depending on the path: wire it as `<sail-portal-button>` (typed → `portalUrl`) **or** as a `table_action` redirect (generic → `url`), not both.
|
|
570
570
|
|
|
571
|
+
### Action parameters (v1.1.25)
|
|
572
|
+
|
|
573
|
+
An action with `parameters` (keel v1.2.75 `table_action_parameter`) opens a dialog before posting: one field per parameter, typed by `dataType` (`boolean` checkbox, numeric, `date`, `timestamp`, otherwise text), or a dropdown when `lookupTable` names a table in the client cache (a `foreign_key_lookup` dropdown table); a numeric `dataType` still posts the chosen key as a number. The dialog shows `confirmMessage` in place of the native prompt and posts `{...primaryKeyValues(record), ...values}`; empty optional values are left out. An action without parameters behaves as before.
|
|
574
|
+
|
|
571
575
|
### Status chip (v1.0.1)
|
|
572
576
|
|
|
573
577
|
`<sail-status-chip>` resolves a code to its caption from the client-cache `constant_value` dictionary — no per-app `@switch`. It defaults to the `SUBSCRIPTION_STATUS` domain (so `T` trialing / `X` past-due render with no app code) and works for any constant domain via `[domain]`. The code is exposed as `[attr.data-status]` so the app can colour the chip:
|
|
@@ -772,14 +776,20 @@ being summed into a misleading total.
|
|
|
772
776
|
The agency URL defaults use `/api/v1/agency/*`. Apps mounting Keel elsewhere
|
|
773
777
|
must override the `RestURL.agency*URL` values with `configureRestUrls()`.
|
|
774
778
|
|
|
775
|
-
## Off-session SCA / 3DS confirmation (v1.0.x)
|
|
779
|
+
## Off-session SCA / 3DS confirmation (v1.0.x; refusals v1.1.24)
|
|
776
780
|
|
|
777
|
-
When an app charges a rider's **vaulted** card off-session (keel `payment.ChargeClient` / `StripeChargeClient`), the
|
|
781
|
+
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:
|
|
778
782
|
|
|
779
|
-
|
|
783
|
+
| `status` | What happened | Client operation |
|
|
784
|
+
|---|---|---|
|
|
785
|
+
| `requires_action` | The PaymentIntent carries a `next_action` to run. | Run it — `actionUrl` redirect, or `confirm(clientSecret)`. |
|
|
786
|
+
| `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)`. |
|
|
780
787
|
|
|
781
|
-
-
|
|
782
|
-
|
|
788
|
+
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:
|
|
789
|
+
|
|
790
|
+
- `authentication_required` → `SCA_CONFIRMER.confirmWithMethod`; else
|
|
791
|
+
- `actionUrl` present → redirect (provider-hosted challenge — no SDK needed); else
|
|
792
|
+
- inline `clientSecret` → `SCA_CONFIRMER.confirm`.
|
|
783
793
|
|
|
784
794
|
### Backend contract (app-owned)
|
|
785
795
|
|
|
@@ -788,6 +798,7 @@ Off-session charging is a worker/handler concern, so the **app** owns the charge
|
|
|
788
798
|
```jsonc
|
|
789
799
|
// POST /api/<app>/charge → 200
|
|
790
800
|
{ "status": "requires_action", "clientSecret": "pi_..._secret_...", "actionUrl": "" }
|
|
801
|
+
// or { "status": "authentication_required", "clientSecret": "pi_..._secret_...", "paymentMethodId": "pm_..." }
|
|
791
802
|
// or { "status": "succeeded", "providerChargeId": "pi_..." }
|
|
792
803
|
// or { "status": "failed", "error": "card_declined" }
|
|
793
804
|
```
|
|
@@ -811,6 +822,13 @@ export class StripeScaConfirmer implements ScaConfirmer {
|
|
|
811
822
|
const { error } = await stripe.handleNextAction({ clientSecret });
|
|
812
823
|
return error ? { outcome: 'failed', error: error.message } : { outcome: 'succeeded' };
|
|
813
824
|
}
|
|
825
|
+
|
|
826
|
+
async confirmWithMethod(clientSecret: string, paymentMethodId: string): Promise<ScaResult> {
|
|
827
|
+
const stripe = await loadStripe(/* publishable key from backend cache */);
|
|
828
|
+
if (!stripe) return { outcome: 'failed', error: 'Stripe.js failed to load' };
|
|
829
|
+
const { error } = await stripe.confirmCardPayment(clientSecret, { payment_method: paymentMethodId });
|
|
830
|
+
return error ? { outcome: 'failed', error: error.message } : { outcome: 'succeeded' };
|
|
831
|
+
}
|
|
814
832
|
}
|
|
815
833
|
```
|
|
816
834
|
|
|
@@ -834,7 +852,7 @@ providers: [
|
|
|
834
852
|
</sail-sca-confirm>
|
|
835
853
|
```
|
|
836
854
|
|
|
837
|
-
`<sail-sca-confirm>` renders nothing for `succeeded`, the decline message for `failed`, and a confirm button for
|
|
855
|
+
`<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.
|
|
838
856
|
|
|
839
857
|
## Consent capture
|
|
840
858
|
|
|
@@ -956,6 +974,7 @@ Horizontal building blocks for an entitlement-aware dashboard. All are presentat
|
|
|
956
974
|
- **`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.
|
|
957
975
|
- **`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`.
|
|
958
976
|
- **`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.
|
|
977
|
+
- **`StatTileComponent`** (`sail-stat-tile`) — one number: `label`, `value`, optional `target`, `progress` (0..1, a `role="progressbar"`), `delta` with `deltaGood` (`true | false | null`, the `.good` / `.bad` hook) and `trend` (`number[]`, a decorative sparkline, hidden under two points). Values are preformatted strings; the tile does no formatting. Styles: `.stat-tile` / `-label` / `-value` / `-target` / `-delta` / `-progress` / `-progress-fill` / `-trend`.
|
|
959
978
|
- **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
|
|
960
979
|
- **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
|
|
961
980
|
- **`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.
|
|
@@ -973,6 +992,13 @@ Analytics clients that need this distinction can call the protected
|
|
|
973
992
|
`{ rows, source: { status, id } }`, populated from `X-Data-Source-Status` and
|
|
974
993
|
`X-Data-Source`; the existing `analytic<T>()` remains `Observable<T[]>`.
|
|
975
994
|
|
|
995
|
+
`sourceState(result, sourceName, connectLabel?)` maps an analytic result to
|
|
996
|
+
`{ state, message, cta }` for `[state]`, `[noSourceMessage]`, and `[connectLabel]`.
|
|
997
|
+
`NOT_CONNECTED` offers Connect; `NOT_COLLECTED` explains that collection is not
|
|
998
|
+
live and leaves the CTA empty; `READY` or no header yields `ready` / `empty` from
|
|
999
|
+
the rows. The caller supplies the source name and handles
|
|
1000
|
+
the click route.
|
|
1001
|
+
|
|
976
1002
|
When the app and its API are on different origins the browser hides both headers
|
|
977
1003
|
unless the backend sends
|
|
978
1004
|
`Access-Control-Expose-Headers: X-Data-Source-Status, X-Data-Source`; without it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.25",
|
|
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": {
|
|
@@ -8,6 +8,7 @@ import { titleCase } from "../../util/text";
|
|
|
8
8
|
import { errorDetail } from "../../util/errors";
|
|
9
9
|
import { MatDialog } from "@angular/material/dialog";
|
|
10
10
|
import { RevealDialog } from "../table/reveal_dialog";
|
|
11
|
+
import { ActionParamsDialog } from "../table/action_params_dialog";
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
@@ -20,7 +21,7 @@ const ISO_PREFIX = /^\d{4}-\d{2}-\d{2}T/;
|
|
|
20
21
|
export abstract class BaseTable {
|
|
21
22
|
protected readonly cacheService = inject(BaseAuthService);
|
|
22
23
|
protected readonly backendService = inject(BackendService);
|
|
23
|
-
//
|
|
24
|
+
// Action dialogs are rare — resolve MatDialog lazily so every BaseTable subclass doesn't eagerly depend on it.
|
|
24
25
|
readonly #injector = inject(Injector);
|
|
25
26
|
protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
26
27
|
protected readonly dialogWidth: string = '400px';
|
|
@@ -127,9 +128,10 @@ export abstract class BaseTable {
|
|
|
127
128
|
* 2. Subclass-specific pre-flight guard via `beforeExecuteAction()` —
|
|
128
129
|
* returns false to abort silently (the hook has already shown its
|
|
129
130
|
* own alert).
|
|
130
|
-
* 3.
|
|
131
|
+
* 3. A parameter dialog when the action declares `parameters`
|
|
132
|
+
* (it shows `confirmMessage`), otherwise the confirm prompt
|
|
131
133
|
* 4. POST `{}` for table-level actions or `primaryKeyValues(record)`
|
|
132
|
-
* for record-specific actions
|
|
134
|
+
* for record-specific actions, merged with the parameter values
|
|
133
135
|
* 5. On success, dispatch `onActionSuccess()` so subclasses can refresh
|
|
134
136
|
* their views; on error, log and alert.
|
|
135
137
|
*
|
|
@@ -141,8 +143,21 @@ export abstract class BaseTable {
|
|
|
141
143
|
return;
|
|
142
144
|
}
|
|
143
145
|
if (!this.beforeExecuteAction(action, record)) return;
|
|
146
|
+
const key = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
147
|
+
if (action.parameters?.length) {
|
|
148
|
+
this.#injector.get(MatDialog)
|
|
149
|
+
.open<ActionParamsDialog, TableAction, Record<string, unknown>>(ActionParamsDialog, { data: action, width: this.dialogWidth })
|
|
150
|
+
.afterClosed()
|
|
151
|
+
.subscribe((values) => {
|
|
152
|
+
if (values) this.postAction(action, {...key, ...values}, record);
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
144
156
|
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
145
|
-
|
|
157
|
+
this.postAction(action, key, record);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private postAction(action: TableAction, body: Record<string, unknown>, record?: Record<string, unknown>): void {
|
|
146
161
|
this.backendService.executeAction<Record<string, unknown>>(action.method, body).subscribe({
|
|
147
162
|
next: (resp) => {
|
|
148
163
|
const url = resp?.['url'];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div class="stat-tile">
|
|
2
|
+
<p class="stat-tile-label">{{ label() }}</p>
|
|
3
|
+
<p class="stat-tile-value">
|
|
4
|
+
{{ value() }}
|
|
5
|
+
@if (target()) { <span class="stat-tile-target">/ {{ target() }}</span> }
|
|
6
|
+
</p>
|
|
7
|
+
@if (delta()) {
|
|
8
|
+
<p class="stat-tile-delta" [class.good]="deltaGood() === true" [class.bad]="deltaGood() === false">
|
|
9
|
+
{{ delta() }}
|
|
10
|
+
@if (deltaGood() !== null) { <span class="sr-only">{{ deltaGood() ? '(improving)' : '(worsening)' }}</span> }
|
|
11
|
+
</p>
|
|
12
|
+
}
|
|
13
|
+
@let percent = progressPercent();
|
|
14
|
+
@if (percent !== null) {
|
|
15
|
+
<div class="stat-tile-progress" role="progressbar"
|
|
16
|
+
[attr.aria-label]="label()"
|
|
17
|
+
aria-valuemin="0"
|
|
18
|
+
aria-valuemax="100"
|
|
19
|
+
[attr.aria-valuenow]="percent"
|
|
20
|
+
[attr.aria-valuetext]="valueText()">
|
|
21
|
+
<div class="stat-tile-progress-fill" [style.width.%]="percent"></div>
|
|
22
|
+
</div>
|
|
23
|
+
}
|
|
24
|
+
@if (sparkline()) {
|
|
25
|
+
<svg class="stat-tile-trend" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
|
26
|
+
<polyline [attr.points]="sparkline()" fill="none" stroke="currentColor" vector-effect="non-scaling-stroke" />
|
|
27
|
+
</svg>
|
|
28
|
+
}
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One dashboard number with an optional target, progress, delta and trend.
|
|
5
|
+
* Values arrive preformatted: the caller knows whether a number is money, a
|
|
6
|
+
* percentage or a count.
|
|
7
|
+
*/
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'sail-stat-tile',
|
|
10
|
+
templateUrl: './stat_tile.html',
|
|
11
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
12
|
+
encapsulation: ViewEncapsulation.None,
|
|
13
|
+
})
|
|
14
|
+
export class StatTileComponent {
|
|
15
|
+
readonly label = input.required<string>();
|
|
16
|
+
readonly value = input.required<string>();
|
|
17
|
+
readonly target = input<string>('');
|
|
18
|
+
/** 0..1; clamped. */
|
|
19
|
+
readonly progress = input<number | null>(null);
|
|
20
|
+
readonly delta = input<string>('');
|
|
21
|
+
/** true / false colours the delta as an improvement / regression; null leaves it neutral. */
|
|
22
|
+
readonly deltaGood = input<boolean | null>(null);
|
|
23
|
+
readonly trend = input<number[]>([]);
|
|
24
|
+
|
|
25
|
+
readonly progressPercent = computed(() => {
|
|
26
|
+
const p = this.progress();
|
|
27
|
+
return p == null || isNaN(p) ? null : Math.round(Math.min(1, Math.max(0, p)) * 100);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
readonly valueText = computed(() => this.target() ? `${this.value()} of ${this.target()}` : this.value());
|
|
31
|
+
|
|
32
|
+
/** SVG polyline points in a 100×24 box; '' under two points. */
|
|
33
|
+
readonly sparkline = computed(() => {
|
|
34
|
+
const points = this.trend().filter((n) => Number.isFinite(n));
|
|
35
|
+
if (points.length < 2) return '';
|
|
36
|
+
const min = Math.min(...points);
|
|
37
|
+
const range = Math.max(...points) - min;
|
|
38
|
+
return points
|
|
39
|
+
.map((n, i) => {
|
|
40
|
+
const x = (i / (points.length - 1)) * 100;
|
|
41
|
+
const y = range === 0 ? 12 : 22 - ((n - min) / range) * 20;
|
|
42
|
+
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
43
|
+
})
|
|
44
|
+
.join(' ');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -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(() =>
|
|
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
|
-
|
|
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 =
|
|
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();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<h2 mat-dialog-title>{{ action.caption }}</h2>
|
|
2
|
+
<form [formGroup]="form" (ngSubmit)="submit()">
|
|
3
|
+
<mat-dialog-content>
|
|
4
|
+
@if (action.confirmMessage) {
|
|
5
|
+
<p>{{ action.confirmMessage }}</p>
|
|
6
|
+
}
|
|
7
|
+
@for (f of fields; track f.param.name) {
|
|
8
|
+
@switch (f.kind) {
|
|
9
|
+
@case ('checkbox') {
|
|
10
|
+
<mat-checkbox [formControlName]="f.param.name">{{ f.param.caption }}</mat-checkbox>
|
|
11
|
+
}
|
|
12
|
+
@case ('select') {
|
|
13
|
+
<mat-form-field appearance="outline">
|
|
14
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
15
|
+
<mat-select [formControlName]="f.param.name" [required]="f.param.required">
|
|
16
|
+
@if (!f.param.required) {
|
|
17
|
+
<mat-option [value]="''">None</mat-option>
|
|
18
|
+
}
|
|
19
|
+
@for (opt of f.options; track opt.Value) {
|
|
20
|
+
<mat-option [value]="opt.Value">{{ opt.Caption }}</mat-option>
|
|
21
|
+
}
|
|
22
|
+
</mat-select>
|
|
23
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
24
|
+
</mat-form-field>
|
|
25
|
+
}
|
|
26
|
+
@default {
|
|
27
|
+
<mat-form-field appearance="outline">
|
|
28
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
29
|
+
<input matInput [type]="f.kind" [formControlName]="f.param.name" [required]="f.param.required">
|
|
30
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
31
|
+
</mat-form-field>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
</mat-dialog-content>
|
|
36
|
+
<mat-dialog-actions align="end">
|
|
37
|
+
<button matButton type="button" mat-dialog-close>Cancel</button>
|
|
38
|
+
<button matButton="filled" type="submit">{{ action.caption }}</button>
|
|
39
|
+
</mat-dialog-actions>
|
|
40
|
+
</form>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
|
2
|
+
import { FormControl, FormGroup, ReactiveFormsModule, ValidatorFn, Validators } from "@angular/forms";
|
|
3
|
+
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog";
|
|
4
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
5
|
+
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
6
|
+
import { MatFormFieldModule } from "@angular/material/form-field";
|
|
7
|
+
import { MatInputModule } from "@angular/material/input";
|
|
8
|
+
import { MatSelectModule } from "@angular/material/select";
|
|
9
|
+
import { TableAction, TableActionParameter } from "../../model/appdata";
|
|
10
|
+
import { ConstantValue } from "../../model/common";
|
|
11
|
+
import { BaseAuthService } from "../../service/auth.service";
|
|
12
|
+
|
|
13
|
+
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'text';
|
|
14
|
+
|
|
15
|
+
interface ParamField {
|
|
16
|
+
param: TableActionParameter;
|
|
17
|
+
kind: ParamKind | 'select';
|
|
18
|
+
/** From dataType; a lookup dropdown still posts a number when the column is numeric. */
|
|
19
|
+
wireKind: ParamKind;
|
|
20
|
+
options?: ConstantValue[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Collects a table action's declared parameters; closes with the typed values, or undefined on cancel. */
|
|
24
|
+
@Component({
|
|
25
|
+
selector: "sail-action-params-dialog",
|
|
26
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
27
|
+
imports: [
|
|
28
|
+
ReactiveFormsModule,
|
|
29
|
+
MatDialogModule,
|
|
30
|
+
MatButtonModule,
|
|
31
|
+
MatCheckboxModule,
|
|
32
|
+
MatFormFieldModule,
|
|
33
|
+
MatInputModule,
|
|
34
|
+
MatSelectModule,
|
|
35
|
+
],
|
|
36
|
+
templateUrl: "./action_params_dialog.html",
|
|
37
|
+
})
|
|
38
|
+
export class ActionParamsDialog {
|
|
39
|
+
private readonly auth = inject(BaseAuthService);
|
|
40
|
+
private readonly dialogRef = inject(MatDialogRef<ActionParamsDialog, Record<string, unknown>>);
|
|
41
|
+
readonly action = inject<TableAction>(MAT_DIALOG_DATA);
|
|
42
|
+
|
|
43
|
+
readonly fields: ParamField[] = (this.action.parameters ?? []).map((param) => {
|
|
44
|
+
const options = param.lookupTable ? this.auth.getTableValues(param.lookupTable) : undefined;
|
|
45
|
+
const wireKind = kindOf(param.dataType);
|
|
46
|
+
return { param, kind: options ? 'select' : wireKind, wireKind, options };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
readonly form = new FormGroup(Object.fromEntries(this.fields.map((f) => {
|
|
50
|
+
const validators: ValidatorFn[] = f.param.required && f.kind !== 'checkbox' ? [Validators.required] : [];
|
|
51
|
+
const initial: unknown = f.kind === 'checkbox' ? false : '';
|
|
52
|
+
return [f.param.name, new FormControl(initial, validators)];
|
|
53
|
+
})));
|
|
54
|
+
|
|
55
|
+
submit(): void {
|
|
56
|
+
if (this.form.invalid) {
|
|
57
|
+
this.form.markAllAsTouched();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const values: Record<string, unknown> = {};
|
|
61
|
+
for (const f of this.fields) {
|
|
62
|
+
const raw = this.form.controls[f.param.name].value;
|
|
63
|
+
if (raw === '' || raw == null) continue;
|
|
64
|
+
values[f.param.name] = toWire(f.wireKind, raw);
|
|
65
|
+
}
|
|
66
|
+
this.dialogRef.close(values);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function kindOf(dataType: string): ParamKind {
|
|
71
|
+
switch ((dataType || '').toLowerCase()) {
|
|
72
|
+
case 'boolean':
|
|
73
|
+
return 'checkbox';
|
|
74
|
+
case 'int':
|
|
75
|
+
case 'integer':
|
|
76
|
+
case 'number':
|
|
77
|
+
case 'numeric':
|
|
78
|
+
case 'float':
|
|
79
|
+
case 'decimal':
|
|
80
|
+
return 'number';
|
|
81
|
+
case 'date':
|
|
82
|
+
return 'date';
|
|
83
|
+
case 'datetime':
|
|
84
|
+
case 'timestamp':
|
|
85
|
+
return 'datetime-local';
|
|
86
|
+
default:
|
|
87
|
+
return 'text';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toWire(kind: ParamKind, raw: unknown): unknown {
|
|
92
|
+
if (kind === 'number') return Number(raw);
|
|
93
|
+
// datetime-local holds local wall-clock time; keel expects a UTC instant.
|
|
94
|
+
if (kind === 'datetime-local') {
|
|
95
|
+
const d = new Date(String(raw));
|
|
96
|
+
return isNaN(d.getTime()) ? raw : d.toISOString();
|
|
97
|
+
}
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './model/decorator';
|
|
|
10
10
|
export * from './component/abstract/base_table';
|
|
11
11
|
export * from './component/dashboard/dashboard_shell';
|
|
12
12
|
export * from './component/dashboard/data_card';
|
|
13
|
+
export * from './component/dashboard/stat_tile';
|
|
13
14
|
export * from './component/dashboard/locked_overlay';
|
|
14
15
|
export * from './component/dashboard/no_source_overlay';
|
|
15
16
|
export * from './component/dashboard/action_center';
|
package/src/model/appdata.ts
CHANGED
|
@@ -79,6 +79,16 @@ export interface TableAction {
|
|
|
79
79
|
authorityObject: string; // uppercased table_name — for canExecute()
|
|
80
80
|
authorityCheck: string; // uppercased action_name — for canExecute()
|
|
81
81
|
kind?: 'P' | 'R' | 'V'; // constant_value codes: P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
|
|
82
|
+
parameters?: TableActionParameter[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A value collected in a dialog before the action posts; merged into the body beside the record key. */
|
|
86
|
+
export interface TableActionParameter {
|
|
87
|
+
name: string;
|
|
88
|
+
caption: string;
|
|
89
|
+
dataType: string;
|
|
90
|
+
required: boolean;
|
|
91
|
+
lookupTable?: string; // offer this table's cached rows as choices
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export interface ForeignKey {
|
|
@@ -325,18 +335,21 @@ export interface UsageMeter {
|
|
|
325
335
|
// returns from its own charge endpoint, mirroring keel `payment.ChargeResult`.
|
|
326
336
|
// 'requires_action' means the rider must complete an SCA / 3DS confirmation
|
|
327
337
|
// (drive it with `<sail-sca-confirm>`).
|
|
328
|
-
|
|
338
|
+
// 'authentication_required' is an off-session refusal: no next_action to run —
|
|
339
|
+
// re-confirm the same intent on-session with paymentMethodId.
|
|
340
|
+
export type ChargeStatus = 'succeeded' | 'requires_action' | 'authentication_required' | 'failed';
|
|
329
341
|
|
|
330
342
|
export interface ChargeResult {
|
|
331
343
|
status: ChargeStatus;
|
|
332
344
|
providerChargeId?: string; // PaymentIntent / charge id (pi_xxx)
|
|
333
|
-
// Provider client secret
|
|
334
|
-
// SCA challenge must be confirmed inline (provider SDK). Mirrors keel
|
|
335
|
-
// ChargeResult.ClientSecret.
|
|
345
|
+
// Provider client secret for inline confirmation or on-session re-confirmation.
|
|
336
346
|
clientSecret?: string;
|
|
337
347
|
// Provider-hosted redirect URL when one exists (often empty for off-session
|
|
338
348
|
// 3DS). When present it's the simplest, provider-agnostic confirmation path.
|
|
339
349
|
actionUrl?: string;
|
|
350
|
+
// The refused stored method, to re-confirm with when
|
|
351
|
+
// status === 'authentication_required'. Mirrors keel ChargeResult.PaymentMethodID.
|
|
352
|
+
paymentMethodId?: string;
|
|
340
353
|
error?: string; // decline / error message when status === 'failed'
|
|
341
354
|
}
|
|
342
355
|
|
|
@@ -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
|
+
}
|
|
@@ -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`
|
|
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');
|