@nauticana/sail 1.0.0 → 1.0.1

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 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).
318
+ sail ships a shared `BillingService` and nine 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
 
@@ -337,6 +337,8 @@ export class PricingPage {
337
337
  | `createCheckout(req)` | `POST /api/billing/checkout` | `Observable<CheckoutResponse>` |
338
338
  | `getSubscription()` | `GET /api/billing/subscription` | `Observable<Subscription>` |
339
339
  | `cancelSubscription()` | `POST /api/billing/subscription/cancel` | `Observable<void>` |
340
+ | `changePlan(planId)` | `POST /api/billing/subscription/change` | `Observable<void>` |
341
+ | `setSeats(seats)` | `POST /api/billing/subscription/seats` | `Observable<void>` |
340
342
  | `listInvoices()` | `GET /api/billing/invoices` | `Observable<Invoice[]>` |
341
343
  | `listPaymentMethods()` | `GET /api/billing/payment-methods` | `Observable<PaymentMethod[]>` |
342
344
  | `createPortalSession()` | `POST /api/billing/portal` | `Observable<PortalResponse>` |
@@ -463,13 +465,64 @@ A `TableAction` with `kind: 'redirect'` makes `BaseTable.executeAction` POST and
463
465
 
464
466
  > **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
467
 
468
+ ### Status chip (v1.0.1)
469
+
470
+ `<sail-status-chip>` resolves a code to its caption from the client-cache `constant_value` dictionary — no per-app `@switch`. It defaults to the `SUBSCRIPTION_STATUS` domain (so `T` trialing / `X` past-due render with no app code) and works for any constant domain via `[domain]`. The code is exposed as `[attr.data-status]` so the app can colour the chip:
471
+
472
+ ```html
473
+ <sail-status-chip [value]="sub()?.status"></sail-status-chip>
474
+ <sail-status-chip [value]="invoice.status" domain="INVOICE_STATUS"></sail-status-chip>
475
+ ```
476
+
477
+ > Replaces a downstream app's hardcoded status `@switch` (e.g. seo's): captions are backend-authoritative — when a dictionary row is absent the raw code shows, never a fabricated label.
478
+
479
+ ### Trial banner (v1.0.1)
480
+
481
+ `<sail-trial-banner>` shows "Trial ends in N days" from `Subscription.trialEnd`; it renders nothing when the value is empty, unparseable, or already past, so it can sit unconditionally in the template:
482
+
483
+ ```html
484
+ <sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
485
+ ```
486
+
487
+ ### Seat selector (v1.0.1)
488
+
489
+ `<sail-seat-selector>` is a presentational quantity stepper seeded from `Subscription.seats`. Feed the result into checkout (`[quantity]` / metadata) for new subs, or `BillingService.setSeats()` for an existing one:
490
+
491
+ ```html
492
+ <sail-seat-selector [seats]="sub()?.seats ?? 1" [min]="1" (seatsChange)="seats.set($event)"></sail-seat-selector>
493
+ ```
494
+
495
+ ### Plan change vs first-time checkout (v1.0.1)
496
+
497
+ First-time checkout uses `<sail-checkout-button>` (provider-hosted). For an **existing** subscription, upgrade/downgrade is a distinct path — `BillingService.changePlan(planId)` routes to keel's `SubscriptionLifecycle.ChangePlan` via an endpoint the app exposes. Wire the picker's selection to whichever path applies:
498
+
499
+ ```typescript
500
+ onPlanPicked(planId: string) {
501
+ this.sub() ? this.billing.changePlan(planId).subscribe() : this.startCheckout(planId);
502
+ }
503
+ ```
504
+
505
+ `<sail-plan-selector>` varies its CTA copy by `PublicPlan.activationMode` ("Start trial" for `T`, "Subscribe" for `P`, "Activate" for `F`); override or extend via `[ctaLabels]="{ S: 'Add seats' }"`.
506
+
507
+ ### Dunning banner (v1.0.1)
508
+
509
+ `<sail-dunning-banner>` shows only when `Subscription.status === 'X'` (past-due / keel `SetDunningState`), prompting the partner to update their card and deep-linking to the provider portal via an embedded `<sail-portal-button>`:
510
+
511
+ ```html
512
+ <sail-dunning-banner [status]="sub()?.status"></sail-dunning-banner>
513
+ ```
514
+
466
515
  ### Compose a full Billing screen
