@nauticana/sail 1.1.25 → 1.1.27
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 +68 -12
- package/package.json +2 -1
- package/src/component/billing/portal_button.ts +6 -1
- package/src/component/document/document_review_queue.html +24 -0
- package/src/component/document/document_review_queue.ts +67 -0
- package/src/component/document/document_upload.html +31 -0
- package/src/component/document/document_upload.ts +113 -0
- package/src/component/document/partner_documents.html +33 -0
- package/src/component/document/partner_documents.ts +92 -0
- package/src/component/login/login_component.ts +3 -3
- package/src/component/payment/user_payment_methods.html +1 -1
- package/src/component/payment/user_payment_methods.ts +6 -6
- package/src/component/table/action_params_dialog.html +10 -0
- package/src/component/table/action_params_dialog.ts +9 -1
- package/src/config.ts +5 -5
- package/src/index.ts +5 -0
- package/src/model/agency.ts +8 -0
- package/src/model/document.ts +36 -0
- package/src/service/agency.service.ts +7 -0
- package/src/service/auth.service.ts +55 -21
- package/src/service/billing.service.ts +3 -3
- package/src/service/label.service.ts +22 -0
- package/src/service/notification_inbox.service.ts +54 -15
- package/src/service/partner_document.service.ts +111 -0
- package/src/service/realtime.service.ts +5 -3
- package/src/service/rest_service.ts +11 -1
- package/src/service/rest_url.ts +8 -4
- package/src/service/user_payment_method.service.ts +8 -14
- package/src/util/errors.ts +5 -0
package/README.md
CHANGED
|
@@ -24,11 +24,12 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
24
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
25
25
|
| **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
|
|
26
26
|
| **Notifications** | `NotificationCenterComponent`, `NotificationInboxService` |
|
|
27
|
+
| **Documents** | `DocumentUploadComponent`, `PartnerDocumentsComponent`, `DocumentReviewQueueComponent`, `PartnerDocumentService` |
|
|
27
28
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
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
|
+
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `PartnerDocumentService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
29
30
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
30
31
|
| **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`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, 2FA types, etc. |
|
|
32
|
+
| **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`, `PartnerDocument`/`DocumentUploadRequest`, 2FA types, etc. |
|
|
32
33
|
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
33
34
|
| **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
|
|
34
35
|
|
|
@@ -269,7 +270,7 @@ this.labels.loadLabels('ride_type').subscribe(() => {
|
|
|
269
270
|
});
|
|
270
271
|
```
|
|
271
272
|
|
|
272
|
-
Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried.
|
|
273
|
+
Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried. `getLabel` falls back to the raw key; `requireLabel` throws instead, for values that must not render as a raw code. `options(domain)` returns every `{code, caption}` of a loaded domain, sorted by caption, for picklists.
|
|
273
274
|
|
|
274
275
|
## Status-filtered API errors
|
|
275
276
|
|
|
@@ -307,11 +308,24 @@ interface SailGuiConfig {
|
|
|
307
308
|
defaultPolicyLanguage?: string; // ISO 639-1 fallback language
|
|
308
309
|
accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
|
|
309
310
|
passwordPolicyUrl?: string; // Public route serving keel's password policy (see below)
|
|
311
|
+
oauthServerUrl?: string; // Base URL of keel's OAuth authorization server (see below)
|
|
312
|
+
allowedReturnHosts?: string[]; // Hosts a post-login ?return= may name
|
|
310
313
|
}
|
|
311
314
|
```
|
|
312
315
|
|
|
313
316
|
### Password-policy validation
|
|
314
317
|
|
|
318
|
+
**OAuth sign-in hand-off (v1.1.27, keel v1.2.89).** When the login page is opened with
|
|
319
|
+
`?return=<authorize URL>` and both `oauthServerUrl` and `allowedReturnHosts` are set,
|
|
320
|
+
`completeLogin` posts `{"return": <raw value>}` with the bearer JWT to
|
|
321
|
+
`{oauthServerUrl}/oauth/session/handoff` and then leaves the SPA with a full navigation to the
|
|
322
|
+
`redirect` keel answers. keel sets the authorization server's own HttpOnly cookie while
|
|
323
|
+
redeeming that URL; sail does not opt into cross-origin cookies or send a CSRF token. A
|
|
324
|
+
`return` whose host is not in `allowedReturnHosts`, or a `redirect` outside `oauthServerUrl`'s
|
|
325
|
+
origin, is not followed. Failures set `BaseAuthService.sessionHandoffError` (rendered by
|
|
326
|
+
`<sail-login>`) and never redirect: 400 means keel rejected the return URL, 401 means the user
|
|
327
|
+
signs in again to retry. The authorization server must allow the SPA origin by CORS.
|
|
328
|
+
|
|
315
329
|
Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
|
|
316
330
|
(e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
|
|
317
331
|
once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
|
|
@@ -400,7 +414,7 @@ Every keel login response (keel v1.2.57+) carries `refreshToken` beside `token`;
|
|
|
400
414
|
|
|
401
415
|
### Realtime
|
|
402
416
|
|
|
403
|
-
`RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
|
|
417
|
+
`RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user, including keel's `{op: "notification"}` frames; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
|
|
404
418
|
|
|
405
419
|
## Billing
|
|
406
420
|
|
|
@@ -433,6 +447,11 @@ export class PricingPage {
|
|
|
433
447
|
| `createPortalSession()` | `POST /api/billing/portal` | `Observable<PortalResponse>` |
|
|
434
448
|
| `listUsage()` | `GET /api/billing/usage` | `Observable<UsageMeter[]>` |
|
|
435
449
|
|
|
450
|
+
`cancelSubscription()`, `changePlan()` and `createPortalSession()` answer 403 (keel v1.2.89)
|
|
451
|
+
unless the user holds `PARTNER_PLAN_SUBSCRIPTION` `CANCEL` / `CHANGE` / `PORTAL`, seeded for
|
|
452
|
+
`PARTNER_ADMIN`. Test a failure with `isPermissionDenied(err)` and report it as not permitted;
|
|
453
|
+
`<sail-portal-button>` does.
|
|
454
|
+
|
|
436
455
|
### Components
|
|
437
456
|
|
|
438
457
|
**Plan picker** — pure presentational, no API calls:
|
|
@@ -570,7 +589,7 @@ A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes
|
|
|
570
589
|
|
|
571
590
|
### Action parameters (v1.1.25)
|
|
572
591
|
|
|
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.
|
|
592
|
+
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`, `file`, 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. A `file` parameter turns the post into `multipart/form-data`, so a table-level `upload` action whose parameters are `file`, `document_type` (lookup `document_type`), `title`, … posts straight to keel's `DocumentHandler.Upload`. An action without parameters behaves as before.
|
|
574
593
|
|
|
575
594
|
### Status chip (v1.0.1)
|
|
576
595
|
|
|
@@ -773,6 +792,15 @@ currency exponent reported by `Intl.NumberFormat`, so zero- and three-decimal
|
|
|
773
792
|
currencies render correctly; earnings stay separated by currency rather than
|
|
774
793
|
being summed into a misleading total.
|
|
775
794
|
|
|
795
|
+
**Delegation roles (v1.1.27, keel v1.2.89).** `AgencyDelegation.roles` (always present) and
|
|
796
|
+
`AgencyClient.roles` carry `AgencyRoleGrant { role, expiresAt? }`; no `expiresAt` is
|
|
797
|
+
open-ended, and a delegation without roles grants no access.
|
|
798
|
+
`AgencyService.setDelegationRoles(roles, clientPartnerId = 0)` replaces the whole set — an
|
|
799
|
+
empty list removes access without revoking the delegation. Role codes and captions come from
|
|
800
|
+
the `agency_delegation_role` constant catalogue (`LabelService.options('agency_delegation_role')`);
|
|
801
|
+
do not hardcode them. keel answers 422 for an unknown, duplicate or blank role, more than 32
|
|
802
|
+
roles, or an expiry not in the future, and 403 without `AGENCY_DELEGATION` / `SET_ROLE`.
|
|
803
|
+
|
|
776
804
|
The agency URL defaults use `/api/v1/agency/*`. Apps mounting Keel elsewhere
|
|
777
805
|
must override the `RestURL.agency*URL` values with `configureRestUrls()`.
|
|
778
806
|
|
|
@@ -1080,8 +1108,10 @@ readonly unread = inject(NotificationInboxService).unreadCount; // Signal<numb
|
|
|
1080
1108
|
| `load(limit)` / `loadMore(limit)` | Default 20; keel caps a page at 100 and the client clamps to match. |
|
|
1081
1109
|
|
|
1082
1110
|
Message ids are server-side `BIGINT`s serialized as strings. Both mark-read routes
|
|
1083
|
-
return `204`, so the service updates its cache locally.
|
|
1084
|
-
|
|
1111
|
+
return `204`, so the service updates its cache locally. After the first successful `load()`, each
|
|
1112
|
+
`{op: "notification"}` frame from keel's `realtime.UserDispatcher` (v1.2.79+) on a
|
|
1113
|
+
connected `RealtimeService` re-fetches the newest page into the cache. Call `reset()`
|
|
1114
|
+
on logout.
|
|
1085
1115
|
|
|
1086
1116
|
```html
|
|
1087
1117
|
<sail-notification-center
|
|
@@ -1105,6 +1135,31 @@ Ships no CSS — style
|
|
|
1105
1135
|
`.notification-item`, `.notification-item--unread` and
|
|
1106
1136
|
`.notification-item__{select,static,title,body,time,read}`.
|
|
1107
1137
|
|
|
1138
|
+
## Partner documents (v1.1.26)
|
|
1139
|
+
|
|
1140
|
+
`PartnerDocumentService` covers generic `partner_document` reads plus keel's
|
|
1141
|
+
`handler.DocumentHandler` (keel **v1.2.79+**): `list(userId?)`, `pending()`,
|
|
1142
|
+
`upload(file, request)` (progress events), `previewUrl(id)` and `review(id, approve,
|
|
1143
|
+
notes)`. The defaults assume the handler is mounted at
|
|
1144
|
+
`/api/v1/partner_document/{upload,preview,review}`; override the three
|
|
1145
|
+
`partnerDocument*URL` values otherwise. Authorization stays in keel's hooks.
|
|
1146
|
+
|
|
1147
|
+
```html
|
|
1148
|
+
<sail-document-upload documentType="driver_license" [maxBytes]="10485760"
|
|
1149
|
+
[canUpload]="attested()" (uploaded)="onUploaded($event)" (failed)="onFailed($event)">
|
|
1150
|
+
<!-- app-specific attestations -->
|
|
1151
|
+
</sail-document-upload>
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
Images preview inline, PDFs open in a new tab. `(failed)` hands over the raw error;
|
|
1155
|
+
branch on `errorCode(err)` for keel's `document_*` codes. Status captions come from
|
|
1156
|
+
the `partner_document_status` domain through `LabelService`.
|
|
1157
|
+
|
|
1158
|
+
`<sail-partner-documents>` shows the latest version per type and offers replacement
|
|
1159
|
+
uploads for rejected or expiring documents. `<sail-document-review-queue>` lists
|
|
1160
|
+
pending rows with an inline preview and approve/reject controls. Both rely on the
|
|
1161
|
+
app's generic `partner_document` read grants and the handler authorization hooks.
|
|
1162
|
+
|
|
1108
1163
|
## Grouped review actions and diffs (v1.1.19)
|
|
1109
1164
|
|
|
1110
1165
|
`GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
|
|
@@ -1481,8 +1536,8 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
|
|
|
1481
1536
|
| `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
|
|
1482
1537
|
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
|
|
1483
1538
|
| `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
|
|
1484
|
-
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list /
|
|
1485
|
-
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default +
|
|
1539
|
+
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / remove / set-default API client |
|
|
1540
|
+
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + remove |
|
|
1486
1541
|
| `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `UserPaymentMethod`, `DEFAULT_COUNTRY_PROFILES` | Model types / defaults |
|
|
1487
1542
|
|
|
1488
1543
|
### 2. New keel endpoints
|
|
@@ -1497,10 +1552,11 @@ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
|
|
|
1497
1552
|
| `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
|
|
1498
1553
|
| `/api/v1/payout/bank/replace` | POST | Supersede the active bank-info version and insert its replacement atomically; the app must wire the handler's `SealTaxID` hook |
|
|
1499
1554
|
| `/api/v1/payout/beneficiary/register` | POST | Create and link a provider beneficiary from provider-collected details |
|
|
1500
|
-
| `/api/v1/
|
|
1555
|
+
| `/api/v1/user_payment_method/set_default` | POST | `{id}` — sets one row as default, clears the rest |
|
|
1556
|
+
| `/api/v1/user_payment_method/remove` | POST | `{id}` — detaches the method at the provider, then deletes the row (keel v1.2.79) |
|
|
1501
1557
|
| `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
|
|
1502
1558
|
|
|
1503
|
-
`list`
|
|
1559
|
+
`list` for `user_payment_method` is keel's generic REST CRUD, auto-scoped to the caller. keel grants no generic DELETE on the table, so removal goes through `remove`.
|
|
1504
1560
|
|
|
1505
1561
|
### 3. Payout onboarding wiring
|
|
1506
1562
|
|
|
@@ -1539,7 +1595,7 @@ For apps that operate on a single keel partner, the reuse picker stays empty and
|
|
|
1539
1595
|
[title]="'Payment Methods'"
|
|
1540
1596
|
(addClicked)="goToSetupIntent()"
|
|
1541
1597
|
(defaultChanged)="onDefaultChanged($event)"
|
|
1542
|
-
(
|
|
1598
|
+
(removed)="onRemoved($event)">
|
|
1543
1599
|
</sail-user-payment-methods>
|
|
1544
1600
|
```
|
|
1545
1601
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
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": {
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@angular/core": "^21.0.0",
|
|
53
53
|
"@angular/forms": "^21.0.0",
|
|
54
54
|
"@angular/material": "^21.0.0",
|
|
55
|
+
"@angular/platform-browser": "^21.0.0",
|
|
55
56
|
"@angular/router": "^21.0.0",
|
|
56
57
|
"class-validator": "^0.15.1",
|
|
57
58
|
"rxjs": "~7.8.0"
|
|
@@ -2,12 +2,14 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, input }
|
|
|
2
2
|
import { MatButtonModule } from '@angular/material/button';
|
|
3
3
|
import { BaseAsync } from '../abstract/base_async';
|
|
4
4
|
import { BillingService } from '../../service/billing.service';
|
|
5
|
+
import { isPermissionDenied } from '../../util/errors';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Button that opens a provider customer-portal session and redirects the
|
|
8
9
|
* browser to the returned URL — the partner manages their payment method /
|
|
9
10
|
* subscription on the provider's hosted page. Mirrors CheckoutButtonComponent;
|
|
10
|
-
* uses BaseAsync for loading/error state.
|
|
11
|
+
* uses BaseAsync for loading/error state. A 403 (no PARTNER_PLAN_SUBSCRIPTION /
|
|
12
|
+
* PORTAL grant) reads as not permitted rather than as a provider failure.
|
|
11
13
|
*/
|
|
12
14
|
@Component({
|
|
13
15
|
selector: 'sail-portal-button',
|
|
@@ -26,6 +28,9 @@ export class PortalButtonComponent extends BaseAsync {
|
|
|
26
28
|
this.billing.createPortalSession(),
|
|
27
29
|
(resp) => { window.location.href = resp.portalUrl; },
|
|
28
30
|
'Could not open the billing portal. Please try again.',
|
|
31
|
+
(err) => {
|
|
32
|
+
if (isPermissionDenied(err)) this.errorMessage.set('You are not permitted to manage billing.');
|
|
33
|
+
},
|
|
29
34
|
);
|
|
30
35
|
}
|
|
31
36
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<section class="document-review-queue" [attr.aria-busy]="loading()">
|
|
2
|
+
@if (errorMessage()) { <p class="document-review-queue__error" role="alert">{{ errorMessage() }}</p> }
|
|
3
|
+
@if (!loading() && !documents().length) {
|
|
4
|
+
<p class="document-review-queue__empty">No documents are awaiting review.</p>
|
|
5
|
+
}
|
|
6
|
+
@for (doc of documents(); track doc.Id) {
|
|
7
|
+
<article class="document-review">
|
|
8
|
+
<h3>{{ doc.Title || doc.DocumentType }}</h3>
|
|
9
|
+
<p>{{ doc.FileName }} · version {{ doc.VersionNo }}</p>
|
|
10
|
+
<button matButton type="button" (click)="showPreview(doc)">Preview</button>
|
|
11
|
+
@if (preview()?.id === doc.Id) {
|
|
12
|
+
<iframe class="document-review__preview" [src]="preview()!.url" [title]="'Preview of ' + (doc.Title || doc.FileName)"></iframe>
|
|
13
|
+
}
|
|
14
|
+
<mat-form-field appearance="outline">
|
|
15
|
+
<mat-label>Review notes</mat-label>
|
|
16
|
+
<textarea matInput [value]="notes()[doc.Id]" (input)="setNotes(doc.Id, $event)"></textarea>
|
|
17
|
+
</mat-form-field>
|
|
18
|
+
<div class="document-review__actions">
|
|
19
|
+
<button matButton="filled" type="button" [disabled]="reviewing() === doc.Id" (click)="review(doc, true)">Approve</button>
|
|
20
|
+
<button matButton type="button" [disabled]="reviewing() === doc.Id" (click)="review(doc, false)">Reject</button>
|
|
21
|
+
</div>
|
|
22
|
+
</article>
|
|
23
|
+
}
|
|
24
|
+
</section>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, SecurityContext, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
2
|
+
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
5
|
+
import { MatInputModule } from '@angular/material/input';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { PartnerDocument } from '../../model/document';
|
|
8
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'sail-document-review-queue',
|
|
12
|
+
templateUrl: './document_review_queue.html',
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
|
+
encapsulation: ViewEncapsulation.None,
|
|
15
|
+
imports: [MatButtonModule, MatFormFieldModule, MatInputModule],
|
|
16
|
+
})
|
|
17
|
+
export class DocumentReviewQueueComponent extends BaseAsync implements OnInit {
|
|
18
|
+
private readonly service = inject(PartnerDocumentService);
|
|
19
|
+
private readonly sanitizer = inject(DomSanitizer);
|
|
20
|
+
|
|
21
|
+
readonly documents = signal<PartnerDocument[]>([]);
|
|
22
|
+
readonly preview = signal<{ id: number; url: SafeResourceUrl } | null>(null);
|
|
23
|
+
readonly reviewing = signal<number | null>(null);
|
|
24
|
+
readonly notes = signal<Record<number, string>>({});
|
|
25
|
+
|
|
26
|
+
ngOnInit(): void {
|
|
27
|
+
this.reload();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
reload(): void {
|
|
31
|
+
this.run(this.service.pending(), (rows) => this.documents.set(rows), 'Could not load pending documents.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
showPreview(doc: PartnerDocument): void {
|
|
35
|
+
this.service.previewUrl(doc.Id).subscribe({
|
|
36
|
+
next: (url) => {
|
|
37
|
+
const safe = this.sanitizer.sanitize(SecurityContext.URL, url);
|
|
38
|
+
if (!safe) {
|
|
39
|
+
this.errorMessage.set('The document preview URL is invalid.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.preview.set({ id: doc.Id, url: this.sanitizer.bypassSecurityTrustResourceUrl(safe) });
|
|
43
|
+
},
|
|
44
|
+
error: (err) => this.setError(err, 'Could not preview the document.'),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setNotes(id: number, event: Event): void {
|
|
49
|
+
const value = (event.target as HTMLTextAreaElement).value;
|
|
50
|
+
this.notes.update((notes) => ({ ...notes, [id]: value }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
review(doc: PartnerDocument, approve: boolean): void {
|
|
54
|
+
this.reviewing.set(doc.Id);
|
|
55
|
+
this.service.review(doc.Id, approve, this.notes()[doc.Id] ?? '').subscribe({
|
|
56
|
+
next: () => {
|
|
57
|
+
this.documents.update((rows) => rows.filter((row) => row.Id !== doc.Id));
|
|
58
|
+
if (this.preview()?.id === doc.Id) this.preview.set(null);
|
|
59
|
+
this.reviewing.set(null);
|
|
60
|
+
},
|
|
61
|
+
error: (err) => {
|
|
62
|
+
this.reviewing.set(null);
|
|
63
|
+
this.setError(err, 'Could not review the document.');
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<div class="document-upload">
|
|
2
|
+
@if (!file()) {
|
|
3
|
+
<button matButton="outlined" type="button" class="document-upload__pick" (click)="picker.click()">
|
|
4
|
+
<mat-icon>upload_file</mat-icon> {{ label() }}
|
|
5
|
+
</button>
|
|
6
|
+
} @else {
|
|
7
|
+
<div class="document-upload__preview">
|
|
8
|
+
@if (imageUrl(); as src) {
|
|
9
|
+
<img [src]="src" [alt]="file()!.name" class="document-upload__image">
|
|
10
|
+
} @else if (pdfUrl(); as src) {
|
|
11
|
+
<a [href]="src" target="_blank" rel="noopener" class="document-upload__pdf">Open preview</a>
|
|
12
|
+
}
|
|
13
|
+
<span class="document-upload__name">{{ file()!.name }}</span>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<ng-content />
|
|
17
|
+
|
|
18
|
+
<div class="document-upload__actions">
|
|
19
|
+
<button matButton="filled" type="button" (click)="upload()" [disabled]="!canUpload() || uploading()">Upload</button>
|
|
20
|
+
<button matButton="outlined" type="button" (click)="picker.click()" [disabled]="uploading()">Choose another</button>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
<input #picker type="file" hidden [accept]="accept()" (change)="pick($event)" [attr.aria-label]="label()">
|
|
24
|
+
|
|
25
|
+
@if (progress() !== null) {
|
|
26
|
+
<mat-progress-bar mode="determinate" [value]="progress()" aria-label="Upload progress" />
|
|
27
|
+
}
|
|
28
|
+
@if (errorMessage()) {
|
|
29
|
+
<p class="document-upload__error" role="alert">{{ errorMessage() }}</p>
|
|
30
|
+
}
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation, computed, inject, input, output, signal } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { HttpEventType } from '@angular/common/http';
|
|
4
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
5
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
6
|
+
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
7
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
8
|
+
import { PartnerDocument } from '../../model/document';
|
|
9
|
+
import { errorDetail } from '../../util/errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Picks, previews and uploads one keel partner document. App-specific
|
|
13
|
+
* attestations are projected content; bind `[canUpload]` to their state.
|
|
14
|
+
* Ships no CSS — style the `.document-upload*` classes globally.
|
|
15
|
+
*/
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'sail-document-upload',
|
|
18
|
+
templateUrl: './document_upload.html',
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
encapsulation: ViewEncapsulation.None,
|
|
21
|
+
imports: [MatButtonModule, MatIconModule, MatProgressBarModule],
|
|
22
|
+
})
|
|
23
|
+
export class DocumentUploadComponent {
|
|
24
|
+
private readonly documents = inject(PartnerDocumentService);
|
|
25
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
26
|
+
|
|
27
|
+
readonly documentType = input.required<string>();
|
|
28
|
+
readonly title = input('');
|
|
29
|
+
readonly documentNumber = input('');
|
|
30
|
+
/** YYYY-MM-DD. */
|
|
31
|
+
readonly expiresOn = input('');
|
|
32
|
+
readonly userId = input<number | undefined>(undefined);
|
|
33
|
+
readonly accept = input('image/*,application/pdf');
|
|
34
|
+
/** Client-side size cap; 0 leaves the limit to keel's document type. */
|
|
35
|
+
readonly maxBytes = input(0);
|
|
36
|
+
readonly label = input('Choose file');
|
|
37
|
+
readonly canUpload = input(true);
|
|
38
|
+
|
|
39
|
+
readonly uploaded = output<PartnerDocument>();
|
|
40
|
+
/** The raw error; branch on `errorCode(err)` (keel's `document_*` codes). */
|
|
41
|
+
readonly failed = output<unknown>();
|
|
42
|
+
|
|
43
|
+
readonly file = signal<File | null>(null);
|
|
44
|
+
readonly progress = signal<number | null>(null);
|
|
45
|
+
readonly errorMessage = signal('');
|
|
46
|
+
private readonly objectUrl = signal<string | null>(null);
|
|
47
|
+
|
|
48
|
+
readonly isImage = computed(() => this.file()?.type.startsWith('image/') ?? false);
|
|
49
|
+
readonly imageUrl = computed(() => (this.isImage() ? this.objectUrl() : null));
|
|
50
|
+
readonly pdfUrl = computed(() => (this.file()?.type === 'application/pdf' ? this.objectUrl() : null));
|
|
51
|
+
readonly uploading = computed(() => this.progress() !== null);
|
|
52
|
+
|
|
53
|
+
constructor() {
|
|
54
|
+
this.destroyRef.onDestroy(() => this.revokePreview());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pick(event: Event): void {
|
|
58
|
+
const input = event.target as HTMLInputElement;
|
|
59
|
+
const file = input.files?.[0] ?? null;
|
|
60
|
+
input.value = '';
|
|
61
|
+
this.errorMessage.set('');
|
|
62
|
+
this.revokePreview();
|
|
63
|
+
this.file.set(null);
|
|
64
|
+
if (!file) return;
|
|
65
|
+
const max = this.maxBytes();
|
|
66
|
+
if (max > 0 && file.size > max) {
|
|
67
|
+
this.errorMessage.set(`The file exceeds the ${(max / 1048576).toFixed(1)} MB limit.`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.file.set(file);
|
|
71
|
+
this.objectUrl.set(URL.createObjectURL(file));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
upload(): void {
|
|
75
|
+
const file = this.file();
|
|
76
|
+
if (!file || !this.canUpload() || this.uploading()) return;
|
|
77
|
+
this.errorMessage.set('');
|
|
78
|
+
this.progress.set(0);
|
|
79
|
+
this.documents.upload(file, {
|
|
80
|
+
documentType: this.documentType(),
|
|
81
|
+
title: this.title() || undefined,
|
|
82
|
+
documentNumber: this.documentNumber() || undefined,
|
|
83
|
+
expiresOn: this.expiresOn() || undefined,
|
|
84
|
+
userId: this.userId(),
|
|
85
|
+
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
86
|
+
next: (event) => {
|
|
87
|
+
if (event.type === HttpEventType.UploadProgress && event.total) {
|
|
88
|
+
this.progress.set(Math.round((100 * event.loaded) / event.total));
|
|
89
|
+
} else if (event.type === HttpEventType.Response && event.body) {
|
|
90
|
+
this.progress.set(null);
|
|
91
|
+
this.clear();
|
|
92
|
+
this.uploaded.emit(event.body);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
error: (err) => {
|
|
96
|
+
this.progress.set(null);
|
|
97
|
+
this.errorMessage.set(errorDetail(err, 'Upload failed.'));
|
|
98
|
+
this.failed.emit(err);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
clear(): void {
|
|
104
|
+
this.revokePreview();
|
|
105
|
+
this.file.set(null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private revokePreview(): void {
|
|
109
|
+
const url = this.objectUrl();
|
|
110
|
+
if (url) URL.revokeObjectURL(url);
|
|
111
|
+
this.objectUrl.set(null);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<section class="partner-documents" [attr.aria-busy]="loading()">
|
|
2
|
+
@if (heading()) { <h2 class="partner-documents__title">{{ heading() }}</h2> }
|
|
3
|
+
@if (errorMessage()) { <p class="partner-documents__error" role="alert">{{ errorMessage() }}</p> }
|
|
4
|
+
|
|
5
|
+
@if (!loading() && !latest().length) {
|
|
6
|
+
<p class="partner-documents__empty">{{ emptyMessage() }}</p>
|
|
7
|
+
} @else {
|
|
8
|
+
<ul class="partner-documents__list">
|
|
9
|
+
@for (doc of latest(); track doc.Id) {
|
|
10
|
+
<li class="partner-document" [class.partner-document--rejected]="doc.Status === 'N'">
|
|
11
|
+
<div class="partner-document__details">
|
|
12
|
+
<strong>{{ doc.Title || doc.DocumentType }}</strong>
|
|
13
|
+
<span>{{ status(doc.Status) }} · version {{ doc.VersionNo }}</span>
|
|
14
|
+
@if (doc.ExpiresOn) { <span>Expires {{ doc.ExpiresOn | date }}</span> }
|
|
15
|
+
@if (doc.ReviewerNotes) { <span>{{ doc.ReviewerNotes }}</span> }
|
|
16
|
+
</div>
|
|
17
|
+
<div class="partner-document__actions">
|
|
18
|
+
<button matButton type="button" (click)="preview(doc)">
|
|
19
|
+
<mat-icon>visibility</mat-icon> Preview
|
|
20
|
+
</button>
|
|
21
|
+
@if (canReupload(doc)) {
|
|
22
|
+
<button matButton type="button" (click)="reuploading.set(doc)">Upload replacement</button>
|
|
23
|
+
}
|
|
24
|
+
</div>
|
|
25
|
+
@if (reuploading()?.Id === doc.Id) {
|
|
26
|
+
<sail-document-upload [documentType]="doc.DocumentType" [title]="doc.Title"
|
|
27
|
+
[documentNumber]="doc.DocumentNumber ?? ''" [userId]="doc.UserId ?? undefined" (uploaded)="uploaded()" />
|
|
28
|
+
}
|
|
29
|
+
</li>
|
|
30
|
+
}
|
|
31
|
+
</ul>
|
|
32
|
+
}
|
|
33
|
+
</section>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, signal } 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 { forkJoin } from 'rxjs';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { PartnerDocument } from '../../model/document';
|
|
8
|
+
import { LabelService } from '../../service/label.service';
|
|
9
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
10
|
+
import { DocumentUploadComponent } from './document_upload';
|
|
11
|
+
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'sail-partner-documents',
|
|
14
|
+
templateUrl: './partner_documents.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
encapsulation: ViewEncapsulation.None,
|
|
17
|
+
imports: [DatePipe, MatButtonModule, MatIconModule, DocumentUploadComponent],
|
|
18
|
+
})
|
|
19
|
+
export class PartnerDocumentsComponent extends BaseAsync implements OnInit {
|
|
20
|
+
private readonly service = inject(PartnerDocumentService);
|
|
21
|
+
private readonly labels = inject(LabelService);
|
|
22
|
+
|
|
23
|
+
readonly userId = input<number | undefined>(undefined);
|
|
24
|
+
readonly heading = input('Documents');
|
|
25
|
+
readonly emptyMessage = input('No documents uploaded.');
|
|
26
|
+
readonly expiringWithinDays = input(30);
|
|
27
|
+
|
|
28
|
+
readonly documents = signal<PartnerDocument[]>([]);
|
|
29
|
+
readonly reuploading = signal<PartnerDocument | null>(null);
|
|
30
|
+
readonly statusesReady = signal(false);
|
|
31
|
+
readonly latest = computed(() => latestByType(this.documents()));
|
|
32
|
+
|
|
33
|
+
ngOnInit(): void {
|
|
34
|
+
this.run(forkJoin({
|
|
35
|
+
labels: this.labels.loadLabels('partner_document_status'),
|
|
36
|
+
documents: this.service.list(this.userId()),
|
|
37
|
+
}), ({ documents }) => {
|
|
38
|
+
this.statusesReady.set(true);
|
|
39
|
+
this.documents.set(documents);
|
|
40
|
+
}, 'Could not load documents.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
reload(): void {
|
|
44
|
+
this.run(this.service.list(this.userId()), (rows) => this.documents.set(rows), 'Could not load documents.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
status(status: string): string {
|
|
48
|
+
return this.statusesReady() ? this.labels.requireLabel('partner_document_status', status) : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
canReupload(doc: PartnerDocument): boolean {
|
|
52
|
+
return doc.Status === 'N' || isExpiring(doc.ExpiresOn, this.expiringWithinDays());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
preview(doc: PartnerDocument): void {
|
|
56
|
+
// Opened synchronously so the popup blocker allows it; 'noopener' would return null.
|
|
57
|
+
const tab = window.open('', '_blank');
|
|
58
|
+
if (tab) tab.opener = null;
|
|
59
|
+
this.service.previewUrl(doc.Id).subscribe({
|
|
60
|
+
next: (url) => {
|
|
61
|
+
if (tab) tab.location.href = url;
|
|
62
|
+
else window.open(url, '_blank', 'noopener');
|
|
63
|
+
},
|
|
64
|
+
error: (err) => {
|
|
65
|
+
tab?.close();
|
|
66
|
+
this.setError(err, 'Could not open the document.');
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
uploaded(): void {
|
|
72
|
+
this.reuploading.set(null);
|
|
73
|
+
this.reload();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function latestByType(rows: PartnerDocument[]): PartnerDocument[] {
|
|
78
|
+
const latest = new Map<string, PartnerDocument>();
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const key = `${row.UserId ?? ''}:${row.DocumentType}`;
|
|
81
|
+
const current = latest.get(key);
|
|
82
|
+
if (!current || row.VersionNo > current.VersionNo) latest.set(key, row);
|
|
83
|
+
}
|
|
84
|
+
return [...latest.values()].sort((a, b) => a.DocumentType.localeCompare(b.DocumentType));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isExpiring(value: string | null, withinDays: number): boolean {
|
|
88
|
+
if (!value || withinDays < 0) return false;
|
|
89
|
+
const expires = new Date(`${value.slice(0, 10)}T00:00:00`);
|
|
90
|
+
if (Number.isNaN(expires.getTime())) return false;
|
|
91
|
+
return expires.getTime() <= Date.now() + withinDays * 86400000;
|
|
92
|
+
}
|
|
@@ -29,7 +29,7 @@ export class LoginComponent extends BaseAsync {
|
|
|
29
29
|
protected fb = inject(FormBuilder);
|
|
30
30
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
31
31
|
protected readonly loggedOut = inject(ActivatedRoute).snapshot.queryParamMap.get('loggedOut') === 'true';
|
|
32
|
-
/** OAuth session-
|
|
32
|
+
/** OAuth session hand-off failure from BaseAuthService, shown below. */
|
|
33
33
|
protected readonly sessionHandoffError = this.auth.sessionHandoffError;
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -64,8 +64,8 @@ export class LoginComponent extends BaseAsync {
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* loginSocial already ran completeLogin, which owns post-login navigation
|
|
67
|
-
* (route init, or the OAuth
|
|
68
|
-
* doing so abandons a pending
|
|
67
|
+
* (route init, or the OAuth session hand-off). Handlers must NOT navigate here —
|
|
68
|
+
* doing so abandons a pending hand-off before it redirects. Downstream
|
|
69
69
|
* social-login handlers should follow the same rule.
|
|
70
70
|
*/
|
|
71
71
|
protected onSocialSuccess(_: LoginResponseSocial): void {}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
@if (!m.IsDefault) {
|
|
32
32
|
<button matButton (click)="setDefault(m)">Set default</button>
|
|
33
33
|
}
|
|
34
|
-
<button matIconButton (click)="remove(m)" aria-label="
|
|
34
|
+
<button matIconButton (click)="remove(m)" aria-label="Remove payment method">
|
|
35
35
|
<mat-icon>delete</mat-icon>
|
|
36
36
|
</button>
|
|
37
37
|
</div>
|
|
@@ -10,7 +10,7 @@ import { UserPaymentMethod } from '../../model/appdata';
|
|
|
10
10
|
* End-user saved payment methods.
|
|
11
11
|
*
|
|
12
12
|
* Drop-in screen that lists the caller's cards/wallets, with set-default
|
|
13
|
-
* +
|
|
13
|
+
* + remove actions. Inherits loading/error state from BaseAsync.
|
|
14
14
|
*
|
|
15
15
|
* Customise the heading and "add" CTA via inputs; emit (addClicked) so
|
|
16
16
|
* the consumer routes to its own SetupIntent flow: BillingService.createSetupIntent()
|
|
@@ -37,8 +37,8 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
|
37
37
|
readonly addClicked = output<void>();
|
|
38
38
|
/** Emitted after a successful set-default flip. */
|
|
39
39
|
readonly defaultChanged = output<UserPaymentMethod>();
|
|
40
|
-
/** Emitted after a
|
|
41
|
-
readonly
|
|
40
|
+
/** Emitted after a method is detached and removed. */
|
|
41
|
+
readonly removed = output<UserPaymentMethod>();
|
|
42
42
|
|
|
43
43
|
private readonly service = inject(UserPaymentMethodService);
|
|
44
44
|
|
|
@@ -78,12 +78,12 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
|
78
78
|
|
|
79
79
|
remove(m: UserPaymentMethod): void {
|
|
80
80
|
this.run(
|
|
81
|
-
this.service.
|
|
81
|
+
this.service.remove(m.Id),
|
|
82
82
|
() => {
|
|
83
83
|
this.methods.update((list) => list.filter((row) => row.Id !== m.Id));
|
|
84
|
-
this.
|
|
84
|
+
this.removed.emit(m);
|
|
85
85
|
},
|
|
86
|
-
'Failed to
|
|
86
|
+
'Failed to remove.',
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|