@nauticana/sail 1.0.2 → 1.0.3

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 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).
318
+ sail ships a shared `BillingService` and ten 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
 
@@ -356,10 +356,24 @@ export class PricingPage {
356
356
  </sail-plan-selector>
357
357
  ```
358
358
 
359
- **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. `PublicPlan.priceId` lets you wire the picker directly to checkout without a local mapping table:
359
+ **Offer / price picker (v1.0.5)** — a plan no longer has a single price. keel v1.0.5 sells per-plan **offers** in the `subscription_plan_price` table (billing cycle × commitment term, each with its own `provider_price_id`), surfaced as `PublicPlan.prices: PlanPrice[]`. The customer picks an offer **at checkout**; its `priceId` is what flows to `<sail-checkout-button>`:
360
+
361
+ ```html
362
+ <sail-price-selector
363
+ [prices]="selectedPlan().prices ?? []"
364
+ [selected]="offer()?.priceId"
365
+ (selectionChange)="offer.set($event)">
366
+ </sail-price-selector>
367
+ ```
368
+
369
+ `<sail-price-selector>` is presentational (a `radiogroup` of offers), formats each amount with `Intl` from the offer's currency (so JPY/BHD render with the right number of decimals), and maps PERIOD_TYPE codes to copy via `[cycleLabels]`. It emits the full `PlanPrice` so you keep the chosen terms, not just the id.
370
+
371
+ > **Migration.** The flat `PublicPlan.priceId` is deprecated (keel v1.0.5 dropped `subscription_plan.provider_price_id`) — read the id from the selected offer (`offer().priceId`). **keel v1.0.7 also removed `PublicPlan.monthlyCost`/`annualCost`** from both `/public/plans` and the billing catalog, so any plan-card code reading them now gets `undefined`. Derive a display price from the offers with `fromPriceLabel(plan.prices)` (used by `<sail-plan-selector>`), or render the per-offer amounts via `<sail-price-selector>`. These fields are removed from sail's `PublicPlan` type so the compiler flags any remaining references.
372
+
373
+ **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. Feed it the chosen offer's `priceId`:
360
374
  ```html
361
375
  <sail-checkout-button
362
- [priceId]="selectedPlan().priceId!"
376
+ [priceId]="offer()!.priceId!"
363
377
  [mode]="'subscription'"
364
378
  [successUrl]="'https://app.example.com/billing/done'"
365
379
  [cancelUrl]="'https://app.example.com/billing'"
@@ -521,8 +535,9 @@ A complete screen is the pieces above assembled over `BillingService` — no met
521
535
  <sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
522
536
  <sail-status-chip [value]="sub()?.status"></sail-status-chip>
523
537
  <sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="onPlanPicked($event)"></sail-plan-selector>
538
+ <sail-price-selector [prices]="selectedPlan()?.prices ?? []" [selected]="offer()?.priceId" (selectionChange)="offer.set($event)"></sail-price-selector>
524
539
  <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>
540
+ <sail-checkout-button [priceId]="offer()!.priceId!" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
526
541
  <sail-usage-meter [meters]="usage()"></sail-usage-meter>
527
542
  <sail-payment-methods></sail-payment-methods>
528
543
  <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.2",
3
+ "version": "1.0.3",
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": [
@@ -2,10 +2,11 @@
2
2
  @for (plan of plans(); track plan.id) {
3
3
  <div class="plan-card" [class.plan-selected]="plan.id === selected()">
4
4
  <h3 class="plan-caption">{{ plan.caption }}</h3>
5
- <div class="plan-price">
6
- <span class="plan-amount">{{ plan.monthlyCost }}</span>
7
- <span class="plan-period">/ month</span>
8
- </div>
5
+ @if (priceLabels()[plan.id]; as priceLabel) {
6
+ <div class="plan-price">
7
+ <span class="plan-amount">{{ priceLabel }}</span>
8
+ </div>
9
+ }
9
10
  @if (featuresFor(plan.id).length > 0) {
10
11
  <ul class="plan-features">
11
12
  @for (feature of featuresFor(plan.id); track feature) {
@@ -1,6 +1,7 @@
1
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
+ import { fromPriceLabel } from '../../util/money';
4
5
 
5
6
  /**
6
7
  * Plan picker. Pure presentational component — stateless, no service calls.
@@ -42,6 +43,13 @@ export class PlanSelectorComponent {
42
43
  return out;
43
44
  });
44
45
 
46
+ /** planId → indicative "from" price derived from the plan's offers ('' = none). */
47
+ readonly priceLabels = computed<Record<string, string>>(() => {
48
+ const out: Record<string, string> = {};
49
+ for (const plan of this.plans()) out[plan.id] = fromPriceLabel(plan.prices);
50
+ return out;
51
+ });
52
+
45
53
  onSelect(planId: string): void {
46
54
  this.selectionChange.emit(planId);
47
55
  }
@@ -0,0 +1,17 @@
1
+ <div class="price-selector" role="radiogroup" aria-label="Billing options">
2
+ @for (offer of offers(); track offer.key) {
3
+ <button class="price-offer" type="button"
4
+ role="radio"
5
+ [class.price-selected]="offer.key === selected()"
6
+ [attr.aria-checked]="offer.key === selected()"
7
+ (click)="onSelect(offer)">
8
+ <span class="price-amount">{{ offer.amountText }}</span>
9
+ <span class="price-cycle">/ {{ offer.cycleText }}</span>
10
+ @if (offer.termText) {
11
+ <span class="price-term">{{ offer.termText }}</span>
12
+ }
13
+ </button>
14
+ } @empty {
15
+ <p class="empty-state">No billing options available.</p>
16
+ }
17
+ </div>
@@ -0,0 +1,66 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
+ import { PlanPrice } from '../../model/appdata';
3
+ import { formatCurrency } from '../../util/money';
4
+
5
+ /** Per-offer view model for accessible, currency-formatted rendering. */
6
+ interface OfferRow {
7
+ key: string; // stable id for tracking / selection (priceId or composite)
8
+ price: PlanPrice;
9
+ amountText: string; // currency-formatted major amount
10
+ cycleText: string; // "Monthly" / "Annual" / …
11
+ termText: string; // commitment note, '' when the term is a single cycle
12
+ }
13
+
14
+ /**
15
+ * Offer picker for a plan's `subscription_plan_price` rows (keel v1.0.5). A plan
16
+ * sells several offers (billing cycle × commitment term); the customer picks one
17
+ * at checkout, then its `priceId` feeds `<sail-checkout-button [priceId]>`.
18
+ *
19
+ * Pure presentational — stateless. Amounts are formatted with `Intl` from each
20
+ * offer's currency, so non-2-decimal currencies (JPY 0, BHD 3) render correctly.
21
+ * Period codes map to copy via `[cycleLabels]` (override/extend the defaults).
22
+ */
23
+ @Component({
24
+ selector: 'sail-price-selector',
25
+ templateUrl: './price_selector.html',
26
+ changeDetection: ChangeDetectionStrategy.OnPush,
27
+ encapsulation: ViewEncapsulation.None,
28
+ })
29
+ export class PriceSelectorComponent {
30
+ private static readonly DEFAULT_CYCLE: Record<string, string> = {
31
+ D: 'Daily',
32
+ W: 'Weekly',
33
+ M: 'Monthly',
34
+ Q: 'Quarterly',
35
+ A: 'Annual',
36
+ };
37
+
38
+ readonly prices = input<PlanPrice[]>([]);
39
+ /** Selected offer key — a `priceId`, or the composite key when priceId is empty. */
40
+ readonly selected = input<string | undefined>(undefined);
41
+ /** PERIOD_TYPE code → label; overrides/extends the built-in defaults. */
42
+ readonly cycleLabels = input<{[periodCode: string]: string}>({});
43
+ readonly selectionChange = output<PlanPrice>();
44
+
45
+ readonly offers = computed<OfferRow[]>(() => {
46
+ const labels = this.cycleLabels();
47
+ const cycleLabel = (code: string) =>
48
+ labels[code] ?? PriceSelectorComponent.DEFAULT_CYCLE[code] ?? code;
49
+ return this.prices().map((p) => {
50
+ const singleCycle = p.termCount === 1 && p.termType === p.billingCycle;
51
+ return {
52
+ key: p.priceId || `${p.billingCycle}-${p.termType}-${p.termCount}`,
53
+ price: p,
54
+ amountText: formatCurrency(p.amount, p.currency),
55
+ cycleText: cycleLabel(p.billingCycle),
56
+ termText: singleCycle
57
+ ? ''
58
+ : `${p.termCount} ${cycleLabel(p.termType)}${p.termCount > 1 ? 's' : ''} commitment`,
59
+ };
60
+ });
61
+ });
62
+
63
+ onSelect(offer: OfferRow): void {
64
+ this.selectionChange.emit(offer.price);
65
+ }
66
+ }
@@ -46,7 +46,7 @@
46
46
  <mat-select formControlName="PlanID" required>
47
47
  @for (p of plans(); track p.id) {
48
48
  <mat-option [value]="p.id">
49
- {{ p.caption }}@if (p.monthlyCost > 0) { — \${{ p.monthlyCost }}/mo }
49
+ {{ p.caption }}@if (priceHint(p); as hint) { — {{ hint }} }
50
50
  </mat-option>
51
51
  }
52
52
  </mat-select>
@@ -13,6 +13,7 @@ import { firstValueFrom, map, merge } from 'rxjs';
13
13
  import { HttpClient } from '@angular/common/http';
14
14
  import { PartnerRegistration, PublicPlan } from '../../model/appdata';
15
15
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
16
+ import { fromPriceLabel } from '../../util/money';
16
17
 
17
18
  @Component({
18
19
  selector: 'sail-register',
@@ -76,6 +77,11 @@ export class RegisterComponent implements OnInit {
76
77
  return this.guiConfig.googleMapsApiKey ?? '';
77
78
  }
78
79
 
80
+ /** Indicative "from" price for a plan option, derived from its offers ('' = free/none). */
81
+ protected priceHint(plan: PublicPlan): string {
82
+ return fromPriceLabel(plan.prices);
83
+ }
84
+
79
85
  ngOnInit() {
80
86
  this.billing.listPlans().subscribe({
81
87
  next: (list) => this.plans.set(list ?? []),
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from './component/security/twofactor_setup';
30
30
  export * from './component/security/twofactor_verify';
31
31
  export * from './component/security/trusted_devices';
32
32
  export * from './component/billing/plan_selector';
33
+ export * from './component/billing/price_selector';
33
34
  export * from './component/billing/checkout_button';
34
35
  export * from './component/billing/payment_methods';
35
36
  export * from './component/billing/portal_button';
@@ -55,3 +56,4 @@ export * from './service/rest_url';
55
56
  export * from './service/label.service';
56
57
  export * from './service/base_rest.service';
57
58
  export * from './util/text';
59
+ export * from './util/money';
@@ -201,17 +201,39 @@ export interface PartnerRegistration {
201
201
  PlanID: string;
202
202
  }
203
203
 
204
+ // One purchasable offer for a plan — a (billing cycle × commitment term) row of
205
+ // keel's `subscription_plan_price` table (v1.0.5). A plan sells several; the
206
+ // customer picks one at checkout via `<sail-price-selector>`. Mirrors keel
207
+ // `billing.PlanPrice`.
208
+ export interface PlanPrice {
209
+ billingCycle: string; // PERIOD_TYPE code (D/W/M/A) — how often charged
210
+ termCount: number; // commitment length, in termType units
211
+ termType: string; // PERIOD_TYPE code — commitment/pricing unit
212
+ amountMinor: number; // price per termType unit, in minor units (cents)
213
+ amount: number; // same amount in major units (currency-exponent applied)
214
+ currency: string; // ISO 4217
215
+ // Provider price id (Stripe Price). Empty for free/unpriced offers; pass to
216
+ // `<sail-checkout-button [priceId]>` / `createCheckout({ priceId })`.
217
+ priceId?: string;
218
+ }
219
+
204
220
  // PublicPlan — shape returned by GET /public/plans. Used on the registration
205
221
  // page to render the plan picker and to drive `<sail-checkout-button>` flows.
206
222
  export interface PublicPlan {
207
223
  id: string;
208
224
  caption: string;
209
- monthlyCost: number;
210
- annualCost: number;
211
- // Stripe price ID (or provider-equivalent). Optional because keel only
212
- // populates it when a plan is wired to a billing provider; FREE plans and
213
- // not-yet-priced tiers leave it empty. Pass straight to
214
- // `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
225
+ // NOTE: keel v1.0.7 REMOVED the flat `monthlyCost`/`annualCost` from the plan
226
+ // JSON (both `/public/plans` and the billing catalog). Price lives per-offer
227
+ // in `prices[]`; derive a display price with `fromPriceLabel(plan.prices)`.
228
+ // Purchasable offers (keel v1.0.5 `subscription_plan_price`, nested under the
229
+ // plan). The source of truth for checkout — feed a chosen offer's `priceId`
230
+ // to `<sail-checkout-button>`. Returned by `/public/plans` as of keel v1.0.6
231
+ // (so `<sail-price-selector>` works on the registration page too); absent on
232
+ // pre-v1.0.6 backends.
233
+ prices?: PlanPrice[];
234
+ // @deprecated keel v1.0.5 moved per-offer prices into `prices[]`
235
+ // (`subscription_plan.provider_price_id` was dropped). Use
236
+ // `prices?.[…].priceId`. Kept optional for pre-v1.0.5 backends.
215
237
  priceId?: string;
216
238
  // keel `subscription_plan.activation_mode` (SUBSCRIPTION_ACTIVATION_MODE
217
239
  // dictionary, single char — e.g. 'P' paid, 'F' free, 'T' trial). Drives the
@@ -320,6 +342,9 @@ export interface Subscription {
320
342
  monthlyCost: number;
321
343
  currency: string;
322
344
  autoRenew: boolean;
345
+ // PERIOD_TYPE code (D/W/M/A) the sub is charged on, copied from the chosen
346
+ // offer (keel `billing_cycle`); absent on pre-v1.0.5 backends.
347
+ billingCycle?: string;
323
348
  // ISO timestamp the trial ends (keel `trial_end`); empty/absent when not
324
349
  // trialing. Drives `<sail-trial-banner>`.
325
350
  trialEnd?: string;
@@ -0,0 +1,29 @@
1
+ import { PlanPrice } from '../model/appdata';
2
+
3
+ /**
4
+ * Format a major-unit amount in its currency, honouring the currency exponent
5
+ * (JPY 0 decimals, BHD 3, …) via `Intl`. Falls back to "<amount> <currency>"
6
+ * for an unknown currency code rather than throwing.
7
+ */
8
+ export function formatCurrency(amount: number, currency: string): string {
9
+ try {
10
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount);
11
+ } catch {
12
+ return `${amount} ${currency}`;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * "From" price teaser for a plan card / dropdown — the lowest priced offer,
18
+ * currency-formatted, prefixed "from " when the plan sells more than one priced
19
+ * offer. Returns '' when the plan has no priced offers (free/trial), so callers
20
+ * can omit the price line entirely. The exact offer is chosen later via
21
+ * `<sail-price-selector>`.
22
+ */
23
+ export function fromPriceLabel(prices: PlanPrice[] | undefined): string {
24
+ const priced = (prices ?? []).filter((p) => p.amount > 0);
25
+ if (priced.length === 0) return '';
26
+ const lowest = priced.reduce((a, b) => (b.amount < a.amount ? b : a));
27
+ const formatted = formatCurrency(lowest.amount, lowest.currency);
28
+ return priced.length > 1 ? `from ${formatted}` : formatted;
29
+ }