467
516
 
468
517
  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
518
 
470
519
  ```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>
520
+ <sail-dunning-banner [status]="sub()?.status"></sail-dunning-banner>
521
+ <sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
522
+ <sail-status-chip [value]="sub()?.status"></sail-status-chip>
523
+ <sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="onPlanPicked($event)"></sail-plan-selector>
524
+ <sail-seat-selector [seats]="sub()?.seats ?? 1" (seatsChange)="seats.set($event)"></sail-seat-selector>
525
+ <sail-checkout-button [priceId]="priceFor(selected())" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
473
526
  <sail-usage-meter [meters]="usage()"></sail-usage-meter>
474
527
  <sail-payment-methods></sail-payment-methods>
475
528
  <sail-portal-button></sail-portal-button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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": [
@@ -0,0 +1,6 @@
1
+ @if (isPastDue()) {
2
+ <div class="dunning-banner" role="alert">
3
+ <span class="dunning-message">{{ message() }}</span>
4
+ <sail-portal-button label="Update your card"></sail-portal-button>
5
+ </div>
6
+ }
@@ -0,0 +1,23 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+ import { PortalButtonComponent } from './portal_button';
3
+
4
+ /**
5
+ * Past-due (dunning) banner. Shows only when the subscription status is the
6
+ * past-due code 'X' (keel `SetDunningState`), prompting the partner to update
7
+ * their card and deep-linking to the provider portal via `<sail-portal-button>`.
8
+ * Renders nothing otherwise, so it can sit unconditionally in the template.
9
+ */
10
+ @Component({
11
+ selector: 'sail-dunning-banner',
12
+ templateUrl: './dunning_banner.html',
13
+ changeDetection: ChangeDetectionStrategy.OnPush,
14
+ encapsulation: ViewEncapsulation.None,
15
+ imports: [PortalButtonComponent],
16
+ })
17
+ export class DunningBannerComponent {
18
+ /** Subscription.status — the banner shows when this is 'X' (past-due). */
19
+ readonly status = input<string | undefined>(undefined);
20
+ readonly message = input('Your payment is past due — please update your card to keep your subscription active.');
21
+
22
+ readonly isPastDue = computed(() => this.status() === 'X');
23
+ }
@@ -14,7 +14,7 @@
14
14
  </ul>
15
15
  }
16
16
  <button matButton="elevated" class="primary" type="button" (click)="onSelect(plan.id)">
17
- @if (plan.id === selected()) { Selected } @else { Choose }
17
+ @if (plan.id === selected()) { Selected } @else { {{ ctas()[plan.id] }} }
18
18
  </button>
19
19
  </div>
20
20
  }
@@ -1,10 +1,14 @@
1
- import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, output } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
2
  import { MatButtonModule } from '@angular/material/button';
3
3
  import { PublicPlan } from '../../model/appdata';
4
4
 
5
5
  /**
6
6
  * Plan picker. Pure presentational component — stateless, no service calls.
7
7
  * The caller provides the plans and handles the selection event.
8
+ *
9
+ * The per-plan CTA copy varies by `PublicPlan.activationMode` (e.g. 'T' →
10
+ * "Start trial", 'P' → "Subscribe"); override or extend the mapping via
11
+ * `[ctaLabels]` (e.g. `{ S: 'Add seats' }`).
8
12
  */
9
13
  @Component({
10
14
  selector: 'sail-plan-selector',
@@ -14,11 +18,30 @@ import { PublicPlan } from '../../model/appdata';
14
18
  imports: [MatButtonModule],
15
19
  })
16
20
  export class PlanSelectorComponent {
21
+ private static readonly DEFAULT_CTA: Record<string, string> = {
22
+ T: 'Start trial',
23
+ F: 'Activate',
24
+ P: 'Subscribe',
25
+ };
26
+
17
27
  readonly plans = input<PublicPlan[]>([]);
18
28
  readonly selected = input<string | undefined>(undefined);
19
29
  readonly features = input<{[planId: string]: string[]}>({});
30
+ /** activationMode → CTA copy; overrides/extends the built-in defaults. */
31
+ readonly ctaLabels = input<{[activationMode: string]: string}>({});
20
32
  readonly selectionChange = output<string>();
21
33
 
34
+ /** planId → CTA label, resolved from activationMode (override wins). */
35
+ readonly ctas = computed<Record<string, string>>(() => {
36
+ const overrides = this.ctaLabels();
37
+ const out: Record<string, string> = {};
38
+ for (const plan of this.plans()) {
39
+ const mode = plan.activationMode ?? '';
40
+ out[plan.id] = overrides[mode] ?? PlanSelectorComponent.DEFAULT_CTA[mode] ?? 'Choose';
41
+ }
42
+ return out;
43
+ });
44
+
22
45
  onSelect(planId: string): void {
23
46
  this.selectionChange.emit(planId);
24
47
  }
@@ -0,0 +1,15 @@
1
+ <div class="seat-selector" role="group" aria-label="Seats">
2
+ <button matIconButton type="button"
3
+ aria-label="Decrease seats"
4
+ [disabled]="!canDecrease()"
5
+ (click)="decrease()">
6
+ <mat-icon>remove</mat-icon>
7
+ </button>
8
+ <span class="seat-count" aria-live="polite">{{ seats() }}</span>
9
+ <button matIconButton type="button"
10
+ aria-label="Increase seats"
11
+ [disabled]="!canIncrease()"
12
+ (click)="increase()">
13
+ <mat-icon>add</mat-icon>
14
+ </button>
15
+ </div>
@@ -0,0 +1,54 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, effect, input, output, signal } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { MatIconModule } from '@angular/material/icon';
4
+
5
+ /**
6
+ * Seat quantity stepper. Presentational — emits the chosen count; the caller
7
+ * feeds it into `<sail-checkout-button [quantity]>` / checkout metadata or a
8
+ * `BillingService.setSeats()` call. Seed `[seats]` from `Subscription.seats`.
9
+ */
10
+ @Component({
11
+ selector: 'sail-seat-selector',
12
+ templateUrl: './seat_selector.html',
13
+ changeDetection: ChangeDetectionStrategy.OnPush,
14
+ encapsulation: ViewEncapsulation.None,
15
+ imports: [MatButtonModule, MatIconModule],
16
+ })
17
+ export class SeatSelectorComponent {
18
+ /** Initial / parent-bound seat count. */
19
+ readonly seatsInput = input(1, { alias: 'seats' });
20
+ /** Minimum selectable seats. */
21
+ readonly min = input(1);
22
+ /** Maximum selectable seats; unbounded when undefined. */
23
+ readonly max = input<number | undefined>(undefined);
24
+ readonly seatsChange = output<number>();
25
+
26
+ /** Locally-mutated value, synced from the parent-bound input. */
27
+ readonly seats = signal(1);
28
+
29
+ readonly canDecrease = computed(() => this.seats() > this.min());
30
+ readonly canIncrease = computed(() => {
31
+ const max = this.max();
32
+ return max === undefined || this.seats() < max;
33
+ });
34
+
35
+ constructor() {
36
+ effect(() => this.seats.set(this.seatsInput()));
37
+ }
38
+
39
+ decrease(): void {
40
+ this.commit(Math.max(this.min(), this.seats() - 1));
41
+ }
42
+
43
+ increase(): void {
44
+ const max = this.max();
45
+ const next = this.seats() + 1;
46
+ this.commit(max === undefined ? next : Math.min(max, next));
47
+ }
48
+
49
+ private commit(value: number): void {
50
+ if (value === this.seats()) return;
51
+ this.seats.set(value);
52
+ this.seatsChange.emit(value);
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ @if (value()) {
2
+ <span class="status-chip" [attr.data-status]="value()">{{ caption() }}</span>
3
+ }
@@ -0,0 +1,39 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input } from '@angular/core';
2
+ import { toSignal } from '@angular/core/rxjs-interop';
3
+ import { BaseAuthService } from '../../service/auth.service';
4
+
5
+ /**
6
+ * Status chip/label that resolves a code to its caption from the client cache
7
+ * `constant_value` dictionary — no per-app `@switch`. Defaults to the
8
+ * `SUBSCRIPTION_STATUS` domain (A active / T trialing / X past-due / …) but
9
+ * works for any constant domain via `[domain]`.
10
+ *
11
+ * The caption is backend-authoritative (read from the dictionary); when the
12
+ * domain row is absent the raw code is shown rather than a fabricated label.
13
+ * `[attr.data-status]` carries the code so the consuming app can colour the
14
+ * chip per status (the library ships no CSS).
15
+ */
16
+ @Component({
17
+ selector: 'sail-status-chip',
18
+ templateUrl: './status_chip.html',
19
+ changeDetection: ChangeDetectionStrategy.OnPush,
20
+ encapsulation: ViewEncapsulation.None,
21
+ })
22
+ export class StatusChipComponent {
23
+ private readonly auth = inject(BaseAuthService);
24
+
25
+ /** The code to resolve (e.g. a Subscription.status value). */
26
+ readonly value = input<string>('');
27
+ /** Constant-value domain to look the caption up in. */
28
+ readonly domain = input<string>('SUBSCRIPTION_STATUS');
29
+
30
+ // Recompute when the cache (app data) loads or the inputs change.
31
+ private readonly appData = toSignal(this.auth.getAppData());
32
+
33
+ readonly caption = computed<string>(() => {
34
+ this.appData(); // dependency: re-read the dictionary once the cache is hydrated
35
+ const code = this.value();
36
+ const match = this.auth.getDomainValues(this.domain())?.find((v) => v.Value === code);
37
+ return match?.Caption ?? code;
38
+ });
39
+ }
@@ -0,0 +1,3 @@
1
+ @if (daysLeft() > 0) {
2
+ <div class="trial-banner" role="status">{{ message() }}</div>
3
+ }
@@ -0,0 +1,30 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+
3
+ /**
4
+ * "Trial ends in N days" banner, bound to `Subscription.trialEnd`. Renders
5
+ * nothing when `trialEnd` is empty, unparseable, or already past, so it can be
6
+ * left in the template unconditionally.
7
+ */
8
+ @Component({
9
+ selector: 'sail-trial-banner',
10
+ templateUrl: './trial_banner.html',
11
+ changeDetection: ChangeDetectionStrategy.OnPush,
12
+ encapsulation: ViewEncapsulation.None,
13
+ })
14
+ export class TrialBannerComponent {
15
+ /** ISO timestamp the trial ends (Subscription.trialEnd). */
16
+ readonly trialEnd = input<string | undefined>(undefined);
17
+
18
+ readonly daysLeft = computed<number>(() => {
19
+ const raw = this.trialEnd();
20
+ if (!raw) return 0;
21
+ const end = Date.parse(raw);
22
+ if (Number.isNaN(end)) return 0;
23
+ return Math.ceil((end - Date.now()) / 86_400_000);
24
+ });
25
+
26
+ readonly message = computed<string>(() => {
27
+ const d = this.daysLeft();
28
+ return d === 1 ? 'Trial ends in 1 day' : `Trial ends in ${d} days`;
29
+ });
30
+ }
package/src/index.ts CHANGED
@@ -34,6 +34,10 @@ export * from './component/billing/checkout_button';
34
34
  export * from './component/billing/payment_methods';
35
35
  export * from './component/billing/portal_button';
36
36
  export * from './component/billing/usage_meter';
37
+ export * from './component/billing/status_chip';
38
+ export * from './component/billing/trial_banner';
39
+ export * from './component/billing/seat_selector';
40
+ export * from './component/billing/dunning_banner';
37
41
  export * from './component/payout/provider_onboarding';
38
42
  export * from './component/payout/bank_info_form';
39
43
  export * from './component/payment/user_payment_methods';
@@ -213,6 +213,13 @@ export interface PublicPlan {
213
213
  // not-yet-priced tiers leave it empty. Pass straight to
214
214
  // `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
