@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 +19 -4
- package/package.json +1 -1
- package/src/component/billing/plan_selector.html +5 -4
- package/src/component/billing/plan_selector.ts +8 -0
- package/src/component/billing/price_selector.html +17 -0
- package/src/component/billing/price_selector.ts +66 -0
- package/src/component/login/register_component.html +1 -1
- package/src/component/login/register_component.ts +6 -0
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +31 -6
- package/src/util/money.ts +29 -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 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
|
-
**
|
|
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]="
|
|
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]="
|
|
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.
|
|
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
|
-
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -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
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// `<sail-checkout-button
|
|
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
|
+
}
|