@antelopejs/dms-saas 0.2.0 → 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.
Files changed (177) hide show
  1. package/README.md +257 -10
  2. package/dist/billing-state/index.d.ts +1 -0
  3. package/dist/billing-state/index.js +1 -0
  4. package/dist/billing-state/index.js.map +1 -1
  5. package/dist/billing-state/past-due-banner.d.ts +4 -0
  6. package/dist/billing-state/past-due-banner.js +25 -0
  7. package/dist/billing-state/past-due-banner.js.map +1 -0
  8. package/dist/config/runtime.d.ts +6 -1
  9. package/dist/config/runtime.js +50 -1
  10. package/dist/config/runtime.js.map +1 -1
  11. package/dist/crons/recompute-billing-state.js +4 -1
  12. package/dist/crons/recompute-billing-state.js.map +1 -1
  13. package/dist/data-api/platformOwner/index.d.ts +1 -0
  14. package/dist/data-api/platformOwner/index.js +1 -0
  15. package/dist/data-api/platformOwner/index.js.map +1 -1
  16. package/dist/data-api/platformOwner/invitations.d.ts +22 -0
  17. package/dist/data-api/platformOwner/invitations.js +121 -0
  18. package/dist/data-api/platformOwner/invitations.js.map +1 -0
  19. package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
  20. package/dist/data-api/platformOwner/workspaces.js +64 -12
  21. package/dist/data-api/platformOwner/workspaces.js.map +1 -1
  22. package/dist/data-api/tenant/tenant-invoices.js +2 -10
  23. package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
  24. package/dist/hooks/register.js +2 -1
  25. package/dist/hooks/register.js.map +1 -1
  26. package/dist/implementations/dms-saas/billing.d.ts +2 -1
  27. package/dist/implementations/dms-saas/billing.js +6 -0
  28. package/dist/implementations/dms-saas/billing.js.map +1 -1
  29. package/dist/index.js +5 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/invoice-line-items/injection.d.ts +10 -0
  32. package/dist/invoice-line-items/injection.js +7 -3
  33. package/dist/invoice-line-items/injection.js.map +1 -1
  34. package/dist/operator-actions/index.d.ts +1 -0
  35. package/dist/operator-actions/index.js +1 -0
  36. package/dist/operator-actions/index.js.map +1 -1
  37. package/dist/operator-actions/invitation-commands.d.ts +12 -0
  38. package/dist/operator-actions/invitation-commands.js +46 -0
  39. package/dist/operator-actions/invitation-commands.js.map +1 -0
  40. package/dist/operator-actions/types.d.ts +1 -1
  41. package/dist/pages/platform/dashboard.js +2 -4
  42. package/dist/pages/platform/dashboard.js.map +1 -1
  43. package/dist/pages/platform/workspaces/detail.js +40 -1
  44. package/dist/pages/platform/workspaces/detail.js.map +1 -1
  45. package/dist/pages/platform/workspaces/index.d.ts +2 -8
  46. package/dist/pages/platform/workspaces/index.js +27 -111
  47. package/dist/pages/platform/workspaces/index.js.map +1 -1
  48. package/dist/pages/public/legal/privacy-policy.js +2 -1
  49. package/dist/pages/public/legal/privacy-policy.js.map +1 -1
  50. package/dist/pages/public/legal/terms-and-conditions.js +2 -1
  51. package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
  52. package/dist/pages/public/legal/terms-of-use.js +2 -1
  53. package/dist/pages/public/legal/terms-of-use.js.map +1 -1
  54. package/dist/plans/index.d.ts +1 -0
  55. package/dist/plans/index.js +1 -0
  56. package/dist/plans/index.js.map +1 -1
  57. package/dist/plans/platform-support.d.ts +2 -0
  58. package/dist/plans/platform-support.js +19 -0
  59. package/dist/plans/platform-support.js.map +1 -0
  60. package/dist/plans/seat-capacity.d.ts +9 -2
  61. package/dist/plans/seat-capacity.js +59 -12
  62. package/dist/plans/seat-capacity.js.map +1 -1
  63. package/dist/plans/seat-hooks.js +34 -13
  64. package/dist/plans/seat-hooks.js.map +1 -1
  65. package/dist/routes/platformOwner/dashboard.d.ts +8 -6
  66. package/dist/routes/platformOwner/dashboard.js +31 -17
  67. package/dist/routes/platformOwner/dashboard.js.map +1 -1
  68. package/dist/routes/platformOwner/index.d.ts +1 -0
  69. package/dist/routes/platformOwner/index.js +1 -0
  70. package/dist/routes/platformOwner/index.js.map +1 -1
  71. package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
  72. package/dist/routes/platformOwner/workspace-invitations.js +73 -0
  73. package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
  74. package/dist/routes/public/register.d.ts +7 -3
  75. package/dist/routes/public/register.js +42 -27
  76. package/dist/routes/public/register.js.map +1 -1
  77. package/dist/routes/tenant/tenant-billing.d.ts +7 -11
  78. package/dist/routes/tenant/tenant-billing.js +34 -26
  79. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  80. package/dist/routes/tenant/tenant-plan.d.ts +1 -0
  81. package/dist/routes/tenant/tenant-plan.js +27 -2
  82. package/dist/routes/tenant/tenant-plan.js.map +1 -1
  83. package/dist/routes/tenant/tenant-seats.d.ts +2 -4
  84. package/dist/routes/tenant/tenant-seats.js.map +1 -1
  85. package/dist/routes/tenant/workspaces.d.ts +2 -0
  86. package/dist/routes/tenant/workspaces.js +10 -4
  87. package/dist/routes/tenant/workspaces.js.map +1 -1
  88. package/dist/stripe/upcoming-invoice.d.ts +21 -0
  89. package/dist/stripe/upcoming-invoice.js +42 -0
  90. package/dist/stripe/upcoming-invoice.js.map +1 -0
  91. package/dist/stripe/webhook-dispatch.js +2 -0
  92. package/dist/stripe/webhook-dispatch.js.map +1 -1
  93. package/dist/types/config.d.ts +9 -0
  94. package/dist/types/config.js +7 -0
  95. package/dist/types/config.js.map +1 -1
  96. package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
  97. package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
  98. package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
  99. package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
  100. package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
  101. package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
  102. package/dist/upcoming-invoice/eligibility.d.ts +17 -0
  103. package/dist/upcoming-invoice/eligibility.js +52 -0
  104. package/dist/upcoming-invoice/eligibility.js.map +1 -0
  105. package/dist/upcoming-invoice/invalidation.d.ts +2 -0
  106. package/dist/upcoming-invoice/invalidation.js +39 -0
  107. package/dist/upcoming-invoice/invalidation.js.map +1 -0
  108. package/dist/upcoming-invoice/mapping.d.ts +8 -0
  109. package/dist/upcoming-invoice/mapping.js +82 -0
  110. package/dist/upcoming-invoice/mapping.js.map +1 -0
  111. package/dist/upcoming-invoice/outcomes.d.ts +13 -0
  112. package/dist/upcoming-invoice/outcomes.js +15 -0
  113. package/dist/upcoming-invoice/outcomes.js.map +1 -0
  114. package/dist/upcoming-invoice/preview.d.ts +3 -0
  115. package/dist/upcoming-invoice/preview.js +80 -0
  116. package/dist/upcoming-invoice/preview.js.map +1 -0
  117. package/dist/upcoming-invoice/pricing.d.ts +3 -0
  118. package/dist/upcoming-invoice/pricing.js +84 -0
  119. package/dist/upcoming-invoice/pricing.js.map +1 -0
  120. package/dist/upcoming-invoice/usage.d.ts +14 -0
  121. package/dist/upcoming-invoice/usage.js +53 -0
  122. package/dist/upcoming-invoice/usage.js.map +1 -0
  123. package/dist/workspaces/billing-identity.d.ts +19 -0
  124. package/dist/workspaces/billing-identity.js +51 -0
  125. package/dist/workspaces/billing-identity.js.map +1 -0
  126. package/dist/workspaces/default-plan.d.ts +5 -0
  127. package/dist/workspaces/default-plan.js +109 -0
  128. package/dist/workspaces/default-plan.js.map +1 -0
  129. package/dist/workspaces/index.d.ts +2 -0
  130. package/dist/workspaces/index.js +2 -0
  131. package/dist/workspaces/index.js.map +1 -1
  132. package/dist/workspaces/invitations.d.ts +26 -0
  133. package/dist/workspaces/invitations.js +112 -0
  134. package/dist/workspaces/invitations.js.map +1 -0
  135. package/dist/workspaces/provisioning.d.ts +3 -3
  136. package/dist/workspaces/provisioning.js +39 -11
  137. package/dist/workspaces/provisioning.js.map +1 -1
  138. package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
  139. package/frontend-vue/app/components/PastDueBanner.vue +81 -0
  140. package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
  141. package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
  142. package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
  143. package/frontend-vue/app/components/PlatformNotes.vue +4 -0
  144. package/frontend-vue/app/components/RefundButton.vue +10 -1
  145. package/frontend-vue/app/components/Register.vue +49 -92
  146. package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
  147. package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
  148. package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
  149. package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
  150. package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
  151. package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
  152. package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
  153. package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
  154. package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
  155. package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
  156. package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
  157. package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
  158. package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
  159. package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
  160. package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
  161. package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
  162. package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
  163. package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
  164. package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
  165. package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
  166. package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
  167. package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
  168. package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
  169. package/frontend-vue/app/plugins/auth-links.ts +0 -4
  170. package/frontend-vue/app/types/runtime-config.ts +13 -0
  171. package/frontend-vue/dms.frontend.ts +10 -4
  172. package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
  173. package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
  174. package/frontend-vue/package.json +0 -1
  175. package/frontend-vue/pnpm-lock.yaml +1 -125
  176. package/package.json +5 -5
  177. 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
- unlimitedLabel: string,
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
- unlimitedLabel: string,
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
- return unit ? `${numeric} ${unit}` : String(numeric);
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(value: unknown, unit: string | null): string {
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
- return unit ? `${text} ${unit}` : text;
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: (value, unit, unlimitedLabel) =>
36
- formatNumeric(value, unit, unlimitedLabel),
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 the way the comparison table shows it, with
42
- * `-1` reading as unlimited — the same convention `maxMembers` already uses.
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
- if (value === undefined || value === null || value === "") {
52
- return EMPTY_LABEL;
53
- }
54
- const formatter = FORMATTERS[feature.valueType];
55
- if (!formatter) return String(value);
56
- return formatter(value, feature.unit, t("saas.workspace.plan.unlimited"));
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
+ }