215
215
  priceId?: string;
216
+ // keel `subscription_plan.activation_mode` (SUBSCRIPTION_ACTIVATION_MODE
217
+ // dictionary, single char — e.g. 'P' paid, 'F' free, 'T' trial). Drives the
218
+ // picker CTA copy ("Start trial" / "Subscribe" / …). Optional — pre-v1.0.3
219
+ // backends omit it.
220
+ activationMode?: string;
221
+ // Length of the free trial in days when `activationMode === 'T'`.
222
+ trialDays?: number;
216
223
  }
217
224
 
218
225
  // ConfirmRegisterResponse — shape returned by POST /public/confirm/register.
@@ -288,12 +295,18 @@ export interface PaginatedList<T> {
288
295
 
289
296
  export interface Subscription {
290
297
  planId: string;
291
- status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, ...
298
+ status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
292
299
  begda: string;
293
300
  endda?: string;
294
301
  monthlyCost: number;
295
302
  currency: string;
296
303
  autoRenew: boolean;
304
+ // ISO timestamp the trial ends (keel `trial_end`); empty/absent when not
305
+ // trialing. Drives `<sail-trial-banner>`.
306
+ trialEnd?: string;
307
+ // Seat quantity on the active/trial sub (keel `seats`); absent for
308
+ // non-seat plans. Drives `<sail-seat-selector>`.
309
+ seats?: number;
297
310
  }
298
311
 
299
312
  export interface Invoice {
@@ -49,6 +49,20 @@ export class BillingService extends BaseRestService {
49
49
  return this.http.post<void>(this.url(RestURL.cancelSubURL), {});
50
50
  }
51
51
 
52
+ /**
53
+ * Upgrade / downgrade the current subscription to `planId` — distinct from
54
+ * first-time checkout. Routes to keel's `SubscriptionLifecycle.ChangePlan`
55
+ * (atomic close+open) via an endpoint the app exposes.
56
+ */
57
+ changePlan(planId: string): Observable<void> {
58
+ return this.http.post<void>(this.url(RestURL.changePlanURL), { planId });
59
+ }
60
+
61
+ /** Set the seat quantity on the current active/trial subscription. */
62
+ setSeats(seats: number): Observable<void> {
63
+ return this.http.post<void>(this.url(RestURL.seatsURL), { seats });
64
+ }
65
+
52
66
  /** Partner's invoice history. */
53
67
  listInvoices(): Observable<Invoice[]> {
54
68
  return this.http.get<Invoice[]>(this.url(RestURL.invoicesURL));
@@ -20,6 +20,8 @@ export const RestURL = {
20
20
  checkoutURL: '/api/billing/checkout',
21
21
  subscriptionURL: '/api/billing/subscription',
22
22
  cancelSubURL: '/api/billing/subscription/cancel',
23
+ changePlanURL: '/api/billing/subscription/change',
24
+ seatsURL: '/api/billing/subscription/seats',
23
25
  invoicesURL: '/api/billing/invoices',
24
26
  paymentMethodsURL: '/api/billing/payment-methods',
25
27
  portalURL: '/api/billing/portal',