@nauticana/sail 1.1.12 → 1.1.14

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x** (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
8
8
 
@@ -22,13 +22,14 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
22
22
  | **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
23
23
  | **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
24
24
  | **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
25
+ | **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
25
26
  | **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
26
- | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
27
+ | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
27
28
  | **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
28
29
  | **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
29
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. |
30
31
  | **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
31
- | **Utils** | `titleCase()`, `fromPriceLabel()` |
32
+ | **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
32
33
 
33
34
  ## Quick start
34
35
 
@@ -273,7 +274,8 @@ interface SailGuiConfig {
273
274
  googleClientId?: string; // Google Identity Services client ID
274
275
  appleServiceId?: string; // Apple Services ID
275
276
  appleRedirectUri?: string; // Apple Sign-In redirect URI
276
- privacyPolicyUrl?: string; // Linked from ConsentGateComponent
277
+ privacyPolicyUrl?: string; // Linked from ConsentGateComponent and the login footer
278
+ termsUrl?: string; // Linked from the SMS-consent disclosure and the login footer
277
279
  defaultPolicyVersion?: string; // Content hash of the deployed policy
278
280
  defaultPolicyLanguage?: string; // ISO 639-1 fallback language
279
281
  accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
@@ -589,6 +591,74 @@ A complete screen is the pieces above assembled over `BillingService` — no met
589
591
  <!-- invoices: render listInvoices() rows directly -->
590
592
  ```
591
593
 
594
+ ## Agency / reseller UI (v1.1.14)
595
+
596
+ sail v1.1.14 supplies composable, unstyled agency components over keel v1.2.41.
597
+ The app owns routes, route guards, brand copy, global CSS, and any columns that
598
+ refer to an app-specific managed entity.
599
+
600
+ `AgencyService` covers profile enrollment, invitation staging/acceptance,
601
+ delegation/revocation, per-client referral/wholesale selection, earnings, and
602
+ payout-profile selection. Its DTOs use the upstream names
603
+ `agency_client_invitation`, `agency_client_delegation`, and
604
+ `agency_client_rate`; there is no client-side rule-schedule model.
605
+ The enrollment method is `enroll()`; downstream `enrollReferral()` calls must
606
+ be renamed when adopting this release.
607
+
608
+ Compose an agency page from the pieces you need:
609
+
610
+ ```html
611
+ <sail-agency-clients
612
+ [extraColumns]="clientColumns"
613
+ [showBillingModelControls]="true" />
614
+ <sail-agency-earnings-summary [earnings]="earnings()" [activeClients]="activeClients()" />
615
+ <sail-commission-ledger [entries]="earnings()?.entries ?? []" />
616
+ <sail-agency-payout-history [payouts]="earnings()?.payouts ?? []" />
617
+ <sail-agency-payout-profile />
618
+ ```
619
+
620
+ `AgencyClientsComponent` renders status values through the shared
621
+ `StatusChipComponent` and backend constant catalogues. It accepts app-specific
622
+ columns rather than assuming the managed entity. Accepted clients begin in
623
+ referral mode; wholesale controls appear only when the profile permits them and
624
+ the app opts in with `showBillingModelControls`.
625
+
626
+ Load keel's v1.2.40 basis seed so status chips receive these canonical domains:
627
+
628
+ | Domain | Values |
629
+ |---|---|
630
+ | `agency_client_status` | `S` Staged, `I` Invited, `V` Client, `X` Cancelled |
631
+ | `agency_billing_model` | `R` Referral, `W` Wholesale |
632
+ | `agency_commission_status` | `H` Held, `P` Payable, `R` Reserved, `D` Paid, `O` Offset |
633
+ | `agency_payout_status` | `C` Created, `P` Processing, `D` Paid, `F` Failed, `R` Returned, `V` Reversed, `M` Manual review |
634
+
635
+ Do not substitute app-local domain names such as `billing_model` or
636
+ `commission_status`; a missing canonical domain deliberately renders the raw
637
+ code so configuration drift remains visible.
638
+
639
+ `AgencyAcceptComponent` keeps consent explicit and persists an invitation token
640
+ when the authenticated user still needs to create a business partner. Consumers
641
+ handle its navigation outputs and route back after business setup. Other 409
642
+ conflicts, such as an existing delegation or self-delegation, remain on the
643
+ invitation screen as errors instead of entering the business-setup flow. The
644
+ branch uses keel's stable RFC 7807 `code: "agency_no_partner"` (keel v1.2.41),
645
+ not error prose.
646
+
647
+ The payout-profile component composes the existing provider-onboarding surface
648
+ and only allows a fully onboarded destination returned by keel. Mount it only
649
+ for an approved, non-suspended agency; keel intentionally rejects its GET for a
650
+ pending profile. The ledger and payout-history components show default empty
651
+ messages; pass `[emptyMessage]="''"` when a composed page should omit empty
652
+ regions entirely.
653
+
654
+ All accounting values are integer minor units. `formatMinorCurrency()` uses the
655
+ currency exponent reported by `Intl.NumberFormat`, so zero- and three-decimal
656
+ currencies render correctly; earnings stay separated by currency rather than
657
+ being summed into a misleading total.
658
+
659
+ The agency URL defaults use `/api/v1/agency/*`. Apps mounting Keel elsewhere
660
+ must override the `RestURL.agency*URL` values with `configureRestUrls()`.
661
+
592
662
  ## Off-session SCA / 3DS confirmation (v1.0.x)
593
663
 
594
664
  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.
@@ -1138,8 +1208,8 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
1138
1208
 
1139
1209
  | From | Export | Purpose |
1140
1210
  |---|---|---|
1141
- | `@nauticana/sail` | `PayoutService` | keel/payout API client — hosted-KYC launch, reuse flow, status |
1142
- | `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker + hosted-KYC launcher |
1211
+ | `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
1212
+ | `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
1143
1213
  | `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
1144
1214
  | `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / delete / set-default API client |
1145
1215
  | `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + delete |
@@ -1151,10 +1221,12 @@ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
1151
1221
 
1152
1222
  | Endpoint | Method | Purpose |
1153
1223
  |---|---|---|
1154
- | `/api/v1/payout/onboard/start` | POST | Open hosted-KYC; returns `{ url, externalAccountId, expiresAt }` |
1224
+ | `/api/v1/payout/onboard/start` | POST | Start provider onboarding; returns `{ url, externalAccountId, expiresAt }` (`url` is empty when confirmation continues asynchronously) |
1155
1225
  | `/api/v1/payout/reusable` | POST | List provider accounts the user has on other partners |
1156
1226
  | `/api/v1/payout/reusable/link` | POST | Copy a `providerAccountId` onto the active partner's `user_bank_info` row |
1157
1227
  | `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
1228
+ | `/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 |
1229
+ | `/api/v1/payout/beneficiary/register` | POST | Create and link a provider beneficiary from provider-collected details |
1158
1230
  | `/api/v1/payment-methods/set-default` | POST | Atomic multi-row UPDATE — sets one row as default, clears the rest |
1159
1231
  | `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
1160
1232
 
@@ -1176,6 +1248,7 @@ Drop the two components into a wizard step. Routing between them stays in the co
1176
1248
  <sail-payout-provider-onboarding
1177
1249
  (linked)="step.set('done')"
1178
1250
  (started)="step.set('waiting')"
1251
+ (pending)="step.set('awaiting-confirmation')"
1179
1252
  (skipped)="step.set('done')"
1180
1253
  (back)="step.set('bank')">
1181
1254
  </sail-payout-provider-onboarding>
@@ -1183,7 +1256,9 @@ Drop the two components into a wizard step. Routing between them stays in the co
1183
1256
  }
1184
1257
  ```
1185
1258
 
1186
- `BankInfoFormValue` maps 1:1 to `basis.user_bank_info` columns. The consumer decides where to POST it — typically a direct generic-CRUD insert against `/api/v1/user_bank_info`. The provider account itself is created by `<sail-payout-provider-onboarding>` via `PayoutService.startOnboarding()` → hosted KYC → webhook back to keel.
1259
+ `BankInfoFormValue` is application-layer input, not a generic-CRUD row. In particular, its plaintext `taxId` must be sealed into the `tax_id_encrypted` column and must never be sent through the generic `/api/v1/user_bank_info` CRUD surface. First-time setup goes through the consuming app's registration service. Changes to an existing destination use `PayoutService.replaceBankInfo()`, which calls keel's atomic version-replacement endpoint; wire `PayoutHandler.SealTaxID` so that endpoint seals the plaintext before persistence.
1260
+
1261
+ `PayoutService.startOnboarding()` returns a `PayoutOnboardingSession`. When `url` is non-empty, the component emits `started` and navigates to the provider's hosted flow. When `url` is empty, as with Wise email recipients, it emits `pending` instead; consumers must show an awaiting-confirmation state while the recipient completes the provider's email flow. Airwallex apps can pass details collected by the embedded beneficiary component to `PayoutService.registerBeneficiary()`.
1187
1262
 
1188
1263
  For apps that operate on a single keel partner, the reuse picker stays empty and only the "Start onboarding" CTA renders. Multi-partner apps get the picker for free.
1189
1264
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
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
  "scripts": {
@@ -0,0 +1,33 @@
1
+ @if (loading()) {
2
+ <mat-spinner diameter="36" aria-label="Loading invitation"></mat-spinner>
3
+ } @else if (complete()) {
4
+ <div class="agency-accept__complete" role="status">Agency management has been accepted.</div>
5
+ } @else if (invite(); as invitation) {
6
+ <section class="agency-accept__invitation">
7
+ <h1>Agency management invitation</h1>
8
+ <p>
9
+ <strong>{{ invitation.agencyName }}</strong> invited
10
+ <strong>{{ invitation.clientName }}</strong>. Accepting grants management access;
11
+ ownership remains with the client and the delegation can be revoked.
12
+ </p>
13
+ @if (invitation.expired) {
14
+ <p class="agency-accept__error" role="alert">This invitation has expired.</p>
15
+ }
16
+ @if (needsBusiness()) {
17
+ <p role="status">A business must be set up before management can be delegated.</p>
18
+ <button matButton="filled" type="button" (click)="requestBusinessSetup()">{{ businessSetupLabel() }}</button>
19
+ } @else if (authenticated()) {
20
+ <button matButton="filled" type="button" [disabled]="accepting() || invitation.expired" (click)="accept()">
21
+ Accept management
22
+ </button>
23
+ } @else {
24
+ <button matButton="filled" type="button" (click)="loginRequested.emit()">{{ loginLabel() }}</button>
25
+ <button matButton type="button" (click)="registrationRequested.emit()">{{ registerLabel() }}</button>
26
+ }
27
+ @if (errorMessage()) {
28
+ <p class="agency-accept__error" role="alert">{{ errorMessage() }}</p>
29
+ }
30
+ </section>
31
+ } @else {
32
+ <p class="agency-accept__error" role="alert">{{ errorMessage() }}</p>
33
+ }
@@ -0,0 +1,94 @@
1
+ import { ChangeDetectionStrategy, Component, OnInit, inject, input, output, signal } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
4
+ import { AgencyInvite } from '../../model/agency';
5
+ import { AgencyService } from '../../service/agency.service';
6
+ import { errorCode, errorDetail } from '../../util/errors';
7
+
8
+ const NO_PARTNER_CODE = 'agency_no_partner';
9
+
10
+ @Component({
11
+ selector: 'sail-agency-accept',
12
+ changeDetection: ChangeDetectionStrategy.OnPush,
13
+ imports: [MatButtonModule, MatProgressSpinnerModule],
14
+ templateUrl: './agency_accept.html',
15
+ host: { class: 'agency-accept' },
16
+ })
17
+ export class AgencyAcceptComponent implements OnInit {
18
+ readonly token = input('');
19
+ readonly authenticated = input(false);
20
+ readonly tokenStorageKey = input('sail.agencyInvite');
21
+ readonly loginLabel = input('Log in to accept');
22
+ readonly registerLabel = input('Create an account');
23
+ readonly businessSetupLabel = input('Set up your business');
24
+
25
+ readonly loginRequested = output<void>();
26
+ readonly registrationRequested = output<void>();
27
+ readonly businessSetupRequested = output<string>();
28
+ readonly accepted = output<AgencyInvite>();
29
+
30
+ private readonly agency = inject(AgencyService);
31
+ private activeToken = '';
32
+
33
+ readonly loading = signal(true);
34
+ readonly accepting = signal(false);
35
+ readonly invite = signal<AgencyInvite | null>(null);
36
+ readonly errorMessage = signal('');
37
+ readonly complete = signal(false);
38
+ readonly needsBusiness = signal(false);
39
+
40
+ ngOnInit(): void {
41
+ this.activeToken = this.token() || localStorage.getItem(this.tokenStorageKey()) || '';
42
+ if (!this.activeToken) {
43
+ this.loading.set(false);
44
+ this.errorMessage.set('The invitation token is missing.');
45
+ return;
46
+ }
47
+ this.agency.inviteInfo(this.activeToken).subscribe({
48
+ next: (invite) => {
49
+ this.invite.set(invite);
50
+ this.loading.set(false);
51
+ },
52
+ error: (error: unknown) => {
53
+ this.loading.set(false);
54
+ this.errorMessage.set(errorDetail(error, 'The invitation was not found.'));
55
+ },
56
+ });
57
+ }
58
+
59
+ accept(): void {
60
+ const invite = this.invite();
61
+ if (!invite || invite.expired || this.accepting()) return;
62
+ this.accepting.set(true);
63
+ this.needsBusiness.set(false);
64
+ this.errorMessage.set('');
65
+ this.agency.accept(this.activeToken).subscribe({
66
+ next: () => {
67
+ localStorage.removeItem(this.tokenStorageKey());
68
+ this.accepting.set(false);
69
+ this.complete.set(true);
70
+ this.accepted.emit(invite);
71
+ },
72
+ error: (error: unknown) => {
73
+ this.accepting.set(false);
74
+ if (this.statusOf(error) === 409 && errorCode(error) === NO_PARTNER_CODE) {
75
+ localStorage.setItem(this.tokenStorageKey(), this.activeToken);
76
+ this.needsBusiness.set(true);
77
+ return;
78
+ }
79
+ this.errorMessage.set(errorDetail(error, 'Could not accept the invitation.'));
80
+ },
81
+ });
82
+ }
83
+
84
+ requestBusinessSetup(): void {
85
+ localStorage.setItem(this.tokenStorageKey(), this.activeToken);
86
+ this.businessSetupRequested.emit(this.activeToken);
87
+ }
88
+
89
+ private statusOf(error: unknown): number | undefined {
90
+ return typeof error === 'object' && error !== null && 'status' in error
91
+ ? Number((error as { status?: unknown }).status)
92
+ : undefined;
93
+ }
94
+ }
@@ -0,0 +1,97 @@
1
+ @if (loading()) {
2
+ <mat-spinner diameter="36" aria-label="Loading agency clients"></mat-spinner>
3
+ } @else if (needsEnrollment()) {
4
+ <section class="agency-clients__enrollment">
5
+ <h2>{{ enrollmentTitle() }}</h2>
6
+ <p>{{ enrollmentDescription() }}</p>
7
+ <button matButton="filled" type="button" (click)="enroll()">{{ enrollmentActionLabel() }}</button>
8
+ </section>
9
+ } @else {
10
+ @if (errorMessage()) {
11
+ <p class="agency-clients__error" role="alert">{{ errorMessage() }}</p>
12
+ }
13
+
14
+ <sail-agency-status-banner [profile]="profile()" />
15
+
16
+ <form class="agency-clients__add" [formGroup]="addForm" (ngSubmit)="addClient()">
17
+ <mat-form-field appearance="outline">
18
+ <mat-label>Client name</mat-label>
19
+ <input matInput formControlName="name" required />
20
+ </mat-form-field>
21
+ <mat-form-field appearance="outline">
22
+ <mat-label>Email</mat-label>
23
+ <input matInput formControlName="email" type="email" />
24
+ </mat-form-field>
25
+ <mat-form-field appearance="outline">
26
+ <mat-label>Website</mat-label>
27
+ <input matInput formControlName="website" />
28
+ </mat-form-field>
29
+ <button matButton="filled" type="submit" [disabled]="addForm.invalid || !canManage()">Add client</button>
30
+ </form>
31
+
32
+ <p class="agency-clients__count">Active clients: {{ activeClients() }}</p>
33
+
34
+ @if (clients().length === 0) {
35
+ <p class="agency-clients__empty">No clients have been staged.</p>
36
+ } @else {
37
+ <div class="agency-clients__table-wrap">
38
+ <table class="agency-clients__table">
39
+ <thead>
40
+ <tr>
41
+ <th>Name</th>
42
+ <th>Email</th>
43
+ <th>Website</th>
44
+ @for (column of extraColumns(); track column.id) {
45
+ <th>{{ column.caption }}</th>
46
+ }
47
+ <th>Status</th>
48
+ @if (showBillingModelControls()) { <th>Billing model</th> }
49
+ <th><span class="agency-clients__visually-hidden">Actions</span></th>
50
+ </tr>
51
+ </thead>
52
+ <tbody>
53
+ @for (client of clients(); track client.id) {
54
+ <tr>
55
+ <td>{{ client.name }}</td>
56
+ <td>{{ client.email }}</td>
57
+ <td>{{ client.website }}</td>
58
+ @for (column of extraColumns(); track column.id) {
59
+ <td>{{ column.value(client) }}</td>
60
+ }
61
+ <td>
62
+ <sail-status-chip domain="agency_client_status" [value]="client.status" />
63
+ @if (client.expired) { <span class="agency-clients__expired">Expired</span> }
64
+ </td>
65
+ @if (showBillingModelControls()) {
66
+ <td>
67
+ @if (client.clientPartnerId) {
68
+ <sail-status-chip domain="agency_billing_model" [value]="client.billingModel ?? ''" />
69
+ <button matButton type="button" [disabled]="!canManage() || client.billingModel === 'R'"
70
+ (click)="setBillingModel(client, 'R')">Referral</button>
71
+ @if (profile()?.wholesaleAllowed) {
72
+ <button matButton type="button" [disabled]="!canManage() || client.billingModel === 'W'"
73
+ (click)="setBillingModel(client, 'W')">Wholesale</button>
74
+ }
75
+ }
76
+ </td>
77
+ }
78
+ <td>
79
+ @if (client.status === 'S' || client.status === 'I') {
80
+ <button matButton type="button" [disabled]="!canManage()" (click)="invite(client)">
81
+ {{ client.status === 'S' ? 'Invite' : 'Re-invite' }}
82
+ </button>
83
+ <button matButton type="button" [disabled]="!canManage()" (click)="cancel(client)">Cancel</button>
84
+ }
85
+ @if (inviteLinks()[client.id]; as link) {
86
+ <button matIconButton type="button" aria-label="Copy invitation link" (click)="copy(link)">
87
+ <mat-icon>content_copy</mat-icon>
88
+ </button>
89
+ }
90
+ </td>
91
+ </tr>
92
+ }
93
+ </tbody>
94
+ </table>
95
+ </div>
96
+ }
97
+ }
@@ -0,0 +1,182 @@
1
+ import { ChangeDetectionStrategy, Component, OnInit, computed, inject, input, output, signal } from '@angular/core';
2
+ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatFormFieldModule } from '@angular/material/form-field';
5
+ import { MatIconModule } from '@angular/material/icon';
6
+ import { MatInputModule } from '@angular/material/input';
7
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
8
+ import { MatSnackBar } from '@angular/material/snack-bar';
9
+ import { AgencyClient, AgencyClientColumn, AgencyProfile } from '../../model/agency';
10
+ import { AgencyService } from '../../service/agency.service';
11
+ import { errorDetail } from '../../util/errors';
12
+ import { StatusChipComponent } from '../billing/status_chip';
13
+ import { AgencyStatusBannerComponent } from './agency_status_banner';
14
+
15
+ @Component({
16
+ selector: 'sail-agency-clients',
17
+ changeDetection: ChangeDetectionStrategy.OnPush,
18
+ imports: [
19
+ ReactiveFormsModule,
20
+ MatButtonModule,
21
+ MatFormFieldModule,
22
+ MatIconModule,
23
+ MatInputModule,
24
+ MatProgressSpinnerModule,
25
+ StatusChipComponent,
26
+ AgencyStatusBannerComponent,
27
+ ],
28
+ templateUrl: './agency_clients.html',
29
+ host: { class: 'agency-clients' },
30
+ })
31
+ export class AgencyClientsComponent implements OnInit {
32
+ readonly extraColumns = input<AgencyClientColumn[]>([]);
33
+ readonly enrollmentTitle = input('Agency enrollment');
34
+ readonly enrollmentDescription = input('Apply to manage clients and receive referral commission.');
35
+ readonly enrollmentActionLabel = input('Apply');
36
+ readonly showBillingModelControls = input(false);
37
+
38
+ readonly enrolled = output<AgencyProfile>();
39
+ readonly clientAdded = output<AgencyClient>();
40
+ readonly invited = output<{ client: AgencyClient; inviteLink: string }>();
41
+ readonly cancelled = output<AgencyClient>();
42
+ readonly billingModelChanged = output<{ client: AgencyClient; billingModel: 'R' | 'W' }>();
43
+
44
+ private readonly agency = inject(AgencyService);
45
+ private readonly snack = inject(MatSnackBar);
46
+ private readonly formBuilder = inject(FormBuilder);
47
+
48
+ readonly loading = signal(true);
49
+ readonly errorMessage = signal('');
50
+ readonly needsEnrollment = signal(false);
51
+ readonly profile = signal<AgencyProfile | null>(null);
52
+ readonly clients = signal<AgencyClient[]>([]);
53
+ readonly activeClients = signal(0);
54
+ readonly inviteLinks = signal<Record<number, string>>({});
55
+ readonly canManage = computed(() => {
56
+ const profile = this.profile();
57
+ return profile !== null && Boolean(profile.approvedAt) && !profile.suspended;
58
+ });
59
+
60
+ readonly addForm = this.formBuilder.nonNullable.group({
61
+ name: ['', Validators.required],
62
+ email: ['', Validators.email],
63
+ website: [''],
64
+ });
65
+
66
+ ngOnInit(): void {
67
+ this.reload();
68
+ }
69
+
70
+ reload(afterProfileLoad?: (profile: AgencyProfile) => void): void {
71
+ this.loading.set(true);
72
+ this.errorMessage.set('');
73
+ this.agency.profile().subscribe({
74
+ next: ({ profile }) => {
75
+ this.profile.set(profile);
76
+ this.needsEnrollment.set(profile === null);
77
+ if (profile === null) {
78
+ this.clients.set([]);
79
+ this.activeClients.set(0);
80
+ this.loading.set(false);
81
+ return;
82
+ }
83
+ afterProfileLoad?.(profile);
84
+ this.loadClients();
85
+ },
86
+ error: (error: unknown) => {
87
+ this.loading.set(false);
88
+ this.errorMessage.set(errorDetail(error, 'Could not load the agency profile.'));
89
+ },
90
+ });
91
+ }
92
+
93
+ enroll(): void {
94
+ this.loading.set(true);
95
+ this.errorMessage.set('');
96
+ this.agency.enroll().subscribe({
97
+ next: () => this.reload((profile) => this.enrolled.emit(profile)),
98
+ error: (error: unknown) => {
99
+ this.loading.set(false);
100
+ this.errorMessage.set(errorDetail(error, 'Agency enrollment failed.'));
101
+ },
102
+ });
103
+ }
104
+
105
+ addClient(): void {
106
+ if (this.addForm.invalid || !this.canManage()) return;
107
+ const value = this.addForm.getRawValue();
108
+ this.agency.addClients([value]).subscribe({
109
+ next: () => {
110
+ this.addForm.reset();
111
+ this.loadClients((client) => this.clientAdded.emit(client));
112
+ },
113
+ error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not add the client.'), 'Dismiss'),
114
+ });
115
+ }
116
+
117
+ invite(client: AgencyClient): void {
118
+ if (!this.canManage()) return;
119
+ this.agency.invite(client.id).subscribe({
120
+ next: ({ inviteLink }) => {
121
+ this.inviteLinks.update((links) => ({ ...links, [client.id]: inviteLink }));
122
+ this.invited.emit({ client, inviteLink });
123
+ this.copy(inviteLink);
124
+ this.loadClients();
125
+ },
126
+ error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not create the invitation.'), 'Dismiss'),
127
+ });
128
+ }
129
+
130
+ cancel(client: AgencyClient): void {
131
+ if (!this.canManage()) return;
132
+ this.agency.cancel(client.id).subscribe({
133
+ next: () => {
134
+ this.cancelled.emit(client);
135
+ this.loadClients();
136
+ },
137
+ error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not cancel the client.'), 'Dismiss'),
138
+ });
139
+ }
140
+
141
+ setBillingModel(client: AgencyClient, billingModel: 'R' | 'W'): void {
142
+ if (!this.canManage() || !client.clientPartnerId) return;
143
+ this.agency.setBillingModel(client.clientPartnerId, billingModel).subscribe({
144
+ next: () => {
145
+ this.billingModelChanged.emit({ client, billingModel });
146
+ this.loadClients();
147
+ },
148
+ error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not change the billing model.'), 'Dismiss'),
149
+ });
150
+ }
151
+
152
+ copy(link: string): void {
153
+ if (!navigator.clipboard) {
154
+ this.snack.open(link, 'Dismiss');
155
+ return;
156
+ }
157
+ navigator.clipboard.writeText(link).then(
158
+ () => this.snack.open('Invitation link copied.', undefined, { duration: 2500 }),
159
+ () => this.snack.open(link, 'Dismiss'),
160
+ );
161
+ }
162
+
163
+ private loadClients(afterLoad?: (client: AgencyClient) => void): void {
164
+ this.agency.clients().subscribe({
165
+ next: ({ items, activeClients }) => {
166
+ const previousIDs = new Set(this.clients().map((client) => client.id));
167
+ const clients = items ?? [];
168
+ this.clients.set(clients);
169
+ this.activeClients.set(activeClients ?? 0);
170
+ this.loading.set(false);
171
+ if (afterLoad) {
172
+ const added = clients.find((client) => !previousIDs.has(client.id));
173
+ if (added) afterLoad(added);
174
+ }
175
+ },
176
+ error: (error: unknown) => {
177
+ this.loading.set(false);
178
+ this.errorMessage.set(errorDetail(error, 'Could not load agency clients.'));
179
+ },
180
+ });
181
+ }
182
+ }
@@ -0,0 +1,24 @@
1
+ @if (earnings(); as value) {
2
+ <div class="agency-earnings-summary__metric">
3
+ <span class="agency-earnings-summary__label">Active clients</span>
4
+ <span class="agency-earnings-summary__value">{{ activeClients() }}</span>
5
+ </div>
6
+ <div class="agency-earnings-summary__metric">
7
+ <span class="agency-earnings-summary__label">Paying clients</span>
8
+ <span class="agency-earnings-summary__value">{{ value.payingClients }}</span>
9
+ </div>
10
+ @for (balance of value.balances; track balance.currency) {
11
+ <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
12
+ <span class="agency-earnings-summary__label">Held ({{ balance.currency }})</span>
13
+ <span class="agency-earnings-summary__value">{{ format(balance.heldMinor, balance.currency) }}</span>
14
+ </div>
15
+ <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
16
+ <span class="agency-earnings-summary__label">Due ({{ balance.currency }})</span>
17
+ <span class="agency-earnings-summary__value">{{ format(balance.dueMinor, balance.currency) }}</span>
18
+ </div>
19
+ <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
20
+ <span class="agency-earnings-summary__label">Paid ({{ balance.currency }})</span>
21
+ <span class="agency-earnings-summary__value">{{ format(balance.paidMinor, balance.currency) }}</span>
22
+ </div>
23
+ }
24
+ }
@@ -0,0 +1,15 @@
1
+ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2
+ import { AgencyEarnings } from '../../model/agency';
3
+ import { formatMinorCurrency } from '../../util/money';
4
+
5
+ @Component({
6
+ selector: 'sail-agency-earnings-summary',
7
+ changeDetection: ChangeDetectionStrategy.OnPush,
8
+ templateUrl: './agency_earnings_summary.html',
9
+ host: { class: 'agency-earnings-summary' },
10
+ })
11
+ export class AgencyEarningsSummaryComponent {
12
+ readonly earnings = input<AgencyEarnings | null>(null);
13
+ readonly activeClients = input(0);
14
+ readonly format = formatMinorCurrency;
15
+ }
@@ -0,0 +1,19 @@
1
+ @if (payouts().length > 0) {
2
+ <div class="agency-payout-history__table-wrap">
3
+ <table class="agency-payout-history__table">
4
+ <thead><tr><th>Cycle</th><th>Amount</th><th>Status</th><th>Paid</th></tr></thead>
5
+ <tbody>
6
+ @for (payout of payouts(); track payout.id) {
7
+ <tr>
8
+ <td>{{ payout.cutoffAt | date: 'yyyy-MM':'UTC' }}</td>
9
+ <td>{{ format(payout.amountMinor, payout.currency) }}</td>
10
+ <td><sail-status-chip domain="agency_payout_status" [value]="payout.status" /></td>
11
+ <td>{{ payout.paidAt ? (payout.paidAt | date: 'mediumDate':'UTC') : '' }}</td>
12
+ </tr>
13
+ }
14
+ </tbody>
15
+ </table>
16
+ </div>
17
+ } @else if (emptyMessage()) {
18
+ <p class="agency-payout-history__empty">{{ emptyMessage() }}</p>
19
+ }
@@ -0,0 +1,18 @@
1
+ import { DatePipe } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
3
+ import { AgencyPayout } from '../../model/agency';
4
+ import { formatMinorCurrency } from '../../util/money';
5
+ import { StatusChipComponent } from '../billing/status_chip';
6
+
7
+ @Component({
8
+ selector: 'sail-agency-payout-history',
9
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
+ imports: [DatePipe, StatusChipComponent],
11
+ templateUrl: './agency_payout_history.html',
12
+ host: { class: 'agency-payout-history' },
13
+ })
14
+ export class AgencyPayoutHistoryComponent {
15
+ readonly payouts = input<AgencyPayout[]>([]);
16
+ readonly emptyMessage = input('No agency payouts yet.');
17
+ readonly format = formatMinorCurrency;
18
+ }