@antelopejs/dms-saas 0.1.8 → 0.2.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 +262 -13
- package/dist/auth/subscription-access-gate.d.ts +1 -1
- package/dist/billing-state/index.d.ts +1 -0
- package/dist/billing-state/index.js +1 -0
- package/dist/billing-state/index.js.map +1 -1
- package/dist/billing-state/past-due-banner.d.ts +4 -0
- package/dist/billing-state/past-due-banner.js +25 -0
- package/dist/billing-state/past-due-banner.js.map +1 -0
- package/dist/config/runtime.d.ts +6 -1
- package/dist/config/runtime.js +50 -1
- package/dist/config/runtime.js.map +1 -1
- package/dist/crons/recompute-billing-state.js +4 -1
- package/dist/crons/recompute-billing-state.js.map +1 -1
- package/dist/data-api/platformOwner/index.d.ts +1 -0
- package/dist/data-api/platformOwner/index.js +1 -0
- package/dist/data-api/platformOwner/index.js.map +1 -1
- package/dist/data-api/platformOwner/invitations.d.ts +22 -0
- package/dist/data-api/platformOwner/invitations.js +121 -0
- package/dist/data-api/platformOwner/invitations.js.map +1 -0
- package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
- package/dist/data-api/platformOwner/workspaces.js +64 -12
- package/dist/data-api/platformOwner/workspaces.js.map +1 -1
- package/dist/data-api/tenant/tenant-invoices.js +2 -10
- package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
- package/dist/hooks/register.js +2 -1
- package/dist/hooks/register.js.map +1 -1
- package/dist/implementations/dms-saas/billing.d.ts +2 -1
- package/dist/implementations/dms-saas/billing.js +6 -0
- package/dist/implementations/dms-saas/billing.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/invoice-line-items/injection.d.ts +10 -0
- package/dist/invoice-line-items/injection.js +7 -3
- package/dist/invoice-line-items/injection.js.map +1 -1
- package/dist/notifications/subjects.d.ts +19 -19
- package/dist/operator-actions/index.d.ts +1 -0
- package/dist/operator-actions/index.js +1 -0
- package/dist/operator-actions/index.js.map +1 -1
- package/dist/operator-actions/invitation-commands.d.ts +12 -0
- package/dist/operator-actions/invitation-commands.js +46 -0
- package/dist/operator-actions/invitation-commands.js.map +1 -0
- package/dist/operator-actions/types.d.ts +1 -1
- package/dist/pages/platform/categories.d.ts +6 -6
- package/dist/pages/platform/credit-notes.d.ts +1 -1
- package/dist/pages/platform/dashboard.d.ts +2 -2
- package/dist/pages/platform/dashboard.js +2 -4
- package/dist/pages/platform/dashboard.js.map +1 -1
- package/dist/pages/platform/features.d.ts +1 -1
- package/dist/pages/platform/invoices.d.ts +1 -1
- package/dist/pages/platform/plans/edit.d.ts +2 -2
- package/dist/pages/platform/plans/index.d.ts +1 -1
- package/dist/pages/platform/plans/migrations.d.ts +1 -1
- package/dist/pages/platform/segments/index.d.ts +1 -1
- package/dist/pages/platform/settings.d.ts +1 -1
- package/dist/pages/platform/users/detail.d.ts +1 -1
- package/dist/pages/platform/users/index.d.ts +1 -1
- package/dist/pages/platform/workspaces/detail.d.ts +1 -1
- package/dist/pages/platform/workspaces/detail.js +40 -1
- package/dist/pages/platform/workspaces/detail.js.map +1 -1
- package/dist/pages/platform/workspaces/index.d.ts +4 -10
- package/dist/pages/platform/workspaces/index.js +27 -111
- package/dist/pages/platform/workspaces/index.js.map +1 -1
- package/dist/pages/public/legal/privacy-policy.js +2 -1
- package/dist/pages/public/legal/privacy-policy.js.map +1 -1
- package/dist/pages/public/legal/terms-and-conditions.js +2 -1
- package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
- package/dist/pages/public/legal/terms-of-use.js +2 -1
- package/dist/pages/public/legal/terms-of-use.js.map +1 -1
- package/dist/pages/tenant/billing.d.ts +1 -1
- package/dist/plans/index.d.ts +1 -0
- package/dist/plans/index.js +1 -0
- package/dist/plans/index.js.map +1 -1
- package/dist/plans/platform-support.d.ts +2 -0
- package/dist/plans/platform-support.js +19 -0
- package/dist/plans/platform-support.js.map +1 -0
- package/dist/plans/seat-capacity.d.ts +9 -2
- package/dist/plans/seat-capacity.js +59 -12
- package/dist/plans/seat-capacity.js.map +1 -1
- package/dist/plans/seat-hooks.js +34 -13
- package/dist/plans/seat-hooks.js.map +1 -1
- package/dist/routes/platformOwner/billing-settings.d.ts +2 -2
- package/dist/routes/platformOwner/dashboard.d.ts +8 -6
- package/dist/routes/platformOwner/dashboard.js +31 -17
- package/dist/routes/platformOwner/dashboard.js.map +1 -1
- package/dist/routes/platformOwner/index.d.ts +1 -0
- package/dist/routes/platformOwner/index.js +1 -0
- package/dist/routes/platformOwner/index.js.map +1 -1
- package/dist/routes/platformOwner/plan-deletion.d.ts +1 -1
- package/dist/routes/platformOwner/plans.d.ts +5 -5
- package/dist/routes/platformOwner/segments.d.ts +1 -1
- package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
- package/dist/routes/platformOwner/workspace-invitations.js +73 -0
- package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
- package/dist/routes/public/legal-documents.d.ts +2 -2
- package/dist/routes/public/register.d.ts +7 -3
- package/dist/routes/public/register.js +42 -27
- package/dist/routes/public/register.js.map +1 -1
- package/dist/routes/tenant/tenant-billing.d.ts +7 -11
- package/dist/routes/tenant/tenant-billing.js +34 -26
- package/dist/routes/tenant/tenant-billing.js.map +1 -1
- package/dist/routes/tenant/tenant-plan.d.ts +1 -0
- package/dist/routes/tenant/tenant-plan.js +27 -2
- package/dist/routes/tenant/tenant-plan.js.map +1 -1
- package/dist/routes/tenant/tenant-seats.d.ts +2 -4
- package/dist/routes/tenant/tenant-seats.js.map +1 -1
- package/dist/routes/tenant/workspaces.d.ts +3 -1
- package/dist/routes/tenant/workspaces.js +10 -4
- package/dist/routes/tenant/workspaces.js.map +1 -1
- package/dist/stripe/upcoming-invoice.d.ts +21 -0
- package/dist/stripe/upcoming-invoice.js +42 -0
- package/dist/stripe/upcoming-invoice.js.map +1 -0
- package/dist/stripe/webhook-dispatch.js +2 -0
- package/dist/stripe/webhook-dispatch.js.map +1 -1
- package/dist/types/config.d.ts +9 -0
- package/dist/types/config.js +7 -0
- package/dist/types/config.js.map +1 -1
- package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
- package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
- package/dist/upcoming-invoice/eligibility.d.ts +17 -0
- package/dist/upcoming-invoice/eligibility.js +52 -0
- package/dist/upcoming-invoice/eligibility.js.map +1 -0
- package/dist/upcoming-invoice/invalidation.d.ts +2 -0
- package/dist/upcoming-invoice/invalidation.js +39 -0
- package/dist/upcoming-invoice/invalidation.js.map +1 -0
- package/dist/upcoming-invoice/mapping.d.ts +8 -0
- package/dist/upcoming-invoice/mapping.js +82 -0
- package/dist/upcoming-invoice/mapping.js.map +1 -0
- package/dist/upcoming-invoice/outcomes.d.ts +13 -0
- package/dist/upcoming-invoice/outcomes.js +15 -0
- package/dist/upcoming-invoice/outcomes.js.map +1 -0
- package/dist/upcoming-invoice/preview.d.ts +3 -0
- package/dist/upcoming-invoice/preview.js +80 -0
- package/dist/upcoming-invoice/preview.js.map +1 -0
- package/dist/upcoming-invoice/pricing.d.ts +3 -0
- package/dist/upcoming-invoice/pricing.js +84 -0
- package/dist/upcoming-invoice/pricing.js.map +1 -0
- package/dist/upcoming-invoice/usage.d.ts +14 -0
- package/dist/upcoming-invoice/usage.js +53 -0
- package/dist/upcoming-invoice/usage.js.map +1 -0
- package/dist/utils/invoice-lines-type.d.ts +1 -1
- package/dist/workspaces/billing-identity.d.ts +19 -0
- package/dist/workspaces/billing-identity.js +51 -0
- package/dist/workspaces/billing-identity.js.map +1 -0
- package/dist/workspaces/default-plan.d.ts +5 -0
- package/dist/workspaces/default-plan.js +109 -0
- package/dist/workspaces/default-plan.js.map +1 -0
- package/dist/workspaces/index.d.ts +2 -0
- package/dist/workspaces/index.js +2 -0
- package/dist/workspaces/index.js.map +1 -1
- package/dist/workspaces/invitations.d.ts +26 -0
- package/dist/workspaces/invitations.js +112 -0
- package/dist/workspaces/invitations.js.map +1 -0
- package/dist/workspaces/provisioning.d.ts +3 -3
- package/dist/workspaces/provisioning.js +39 -11
- package/dist/workspaces/provisioning.js.map +1 -1
- package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
- package/frontend-vue/app/components/PastDueBanner.vue +81 -0
- package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
- package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
- package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
- package/frontend-vue/app/components/PlatformNotes.vue +4 -0
- package/frontend-vue/app/components/RefundButton.vue +10 -1
- package/frontend-vue/app/components/Register.vue +49 -92
- package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
- package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
- package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
- package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
- package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
- package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
- package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
- package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
- package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
- package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
- package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
- package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
- package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
- package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
- package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
- package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
- package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
- package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
- package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
- package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
- package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
- package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
- package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
- package/frontend-vue/app/plugins/auth-links.ts +0 -4
- package/frontend-vue/app/types/runtime-config.ts +13 -0
- package/frontend-vue/dms.frontend.ts +10 -4
- package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
- package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
- package/frontend-vue/package.json +0 -1
- package/frontend-vue/pnpm-lock.yaml +1 -125
- package/package.json +15 -15
- package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
|
@@ -1,59 +1,351 @@
|
|
|
1
|
+
import { formatMajorUnits } from "./useMoneyFormat";
|
|
2
|
+
|
|
1
3
|
const UNLIMITED_NUMERIC_VALUE = -1;
|
|
2
4
|
const EMPTY_LABEL = "—";
|
|
3
5
|
const INCLUDED_LABEL = "✓";
|
|
4
|
-
const EXCLUDED_LABEL = "
|
|
6
|
+
const EXCLUDED_LABEL = "—";
|
|
7
|
+
const PRICE_UNIT_PREFIX = "per ";
|
|
8
|
+
const UNIT_LABEL_KEY_PREFIX = "saas.workspace.plan.units";
|
|
9
|
+
const MAX_QUANTITY_FRACTION_DIGITS = 2;
|
|
10
|
+
const MIN_PRICE_FRACTION_DIGITS = 2;
|
|
11
|
+
const MAX_PRICE_SIGNIFICANT_DIGITS = 3;
|
|
12
|
+
/** Upper bound `Intl.NumberFormat` accepts for fraction digits. */
|
|
13
|
+
const MAX_FRACTION_DIGITS = 20;
|
|
14
|
+
const BYTES_PER_KILOBYTE = 1_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One way of showing a unit: how many stored units it holds, and the i18n
|
|
18
|
+
* key of a message node with a `quantity` message (`{value}`, pluralised on
|
|
19
|
+
* the scaled value) and a `price` message (`{price}`).
|
|
20
|
+
*/
|
|
21
|
+
export interface PlanFeatureUnitScale {
|
|
22
|
+
label: string;
|
|
23
|
+
size: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How a stored unit reads on a plan page. Quantities take the largest scale
|
|
28
|
+
* they fill; a price reads per `priceScale` of the unit.
|
|
29
|
+
*/
|
|
30
|
+
export interface PlanFeatureUnitRule {
|
|
31
|
+
scales: PlanFeatureUnitScale[];
|
|
32
|
+
priceScale: PlanFeatureUnitScale;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type UnitKind = "quantity" | "price";
|
|
36
|
+
|
|
37
|
+
/** Translates unit label keys, pluralised on `count`. */
|
|
38
|
+
export type UnitLabelTranslator = (
|
|
39
|
+
key: string,
|
|
40
|
+
params: Record<string, string>,
|
|
41
|
+
count: number,
|
|
42
|
+
) => string;
|
|
43
|
+
|
|
44
|
+
/** Everything the formatter needs besides the value, kept Vue-free for tests. */
|
|
45
|
+
export interface PlanFeatureFormatContext {
|
|
46
|
+
locale: string;
|
|
47
|
+
/** Currency the plan is billed in; prices and money amounts read in it. */
|
|
48
|
+
currency: string | null;
|
|
49
|
+
unlimitedLabel: string;
|
|
50
|
+
translateUnit: UnitLabelTranslator;
|
|
51
|
+
/**
|
|
52
|
+
* Renders a stored text value through the DMS display string convention:
|
|
53
|
+
* a `$key` is translated, anything else is returned as is.
|
|
54
|
+
*/
|
|
55
|
+
translateText: (text: string) => string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The part of a feature definition that decides how its value reads. */
|
|
59
|
+
export interface FormattableFeature {
|
|
60
|
+
valueType: string;
|
|
61
|
+
unit: string | null;
|
|
62
|
+
}
|
|
5
63
|
|
|
6
64
|
type FeatureValueFormatter = (
|
|
7
65
|
value: unknown,
|
|
8
66
|
unit: string | null,
|
|
9
|
-
|
|
67
|
+
context: PlanFeatureFormatContext,
|
|
10
68
|
) => string;
|
|
11
69
|
|
|
70
|
+
/** A built-in scale, labelled under dms-saas's own unit messages. */
|
|
71
|
+
function builtInScale(name: string, size: number): PlanFeatureUnitScale {
|
|
72
|
+
return { label: `${UNIT_LABEL_KEY_PREFIX}.${name}`, size };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BYTE_SCALES = ["byte", "kilobyte", "megabyte", "gigabyte", "terabyte"].map(
|
|
76
|
+
(name, power) => builtInScale(name, BYTES_PER_KILOBYTE ** power),
|
|
77
|
+
);
|
|
78
|
+
const GIGABYTE_SCALE = BYTE_SCALES[3]!;
|
|
79
|
+
const MINUTE_SCALE = builtInScale("minute", 1);
|
|
80
|
+
|
|
81
|
+
const BYTE_RULE: PlanFeatureUnitRule = {
|
|
82
|
+
scales: BYTE_SCALES,
|
|
83
|
+
priceScale: GIGABYTE_SCALE,
|
|
84
|
+
};
|
|
85
|
+
const MINUTE_RULE: PlanFeatureUnitRule = {
|
|
86
|
+
scales: [MINUTE_SCALE],
|
|
87
|
+
priceScale: MINUTE_SCALE,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generic units dms-saas knows, normalised to lower case. A unit missing here
|
|
92
|
+
* and from the registry still reads, as a grouped number followed by the unit
|
|
93
|
+
* verbatim.
|
|
94
|
+
*/
|
|
95
|
+
const BUILT_IN_UNIT_RULES = new Map<string, PlanFeatureUnitRule>([
|
|
96
|
+
["byte", BYTE_RULE],
|
|
97
|
+
["bytes", BYTE_RULE],
|
|
98
|
+
["minute", MINUTE_RULE],
|
|
99
|
+
["minutes", MINUTE_RULE],
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Units consumer modules registered. Registration happens at plugin setup,
|
|
104
|
+
* on the server and in the browser alike, and always maps a unit to the same
|
|
105
|
+
* rule, so one module-level map is safe to share across SSR requests.
|
|
106
|
+
*/
|
|
107
|
+
const registeredUnitRules = new Map<string, PlanFeatureUnitRule>();
|
|
108
|
+
|
|
109
|
+
function normalizeUnit(unit: string): string {
|
|
110
|
+
return unit.trim().toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Teaches the plan pages how a feature unit reads, e.g. vCPU-minutes shown
|
|
115
|
+
* in vCPU-hours. Call it from a frontend plugin of the module declaring the
|
|
116
|
+
* features; labels are full i18n keys that module ships in its own locales.
|
|
117
|
+
* A registered unit takes precedence over a built-in one of the same name.
|
|
118
|
+
*
|
|
119
|
+
* @param unit Stored unit or its spellings (matched case-insensitively), without `per `
|
|
120
|
+
* @param rule Display scales, in any order, and the scale prices read per
|
|
121
|
+
* @throws When the rule has no scale or a scale is not a positive size
|
|
122
|
+
*/
|
|
123
|
+
export function registerPlanFeatureUnit(
|
|
124
|
+
unit: string | string[],
|
|
125
|
+
rule: PlanFeatureUnitRule,
|
|
126
|
+
): void {
|
|
127
|
+
const scales = [...rule.scales].sort((left, right) => left.size - right.size);
|
|
128
|
+
const isValid =
|
|
129
|
+
scales.length > 0 &&
|
|
130
|
+
[...scales, rule.priceScale].every((candidate) => candidate.size > 0);
|
|
131
|
+
if (!isValid) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Invalid plan feature unit rule: expected at least one scale, all with a positive size",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
for (const name of [unit].flat()) {
|
|
137
|
+
registeredUnitRules.set(normalizeUnit(name), {
|
|
138
|
+
scales,
|
|
139
|
+
priceScale: rule.priceScale,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findUnitRule(base: string): PlanFeatureUnitRule | undefined {
|
|
145
|
+
return registeredUnitRules.get(base) ?? BUILT_IN_UNIT_RULES.get(base);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Units whose value is an amount of the plan's currency. */
|
|
149
|
+
const MONEY_UNITS = new Set(["currency", "currency unit", "currency units"]);
|
|
150
|
+
|
|
151
|
+
interface ParsedUnit {
|
|
152
|
+
kind: UnitKind;
|
|
153
|
+
base: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseUnit(unit: string): ParsedUnit {
|
|
157
|
+
const normalized = normalizeUnit(unit);
|
|
158
|
+
if (normalized.startsWith(PRICE_UNIT_PREFIX)) {
|
|
159
|
+
return {
|
|
160
|
+
kind: "price",
|
|
161
|
+
base: normalized.slice(PRICE_UNIT_PREFIX.length).trim(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { kind: "quantity", base: normalized };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatNumber(value: number, locale: string): string {
|
|
168
|
+
return new Intl.NumberFormat(locale, {
|
|
169
|
+
maximumFractionDigits: MAX_QUANTITY_FRACTION_DIGITS,
|
|
170
|
+
}).format(value);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fraction digits keeping `MAX_PRICE_SIGNIFICANT_DIGITS` of a price, never
|
|
175
|
+
* fewer than a currency's usual two.
|
|
176
|
+
*/
|
|
177
|
+
function priceFractionDigits(value: number): number {
|
|
178
|
+
if (value === 0) return MIN_PRICE_FRACTION_DIGITS;
|
|
179
|
+
const magnitude = Math.floor(Math.log10(Math.abs(value)));
|
|
180
|
+
const significant = MAX_PRICE_SIGNIFICANT_DIGITS - 1 - magnitude;
|
|
181
|
+
return Math.min(
|
|
182
|
+
MAX_FRACTION_DIGITS,
|
|
183
|
+
Math.max(MIN_PRICE_FRACTION_DIGITS, significant),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Unit prices are often fractions of a cent: keep three significant digits
|
|
189
|
+
* below a cent instead of rounding them to a misleading zero.
|
|
190
|
+
*/
|
|
191
|
+
function formatPrice(value: number, context: PlanFeatureFormatContext): string {
|
|
192
|
+
if (!context.currency) return formatNumber(value, context.locale);
|
|
193
|
+
return new Intl.NumberFormat(context.locale, {
|
|
194
|
+
style: "currency",
|
|
195
|
+
currency: context.currency.toUpperCase(),
|
|
196
|
+
minimumFractionDigits: MIN_PRICE_FRACTION_DIGITS,
|
|
197
|
+
maximumFractionDigits: priceFractionDigits(value),
|
|
198
|
+
}).format(value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pickScale(
|
|
202
|
+
scales: PlanFeatureUnitScale[],
|
|
203
|
+
value: number,
|
|
204
|
+
): PlanFeatureUnitScale {
|
|
205
|
+
const magnitude = Math.abs(value);
|
|
206
|
+
const fitting = scales.filter((candidate) => candidate.size <= magnitude);
|
|
207
|
+
return fitting.at(-1) ?? scales[0]!;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function unitLabelKey(label: string, kind: UnitKind): string {
|
|
211
|
+
return `${label}.${kind}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatQuantityInRule(
|
|
215
|
+
value: number,
|
|
216
|
+
rule: PlanFeatureUnitRule,
|
|
217
|
+
context: PlanFeatureFormatContext,
|
|
218
|
+
): string {
|
|
219
|
+
const chosen = pickScale(rule.scales, value);
|
|
220
|
+
const scaled = value / chosen.size;
|
|
221
|
+
return context.translateUnit(
|
|
222
|
+
unitLabelKey(chosen.label, "quantity"),
|
|
223
|
+
{ value: formatNumber(scaled, context.locale) },
|
|
224
|
+
scaled,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatPriceInRule(
|
|
229
|
+
value: number,
|
|
230
|
+
rule: PlanFeatureUnitRule,
|
|
231
|
+
context: PlanFeatureFormatContext,
|
|
232
|
+
): string {
|
|
233
|
+
const scaled = value * rule.priceScale.size;
|
|
234
|
+
return context.translateUnit(
|
|
235
|
+
unitLabelKey(rule.priceScale.label, "price"),
|
|
236
|
+
{ price: formatPrice(scaled, context) },
|
|
237
|
+
1,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatWithUnknownUnit(
|
|
242
|
+
value: number,
|
|
243
|
+
parsed: ParsedUnit,
|
|
244
|
+
unit: string,
|
|
245
|
+
context: PlanFeatureFormatContext,
|
|
246
|
+
): string {
|
|
247
|
+
const amount =
|
|
248
|
+
parsed.kind === "price"
|
|
249
|
+
? formatPrice(value, context)
|
|
250
|
+
: formatNumber(value, context.locale);
|
|
251
|
+
return `${amount} ${unit.trim()}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatNumberWithUnit(
|
|
255
|
+
value: number,
|
|
256
|
+
unit: string,
|
|
257
|
+
context: PlanFeatureFormatContext,
|
|
258
|
+
): string {
|
|
259
|
+
const parsed = parseUnit(unit);
|
|
260
|
+
if (parsed.kind === "quantity" && MONEY_UNITS.has(parsed.base)) {
|
|
261
|
+
return formatMajorUnits(value, context.currency, context.locale);
|
|
262
|
+
}
|
|
263
|
+
const rule = findUnitRule(parsed.base);
|
|
264
|
+
if (!rule) return formatWithUnknownUnit(value, parsed, unit, context);
|
|
265
|
+
return parsed.kind === "price"
|
|
266
|
+
? formatPriceInRule(value, rule, context)
|
|
267
|
+
: formatQuantityInRule(value, rule, context);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isTruthyFlag(value: unknown): boolean {
|
|
271
|
+
return value === true || value === "true" || value === 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
12
274
|
function formatBoolean(value: unknown): string {
|
|
13
|
-
return value ? INCLUDED_LABEL : EXCLUDED_LABEL;
|
|
275
|
+
return isTruthyFlag(value) ? INCLUDED_LABEL : EXCLUDED_LABEL;
|
|
14
276
|
}
|
|
15
277
|
|
|
16
278
|
function formatNumeric(
|
|
17
279
|
value: unknown,
|
|
18
280
|
unit: string | null,
|
|
19
|
-
|
|
281
|
+
context: PlanFeatureFormatContext,
|
|
20
282
|
): string {
|
|
21
283
|
const numeric = Number(value);
|
|
22
284
|
if (Number.isNaN(numeric)) return EMPTY_LABEL;
|
|
23
|
-
if (numeric === UNLIMITED_NUMERIC_VALUE) return unlimitedLabel;
|
|
24
|
-
|
|
285
|
+
if (numeric === UNLIMITED_NUMERIC_VALUE) return context.unlimitedLabel;
|
|
286
|
+
if (!unit?.trim()) return formatNumber(numeric, context.locale);
|
|
287
|
+
return formatNumberWithUnit(numeric, unit, context);
|
|
25
288
|
}
|
|
26
289
|
|
|
27
|
-
function formatText(
|
|
290
|
+
function formatText(
|
|
291
|
+
value: unknown,
|
|
292
|
+
unit: string | null,
|
|
293
|
+
context: PlanFeatureFormatContext,
|
|
294
|
+
): string {
|
|
28
295
|
const text = String(value).trim();
|
|
29
296
|
if (!text) return EMPTY_LABEL;
|
|
30
|
-
|
|
297
|
+
const shown = context.translateText(text);
|
|
298
|
+
return unit ? `${shown} ${unit}` : shown;
|
|
31
299
|
}
|
|
32
300
|
|
|
33
301
|
const FORMATTERS: Record<string, FeatureValueFormatter> = {
|
|
34
302
|
boolean: (value) => formatBoolean(value),
|
|
35
|
-
number:
|
|
36
|
-
|
|
37
|
-
string: (value, unit) => formatText(value, unit),
|
|
303
|
+
number: formatNumeric,
|
|
304
|
+
string: formatText,
|
|
38
305
|
};
|
|
39
306
|
|
|
40
307
|
/**
|
|
41
|
-
* Renders a plan's feature value
|
|
42
|
-
*
|
|
308
|
+
* Renders a plan's feature value for people: `-1` reads as unlimited (the
|
|
309
|
+
* convention `maxMembers` already uses), booleans as ✓/—, numbers grouped in
|
|
310
|
+
* the viewer's locale and scaled by unit — bytes to KB…TB, plus any unit a
|
|
311
|
+
* consumer registered — and `per <unit>` prices read per the same readable
|
|
312
|
+
* unit. Text values follow the DMS display string convention: a `$key` is
|
|
313
|
+
* translated, plain text is shown as stored.
|
|
314
|
+
*
|
|
315
|
+
* @param feature Feature definition carrying the value type and unit
|
|
316
|
+
* @param value Value a plan sets for the feature
|
|
317
|
+
* @param context Locale, plan currency and unit label translator
|
|
43
318
|
*/
|
|
319
|
+
export function formatPlanFeatureValue(
|
|
320
|
+
feature: FormattableFeature,
|
|
321
|
+
value: unknown,
|
|
322
|
+
context: PlanFeatureFormatContext,
|
|
323
|
+
): string {
|
|
324
|
+
if (value === undefined || value === null || value === "") {
|
|
325
|
+
return EMPTY_LABEL;
|
|
326
|
+
}
|
|
327
|
+
const formatter = FORMATTERS[feature.valueType];
|
|
328
|
+
if (!formatter) return String(value);
|
|
329
|
+
return formatter(value, feature.unit, context);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Locale-bound view of {@link formatPlanFeatureValue}, for components. */
|
|
44
333
|
export function usePlanFeatureFormat() {
|
|
45
|
-
const { t } = useI18n();
|
|
334
|
+
const { t, locale } = useI18n();
|
|
335
|
+
const { processI18n } = useTranslation();
|
|
46
336
|
|
|
47
337
|
function formatFeatureValue(
|
|
48
338
|
feature: TenantPlanFeature,
|
|
49
339
|
value: unknown,
|
|
340
|
+
currency: string | null = null,
|
|
50
341
|
): string {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
342
|
+
return formatPlanFeatureValue(feature, value, {
|
|
343
|
+
locale: locale.value,
|
|
344
|
+
currency,
|
|
345
|
+
unlimitedLabel: t("saas.workspace.plan.unlimited"),
|
|
346
|
+
translateUnit: (key, params, count) => t(key, params, count),
|
|
347
|
+
translateText: (text) => processI18n(text),
|
|
348
|
+
});
|
|
57
349
|
}
|
|
58
350
|
|
|
59
351
|
return { formatFeatureValue };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The part of a feature definition its label and tooltip come from. */
|
|
2
|
+
export interface LabelledFeature {
|
|
3
|
+
displayName: string;
|
|
4
|
+
tooltip: string | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Feature label and tooltip readers for the plan pages. Both follow the DMS
|
|
9
|
+
* display string convention: a stored value starting with `$` is an i18n key
|
|
10
|
+
* the declaring module ships in its own locales, anything else is shown as
|
|
11
|
+
* written. A `$` tooltip missing from the locales is hidden rather than shown
|
|
12
|
+
* as a bare key path.
|
|
13
|
+
*/
|
|
14
|
+
export function usePlanFeatureLabel() {
|
|
15
|
+
const { t } = useI18n();
|
|
16
|
+
const { processI18n } = useTranslation();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
featureLabel: (feature: LabelledFeature) =>
|
|
20
|
+
processI18n(feature.displayName),
|
|
21
|
+
featureTooltip: (feature: LabelledFeature) =>
|
|
22
|
+
resolveOptionalI18nKey(t, feature.tooltip ?? undefined) ?? null,
|
|
23
|
+
};
|
|
24
|
+
}
|