@nauticana/sail 0.9.5 → 1.0.0

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
@@ -315,7 +315,7 @@ If "trust this device" appears to do nothing (user is 2FA-prompted on every logi
315
315
 
316
316
  ## Billing
317
317
 
318
- sail ships a shared `BillingService` and three billing components backed by keel's payment endpoints (see [keel/SHARED_PAYMENT.md](https://github.com/nauticana/keel/blob/main/SHARED_PAYMENT.md) for the provider contract — Stripe by default, pluggable).
318
+ sail ships a shared `BillingService` and five billing components backed by keel's payment endpoints (see [keel/SHARED_PAYMENT.md](https://github.com/nauticana/keel/blob/main/SHARED_PAYMENT.md) for the provider contract — Stripe by default, pluggable).
319
319
 
320
320
  ### Service
321
321
 
@@ -339,6 +339,8 @@ export class PricingPage {
339
339
  | `cancelSubscription()` | `POST /api/billing/subscription/cancel` | `Observable<void>` |
340
340
  | `listInvoices()` | `GET /api/billing/invoices` | `Observable<Invoice[]>` |
341
341
  | `listPaymentMethods()` | `GET /api/billing/payment-methods` | `Observable<PaymentMethod[]>` |
342
+ | `createPortalSession()` | `POST /api/billing/portal` | `Observable<PortalResponse>` |
343
+ | `listUsage()` | `GET /api/billing/usage` | `Observable<UsageMeter[]>` |
342
344
 
343
345
  ### Components
344
346
 
@@ -436,6 +438,44 @@ Add to `src/styles.css` to match the rest of the library:
436
438
  .checkout-button { display: flex; flex-direction: column; gap: .5rem; }
437
439
  ```
438
440
 
441
+ ### Customer portal (v1.0.0)
442
+
443
+ `createPortalSession()` + `<sail-portal-button>` mirror checkout — POST `/api/billing/portal`, then redirect to the provider-hosted customer portal where the partner manages their payment method / subscription:
444
+
445
+ ```html
446
+ <sail-portal-button [label]="'Manage billing'"></sail-portal-button>
447
+ ```
448
+
449
+ ### Usage meters (v1.0.0)
450
+
451
+ `listUsage()` returns `UsageMeter[]` (`{ resource, used, limit }`, matching keel's `UsageItem`); `<sail-usage-meter>` renders one progress bar per resource (a `limit < 0` shows "Unlimited"):
452
+
453
+ ```typescript
454
+ readonly meters = toSignal(this.billing.listUsage(), { initialValue: [] });
455
+ ```
456
+ ```html
457
+ <sail-usage-meter [meters]="meters()"></sail-usage-meter>
458
+ ```
459
+
460
+ ### Redirect-action (v1.0.0)
461
+
462
+ A `TableAction` with `kind: 'redirect'` makes `BaseTable.executeAction` POST and then follow the returned `{ url }` instead of refreshing — so checkout / portal / any provider-hosted handoff can be declared as a basis `table_action` row with **no bespoke component**. The backend action handler returns `{ "url": "https://…" }`.
463
+
464
+ > **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.
465
+
466
+ ### Compose a full Billing screen
467
+
468
+ A complete screen is the pieces above assembled over `BillingService` — no metadata CRUD (billing data is partner-scoped, read-mostly, money-formatted, with an external-redirect action, which `TableList`/`TableEdit` don't model):
469
+
470
+ ```html
471
+ <sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="selected.set($event)"></sail-plan-selector>
472
+ <sail-checkout-button [priceId]="priceFor(selected())" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
473
+ <sail-usage-meter [meters]="usage()"></sail-usage-meter>
474
+ <sail-payment-methods></sail-payment-methods>
475
+ <sail-portal-button></sail-portal-button>
476
+ <!-- invoices: render listInvoices() rows directly -->
477
+ ```
478
+
439
479
  ## Consent capture
440
480
 
441
481
  `ConsentGateComponent` is a reusable signup-consent primitive that mirrors keel's `user.SignupConsent` structure. It always renders two required checkboxes (`privacy_policy`, `cross_border`) and lets you declare any number of optional consents.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "0.9.5",
3
+ "version": "1.0.0",
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
  "files": [
@@ -114,8 +114,17 @@ export abstract class BaseTable {
114
114
  if (!this.beforeExecuteAction(action, record)) return;
115
115
  if (action.confirmMessage && !confirm(action.confirmMessage)) return;
116
116
  const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
117
- this.backendService.executeAction(action.method, body).subscribe({
118
- next: () => this.onActionSuccess(action, record),
117
+ this.backendService.executeAction<{ url?: string }>(action.method, body).subscribe({
118
+ next: (resp) => {
119
+ // A 'redirect' action POSTs then follows the returned {url}
120
+ // (provider-hosted checkout / portal / any external handoff
121
+ // declared as basis table_action metadata — no component).
122
+ if (action.kind === 'redirect' && resp?.url) {
123
+ window.location.href = resp.url;
124
+ return;
125
+ }
126
+ this.onActionSuccess(action, record);
127
+ },
119
128
  error: (err) => {
120
129
  console.error(`Action ${action.action} failed`, err);
121
130
  alert(err?.error?.detail ?? `Failed to ${action.caption}`);
@@ -0,0 +1,10 @@
1
+ <div class="portal-button">
2
+ <button matButton="elevated" class="primary" type="button"
3
+ [disabled]="loading()"
4
+ (click)="openPortal()">
5
+ @if (loading()) { Opening… } @else { {{ label() }} }
6
+ </button>
7
+ @if (errorMessage()) {
8
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
9
+ }
10
+ </div>
@@ -0,0 +1,31 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, input } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { BaseAsync } from '../abstract/base_async';
4
+ import { BillingService } from '../../service/billing.service';
5
+
6
+ /**
7
+ * Button that opens a provider customer-portal session and redirects the
8
+ * browser to the returned URL — the partner manages their payment method /
9
+ * subscription on the provider's hosted page. Mirrors CheckoutButtonComponent;
10
+ * uses BaseAsync for loading/error state.
11
+ */
12
+ @Component({
13
+ selector: 'sail-portal-button',
14
+ templateUrl: './portal_button.html',
15
+ changeDetection: ChangeDetectionStrategy.OnPush,
16
+ encapsulation: ViewEncapsulation.None,
17
+ imports: [MatButtonModule],
18
+ })
19
+ export class PortalButtonComponent extends BaseAsync {
20
+ private readonly billing = inject(BillingService);
21
+
22
+ readonly label = input('Manage billing');
23
+
24
+ openPortal(): void {
25
+ this.run(
26
+ this.billing.createPortalSession(),
27
+ (resp) => { window.location.href = resp.portalUrl; },
28
+ 'Could not open the billing portal. Please try again.',
29
+ );
30
+ }
31
+ }
@@ -0,0 +1,20 @@
1
+ <div class="usage-meter">
2
+ @for (r of rows(); track r.resource) {
3
+ <div class="usage-row">
4
+ <div class="usage-label">{{ r.resource }}</div>
5
+ @if (r.hasBar) {
6
+ <div class="usage-bar" role="progressbar"
7
+ [attr.aria-label]="r.resource"
8
+ [attr.aria-valuemin]="0"
9
+ [attr.aria-valuemax]="r.ariaMax"
10
+ [attr.aria-valuenow]="r.ariaNow"
11
+ [attr.aria-valuetext]="r.valueText">
12
+ <div class="usage-fill" [style.width.%]="r.percent"></div>
13
+ </div>
14
+ }
15
+ <div class="usage-value">{{ r.valueText }}</div>
16
+ </div>
17
+ } @empty {
18
+ <div class="usage-empty">No usage data.</div>
19
+ }
20
+ </div>
@@ -0,0 +1,44 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+ import { UsageMeter } from '../../model/appdata';
3
+
4
+ /** Per-row view model derived from a UsageMeter for accessible rendering. */
5
+ interface UsageRow {
6
+ resource: string;
7
+ hasBar: boolean; // limit > 0 → determinate progress bar; else text-only
8
+ percent: number; // fill width 0..100 (clamped)
9
+ ariaMax: number; // limit (only meaningful when hasBar)
10
+ ariaNow: number; // used, clamped to [0, limit] for a valid progressbar
11
+ valueText: string; // "used / limit", "used / Unlimited" (<0), or "used / 0"
12
+ }
13
+
14
+ /**
15
+ * Presentational usage meters — one progress bar per resource (used / limit).
16
+ * Stateless; the caller (or BillingService.listUsage) supplies the data.
17
+ *
18
+ * Limit semantics mirror keel's UsageItem: `limit < 0` = unlimited (keel's
19
+ * documented sentinel), `limit === 0` = a real zero / unprovisioned cap. Only
20
+ * `limit > 0` renders a determinate progress bar; the other two are text-only.
21
+ */
22
+ @Component({
23
+ selector: 'sail-usage-meter',
24
+ templateUrl: './usage_meter.html',
25
+ changeDetection: ChangeDetectionStrategy.OnPush,
26
+ encapsulation: ViewEncapsulation.None,
27
+ })
28
+ export class UsageMeterComponent {
29
+ readonly meters = input<UsageMeter[]>([]);
30
+
31
+ readonly rows = computed<UsageRow[]>(() =>
32
+ this.meters().map((m) => {
33
+ const hasBar = m.limit > 0;
34
+ return {
35
+ resource: m.resource,
36
+ hasBar,
37
+ percent: hasBar ? Math.min(100, Math.round((m.used / m.limit) * 100)) : 0,
38
+ ariaMax: m.limit,
39
+ ariaNow: hasBar ? Math.min(m.used, m.limit) : m.used,
40
+ valueText: `${m.used} / ${m.limit < 0 ? 'Unlimited' : m.limit}`,
41
+ };
42
+ }),
43
+ );
44
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ export * from './component/security/trusted_devices';
32
32
  export * from './component/billing/plan_selector';
33
33
  export * from './component/billing/checkout_button';
34
34
  export * from './component/billing/payment_methods';
35
+ export * from './component/billing/portal_button';
36
+ export * from './component/billing/usage_meter';
35
37
  export * from './component/payout/provider_onboarding';
36
38
  export * from './component/payout/bank_info_form';
37
39
  export * from './component/payment/user_payment_methods';
@@ -83,6 +83,7 @@ export interface TableAction {
83
83
  confirmMessage?: string;
84
84
  authorityObject: string; // uppercased table_name — for canExecute()
85
85
  authorityCheck: string; // uppercased action_name — for canExecute()
86
+ kind?: 'post' | 'redirect'; // 'redirect' = POST then follow the returned {url}; default 'post'
86
87
  }
87
88
 
88
89
  export interface ForeignKey {
@@ -264,6 +265,18 @@ export interface CheckoutResponse {
264
265
  checkoutUrl: string;
265
266
  }
266
267
 
268
+ /** Response of BillingService.createPortalSession — the provider customer-portal URL. */
269
+ export interface PortalResponse {
270
+ portalUrl: string;
271
+ }
272
+
273
+ /** One resource's current-period usage vs its plan limit (matches keel UsageItem). */
274
+ export interface UsageMeter {
275
+ resource: string;
276
+ used: number;
277
+ limit: number;
278
+ }
279
+
267
280
  // REST list endpoints return paginated wrappers. `BackendService.list<T>()`
268
281
  // unwraps to the array; use `listPaginated<T>()` to get the metadata.
269
282
  export interface PaginatedList<T> {
@@ -16,6 +16,10 @@ import { CanActivateFn, Router, Routes } from '@angular/router';
16
16
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
17
17
  import { BaseRestService } from './base_rest.service';
18
18
 
19
+ /** Sort dropdown options by their visible label so long lookup lists are scannable. */
20
+ const byCaption = (a: ConstantValue, b: ConstantValue) =>
21
+ (a.Caption ?? '').localeCompare(b.Caption ?? '', undefined, { numeric: true, sensitivity: 'base' });
22
+
19
23
  @Injectable()
20
24
  export abstract class BaseAuthService extends BaseRestService {
21
25
  protected readonly router = inject(Router);
@@ -416,7 +420,7 @@ export abstract class BaseAuthService extends BaseRestService {
416
420
  Value: key,
417
421
  Caption: domainMap[key],
418
422
  [opField]: 'S',
419
- } as unknown as ConstantValue));
423
+ } as unknown as ConstantValue)).sort(byCaption);
420
424
  }
421
425
 
422
426
  getTableValues(tableName: string): ConstantValue[] | undefined {
@@ -430,7 +434,7 @@ export abstract class BaseAuthService extends BaseRestService {
430
434
  Value: key,
431
435
  Caption: tableMap[key],
432
436
  [opField]: 'S',
433
- } as unknown as ConstantValue));
437
+ } as unknown as ConstantValue)).sort(byCaption);
434
438
  }
435
439
 
436
440
  getMenus(): Observable<ApplicationMenu[]> {
@@ -7,8 +7,10 @@ import {
7
7
  CheckoutResponse,
8
8
  Invoice,
9
9
  PaymentMethod,
10
+ PortalResponse,
10
11
  PublicPlan,
11
12
  Subscription,
13
+ UsageMeter,
12
14
  } from '../model/appdata';
13
15
 
14
16
  /**
@@ -56,4 +58,18 @@ export class BillingService extends BaseRestService {
56
58
  listPaymentMethods(): Observable<PaymentMethod[]> {
57
59
  return this.http.get<PaymentMethod[]>(this.url(RestURL.paymentMethodsURL));
58
60
  }
61
+
62
+ /**
63
+ * Create a provider customer-portal session; caller redirects to the URL so
64
+ * the partner can manage their payment method / subscription on the
65
+ * provider's hosted page.
66
+ */
67
+ createPortalSession(): Observable<PortalResponse> {
68
+ return this.http.post<PortalResponse>(this.url(RestURL.portalURL), {});
69
+ }
70
+
71
+ /** Current-period usage per resource (used vs plan limit). */
72
+ listUsage(): Observable<UsageMeter[]> {
73
+ return this.http.get<UsageMeter[]>(this.url(RestURL.usageURL));
74
+ }
59
75
  }
@@ -22,6 +22,8 @@ export const RestURL = {
22
22
  cancelSubURL: '/api/billing/subscription/cancel',
23
23
  invoicesURL: '/api/billing/invoices',
24
24
  paymentMethodsURL: '/api/billing/payment-methods',
25
+ portalURL: '/api/billing/portal',
26
+ usageURL: '/api/billing/usage',
25
27
  // OTP / social / push / account-lifecycle endpoints
26
28
  otpSendURL: '/public/otp/send',
27
29
  otpVerifyURL: '/public/otp/verify',