@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 +41 -1
- package/package.json +1 -1
- package/src/component/abstract/base_table.ts +11 -2
- package/src/component/billing/portal_button.html +10 -0
- package/src/component/billing/portal_button.ts +31 -0
- package/src/component/billing/usage_meter.html +20 -0
- package/src/component/billing/usage_meter.ts +44 -0
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +13 -0
- package/src/service/auth.service.ts +6 -2
- package/src/service/billing.service.ts +16 -0
- package/src/service/rest_url.ts +2 -0
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
|
|
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.
|
|
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: () =>
|
|
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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -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
|
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -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',
|