@carlonicora/nextjs-jsonapi 4.1.1 → 4.2.0

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 (230) hide show
  1. package/dist/{AssistantMessageInterface-CubxuBIJ.d.ts → AssistantMessageInterface-DHJJO_Z_.d.ts} +1 -1
  2. package/dist/{AssistantMessageInterface-sHjI2zPN.d.mts → AssistantMessageInterface-S7oyERTT.d.mts} +1 -1
  3. package/dist/{BlockNoteEditor-VOYKZOP5.mjs → BlockNoteEditor-HXAHDJA7.mjs} +34 -27
  4. package/dist/BlockNoteEditor-HXAHDJA7.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-BENYTTCZ.js → BlockNoteEditor-LLCZDFLZ.js} +50 -43
  6. package/dist/BlockNoteEditor-LLCZDFLZ.js.map +1 -0
  7. package/dist/{BlockNoteViewer-USLR4Y7C.js → BlockNoteViewer-77CCTC2U.js} +3 -3
  8. package/dist/{BlockNoteViewer-USLR4Y7C.js.map → BlockNoteViewer-77CCTC2U.js.map} +1 -1
  9. package/dist/{BlockNoteViewer-Y2ZM4ESA.mjs → BlockNoteViewer-MDPO6BXN.mjs} +2 -2
  10. package/dist/{auth.interface-Df4vXbNz.d.mts → auth.interface-CeFCz9NU.d.mts} +1 -1
  11. package/dist/{auth.interface-BzI4onI_.d.ts → auth.interface-D1R7VVky.d.ts} +1 -1
  12. package/dist/billing/index.d.mts +40 -6
  13. package/dist/billing/index.d.ts +40 -6
  14. package/dist/billing/index.js +806 -774
  15. package/dist/billing/index.js.map +1 -1
  16. package/dist/billing/index.mjs +494 -462
  17. package/dist/billing/index.mjs.map +1 -1
  18. package/dist/{chunk-6ISF5KNQ.mjs → chunk-2VXDZRYY.mjs} +1103 -816
  19. package/dist/chunk-2VXDZRYY.mjs.map +1 -0
  20. package/dist/{chunk-4BZJ4LAB.js → chunk-4ORLPEK7.js} +136 -104
  21. package/dist/chunk-4ORLPEK7.js.map +1 -0
  22. package/dist/{chunk-ZZ6E33FO.mjs → chunk-6DRSSW5O.mjs} +36 -4
  23. package/dist/chunk-6DRSSW5O.mjs.map +1 -0
  24. package/dist/{chunk-KS6WZP2E.js → chunk-6SMJRAE3.js} +4 -4
  25. package/dist/{chunk-KS6WZP2E.js.map → chunk-6SMJRAE3.js.map} +1 -1
  26. package/dist/{chunk-WQ77CUUZ.js → chunk-GPXYQMOZ.js} +7 -7
  27. package/dist/{chunk-WQ77CUUZ.js.map → chunk-GPXYQMOZ.js.map} +1 -1
  28. package/dist/{chunk-46S3KPLU.mjs → chunk-ILFFC6BG.mjs} +2 -2
  29. package/dist/{chunk-HZVVRIJQ.mjs → chunk-IUS2RHCX.mjs} +2 -2
  30. package/dist/{chunk-F7RNBGHS.js → chunk-KJMMBUOA.js} +3 -3
  31. package/dist/{chunk-F7RNBGHS.js.map → chunk-KJMMBUOA.js.map} +1 -1
  32. package/dist/{chunk-ZO3QQ5CQ.mjs → chunk-MQDR6MEE.mjs} +2 -2
  33. package/dist/{chunk-ORU7KLCY.js → chunk-VDVIUDY5.js} +1445 -1158
  34. package/dist/chunk-VDVIUDY5.js.map +1 -0
  35. package/dist/client/index.d.mts +20 -6
  36. package/dist/client/index.d.ts +20 -6
  37. package/dist/client/index.js +6 -4
  38. package/dist/client/index.js.map +1 -1
  39. package/dist/client/index.mjs +5 -3
  40. package/dist/components/index.d.mts +59 -21
  41. package/dist/components/index.d.ts +59 -21
  42. package/dist/components/index.js +4 -4
  43. package/dist/components/index.mjs +3 -3
  44. package/dist/{config-CEAq4Ozy.d.mts → config-BP2vyddw.d.mts} +5 -0
  45. package/dist/{config-DydDHCfb.d.ts → config-BhW2QrRD.d.ts} +5 -0
  46. package/dist/contexts/index.d.mts +4 -4
  47. package/dist/contexts/index.d.ts +4 -4
  48. package/dist/contexts/index.js +4 -4
  49. package/dist/contexts/index.mjs +3 -3
  50. package/dist/core/index.d.mts +17 -9
  51. package/dist/core/index.d.ts +17 -9
  52. package/dist/core/index.js +4 -2
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/core/index.mjs +3 -1
  55. package/dist/features/analytics/index.d.mts +1 -1
  56. package/dist/features/analytics/index.d.ts +1 -1
  57. package/dist/features/analytics/index.js +122 -122
  58. package/dist/features/analytics/index.mjs +4 -4
  59. package/dist/features/analytics/server.js +4 -4
  60. package/dist/features/analytics/server.mjs +2 -2
  61. package/dist/features/help/index.d.mts +1 -1
  62. package/dist/features/help/index.d.ts +1 -1
  63. package/dist/features/help/index.js +42 -40
  64. package/dist/features/help/index.js.map +1 -1
  65. package/dist/features/help/index.mjs +8 -6
  66. package/dist/features/help/index.mjs.map +1 -1
  67. package/dist/features/tokenusage/index.js +125 -125
  68. package/dist/features/tokenusage/index.mjs +4 -4
  69. package/dist/index.d.mts +7 -7
  70. package/dist/index.d.ts +7 -7
  71. package/dist/index.js +5 -3
  72. package/dist/index.js.map +1 -1
  73. package/dist/index.mjs +4 -2
  74. package/dist/{notification.interface-P_pNfzZQ.d.mts → notification.interface-BB6GPpZY.d.mts} +1 -1
  75. package/dist/{notification.interface-BNCvnVCW.d.ts → notification.interface-CmAGrijJ.d.ts} +1 -1
  76. package/dist/{s3.service-CbUa6a-8.d.ts → s3.service-C-4YpEMu.d.ts} +7 -3
  77. package/dist/{s3.service-DAp8FbuG.d.mts → s3.service-dVj-nxMl.d.mts} +7 -3
  78. package/dist/server/index.d.mts +4 -4
  79. package/dist/server/index.d.ts +4 -4
  80. package/dist/server/index.js +3 -3
  81. package/dist/server/index.mjs +2 -2
  82. package/dist/{useSocket-BHVIyigI.d.ts → useSocket-B0H9pmmH.d.ts} +1 -1
  83. package/dist/{useSocket-B9gTbuCj.d.mts → useSocket-B5RQb6v2.d.mts} +1 -1
  84. package/dist/{user.interface-CrmwJLYW.d.ts → user.interface-C0ZdsBZj.d.ts} +2 -0
  85. package/dist/{user.interface-CSaGWFbg.d.mts → user.interface-CIWGlbqM.d.mts} +2 -0
  86. package/package.json +1 -1
  87. package/scripts/vitest-slow-tests.reporter.spec.ts +54 -0
  88. package/src/components/containers/RoundPageContainer.tsx +3 -1
  89. package/src/components/containers/RoundPageContainerTitle.tsx +5 -2
  90. package/src/components/editors/BlockNoteEditor.tsx +31 -15
  91. package/src/components/editors/BlockNoteEditorAiActions.tsx +11 -7
  92. package/src/components/editors/__tests__/BlockNoteEditorAiActions.test.ts +10 -3
  93. package/src/components/errors/ErrorDetails.tsx +5 -2
  94. package/src/components/forms/CommonAssociationForm.tsx +1 -1
  95. package/src/components/forms/EntityMultiSelector.tsx +6 -2
  96. package/src/components/forms/EntitySelector.tsx +4 -1
  97. package/src/components/forms/FileUploader.tsx +3 -2
  98. package/src/components/forms/MultiFileUploader.tsx +3 -1
  99. package/src/components/navigations/Breadcrumb.tsx +3 -2
  100. package/src/components/navigations/Header.tsx +3 -1
  101. package/src/components/navigations/MobileNavigationBar.tsx +3 -1
  102. package/src/components/navigations/__tests__/Header.spec.tsx +2 -2
  103. package/src/components/tables/cells/cell.id.tsx +13 -2
  104. package/src/core/abstracts/AbstractService.ts +3 -3
  105. package/src/core/abstracts/ClientAbstractService.ts +3 -2
  106. package/src/features/assistant-action/components/ApprovalActionCard.tsx +74 -5
  107. package/src/features/assistant-action/components/__tests__/ApprovalActionCard.spec.tsx +81 -0
  108. package/src/features/assistant-message/components/parts/RelevanceMeter.tsx +4 -1
  109. package/src/features/assistant-message/components/parts/tabs/UsersTab.tsx +2 -1
  110. package/src/features/auth/components/buttons/GoogleSignInButton.tsx +4 -1
  111. package/src/features/auth/components/details/LandingComponent.tsx +1 -1
  112. package/src/features/auth/components/forms/ForgotPassword.tsx +1 -1
  113. package/src/features/auth/components/forms/Login.tsx +1 -1
  114. package/src/features/auth/components/forms/Register.tsx +1 -1
  115. package/src/features/auth/components/forms/ResetPassword.tsx +2 -2
  116. package/src/features/auth/components/two-factor/BackupCodesDialog.tsx +1 -1
  117. package/src/features/auth/components/two-factor/PasskeyButton.tsx +1 -1
  118. package/src/features/billing/components/cards/BillingUsageSummaryCard.tsx +21 -11
  119. package/src/features/billing/components/cards/CustomerInfoCard.tsx +16 -13
  120. package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +33 -21
  121. package/src/features/billing/components/cards/PaymentMethodSummaryCard.tsx +20 -10
  122. package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +35 -22
  123. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +24 -24
  124. package/src/features/billing/components/utils/__tests__/billing-formatters.spec.ts +63 -0
  125. package/src/features/billing/components/utils/currency.ts +67 -10
  126. package/src/features/billing/components/utils/date.ts +9 -4
  127. package/src/features/billing/components/utils/format-options.ts +20 -0
  128. package/src/features/billing/components/utils/index.ts +3 -1
  129. package/src/features/billing/components/utils/useBillingFormatOptions.ts +11 -0
  130. package/src/features/billing/components/widgets/BillingAlertBanner.tsx +8 -9
  131. package/src/features/billing/contexts/PriceContext.tsx +18 -6
  132. package/src/features/billing/i18n-keys.ts +212 -0
  133. package/src/features/billing/stripe-customer/components/containers/PaymentMethodsContainer.tsx +8 -8
  134. package/src/features/billing/stripe-customer/components/details/PaymentMethodCard.tsx +11 -9
  135. package/src/features/billing/stripe-customer/components/forms/PaymentMethodEditor.tsx +4 -4
  136. package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +9 -7
  137. package/src/features/billing/stripe-invoice/components/containers/InvoicesContainer.tsx +10 -8
  138. package/src/features/billing/stripe-invoice/components/details/InvoiceDetails.tsx +34 -29
  139. package/src/features/billing/stripe-invoice/components/lists/InvoicesList.tsx +12 -9
  140. package/src/features/billing/stripe-invoice/components/widgets/InvoiceStatusBadge.tsx +9 -6
  141. package/src/features/billing/stripe-price/components/details/PriceDetails.tsx +4 -3
  142. package/src/features/billing/stripe-price/components/forms/PriceArchiver.tsx +3 -1
  143. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +3 -1
  144. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +6 -5
  145. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +12 -7
  146. package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.service.ts +3 -1
  147. package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +10 -6
  148. package/src/features/billing/stripe-subscription/components/details/SubscriptionDetails.tsx +44 -29
  149. package/src/features/billing/stripe-subscription/components/forms/CancelSubscriptionDialog.tsx +14 -11
  150. package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +20 -17
  151. package/src/features/billing/stripe-subscription/components/widgets/IntervalToggle.tsx +4 -2
  152. package/src/features/billing/stripe-subscription/components/widgets/PricingCard.tsx +16 -7
  153. package/src/features/billing/stripe-subscription/components/widgets/ProductPricingList.tsx +3 -1
  154. package/src/features/billing/stripe-subscription/components/widgets/ProductPricingRow.tsx +3 -1
  155. package/src/features/billing/stripe-subscription/components/widgets/ProrationPreview.tsx +12 -7
  156. package/src/features/billing/stripe-subscription/components/widgets/SubscriptionConfirmation.tsx +11 -8
  157. package/src/features/billing/stripe-subscription/components/widgets/SubscriptionStatusBadge.tsx +13 -10
  158. package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +8 -6
  159. package/src/features/billing/stripe-subscription/components/wizards/WizardProgressIndicator.tsx +7 -4
  160. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +4 -2
  161. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPlanSelection.tsx +3 -1
  162. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +37 -28
  163. package/src/features/billing/stripe-subscription/hooks/useConfirmSubscriptionPayment.ts +9 -7
  164. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +20 -9
  165. package/src/features/billing/stripe-usage/components/containers/UsageContainer.tsx +6 -6
  166. package/src/features/billing/stripe-usage/components/details/UsageSummaryCard.tsx +24 -9
  167. package/src/features/billing/stripe-usage/components/lists/UsageHistoryTable.tsx +16 -12
  168. package/src/features/company/components/details/TokenStatusIndicator.tsx +7 -5
  169. package/src/features/company/components/forms/CompanyEditor.tsx +2 -2
  170. package/src/features/help/components/HelpArticleBody.tsx +1 -1
  171. package/src/features/help/components/HelpHeader.tsx +1 -1
  172. package/src/features/help/components/HelpSideNav.tsx +1 -1
  173. package/src/features/help/i18n-keys.ts +2 -0
  174. package/src/features/notification/components/common/NotificationErrorBoundary.tsx +17 -12
  175. package/src/features/notification/components/containers/NotificationsListContainer.tsx +4 -2
  176. package/src/features/notification/components/notifications/Notification.tsx +6 -3
  177. package/src/features/notification/contexts/NotificationContext.tsx +9 -4
  178. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +13 -0
  179. package/src/features/notification/hooks/useNotificationTableStructure.tsx +4 -2
  180. package/src/features/oauth/components/OAuthClientCard.tsx +13 -7
  181. package/src/features/oauth/components/OAuthClientDetail.tsx +34 -30
  182. package/src/features/oauth/components/OAuthClientForm.tsx +24 -24
  183. package/src/features/oauth/components/OAuthClientList.tsx +10 -6
  184. package/src/features/oauth/components/OAuthClientSecretDisplay.tsx +10 -8
  185. package/src/features/oauth/components/OAuthRedirectUriInput.tsx +9 -13
  186. package/src/features/oauth/components/OAuthScopeSelector.tsx +15 -6
  187. package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +11 -9
  188. package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -2
  189. package/src/features/oauth/hooks/__tests__/useOAuthScopeText.spec.ts +30 -0
  190. package/src/features/oauth/hooks/index.ts +1 -0
  191. package/src/features/oauth/hooks/useOAuthClient.ts +11 -5
  192. package/src/features/oauth/hooks/useOAuthClients.ts +9 -3
  193. package/src/features/oauth/hooks/useOAuthConsent.ts +11 -5
  194. package/src/features/oauth/hooks/useOAuthScopeText.ts +33 -0
  195. package/src/features/rbac/components/RbacByRoleContainer.tsx +7 -6
  196. package/src/features/rbac/components/RbacContainer.tsx +7 -6
  197. package/src/features/rbac/contexts/RbacContext.tsx +3 -3
  198. package/src/features/user/components/containers/UserContainer.tsx +3 -1
  199. package/src/features/user/data/__tests__/user.test.ts +66 -0
  200. package/src/features/user/data/user.interface.ts +2 -0
  201. package/src/features/user/data/user.service.ts +9 -0
  202. package/src/features/user/data/user.ts +7 -0
  203. package/src/features/waitlist/components/forms/WaitlistForm.tsx +1 -1
  204. package/src/features/waitlist/components/lists/WaitlistList.tsx +1 -1
  205. package/src/i18n/__tests__/config.test.ts +21 -1
  206. package/src/i18n/config.ts +13 -0
  207. package/src/i18n/index.ts +1 -0
  208. package/src/shadcnui/custom/multi-select.tsx +47 -14
  209. package/src/shadcnui/custom/multiple-selector.tsx +14 -2
  210. package/src/shadcnui/ui/__tests__/use-ui-text.test.tsx +76 -0
  211. package/src/shadcnui/ui/breadcrumb.tsx +9 -2
  212. package/src/shadcnui/ui/carousel.tsx +15 -4
  213. package/src/shadcnui/ui/dialog.tsx +15 -2
  214. package/src/shadcnui/ui/sheet.tsx +6 -1
  215. package/src/shadcnui/ui/sidebar.tsx +33 -7
  216. package/src/shadcnui/ui/spinner.tsx +6 -1
  217. package/src/shadcnui/ui/use-ui-text.ts +49 -0
  218. package/src/utils/__tests__/date-formatter.test.ts +24 -1
  219. package/src/utils/date-formatter.ts +16 -0
  220. package/src/utils/index.ts +1 -1
  221. package/dist/BlockNoteEditor-BENYTTCZ.js.map +0 -1
  222. package/dist/BlockNoteEditor-VOYKZOP5.mjs.map +0 -1
  223. package/dist/chunk-4BZJ4LAB.js.map +0 -1
  224. package/dist/chunk-6ISF5KNQ.mjs.map +0 -1
  225. package/dist/chunk-ORU7KLCY.js.map +0 -1
  226. package/dist/chunk-ZZ6E33FO.mjs.map +0 -1
  227. /package/dist/{BlockNoteViewer-Y2ZM4ESA.mjs.map → BlockNoteViewer-MDPO6BXN.mjs.map} +0 -0
  228. /package/dist/{chunk-46S3KPLU.mjs.map → chunk-ILFFC6BG.mjs.map} +0 -0
  229. /package/dist/{chunk-HZVVRIJQ.mjs.map → chunk-IUS2RHCX.mjs.map} +0 -0
  230. /package/dist/{chunk-ZO3QQ5CQ.mjs.map → chunk-MQDR6MEE.mjs.map} +0 -0
@@ -108,6 +108,69 @@ function humaniseKey(key: string): string {
108
108
  return words.charAt(0).toUpperCase() + words.slice(1);
109
109
  }
110
110
 
111
+ /**
112
+ * The untyped call signature of next-intl's `t`. Every key below is computed
113
+ * from wire identifiers, so the generated message-key union cannot describe it.
114
+ */
115
+ type LooseTranslate = ((key: string, values?: Record<string, unknown>) => string) & {
116
+ has?: (key: string) => boolean;
117
+ };
118
+
119
+ /**
120
+ * Translates `key` when the host app defines it, else `undefined`. `t.has` is
121
+ * the only non-throwing probe: a plain `t()` on a missing key either throws or
122
+ * returns the key path, depending on the app's next-intl error handler.
123
+ */
124
+ function translateIfPresent(t: LooseTranslate, key: string, values?: Record<string, unknown>): string | undefined {
125
+ if (typeof t.has !== "function" || !t.has(key)) return undefined;
126
+ try {
127
+ const text = t(key, values);
128
+ return text && text !== key ? text : undefined;
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Message-key segments a JSON:API type may be filed under in the app's
136
+ * `features.<feature>` namespace: `npcs` → `npc`, `knives` → `knife`,
137
+ * `stories` → `story`, `scene-cornerstones` → `scene-cornerstone` and
138
+ * `sceneCornerstone`.
139
+ */
140
+ function featureKeyCandidates(type: string): string[] {
141
+ if (!type) return [];
142
+ const singulars = new Set<string>();
143
+ if (type.endsWith("ives")) singulars.add(`${type.slice(0, -4)}ife`);
144
+ if (type.endsWith("ies")) singulars.add(`${type.slice(0, -3)}y`);
145
+ if (type.endsWith("s")) singulars.add(type.slice(0, -1));
146
+ singulars.add(type);
147
+ const candidates = new Set<string>();
148
+ for (const singular of singulars) {
149
+ candidates.add(singular);
150
+ candidates.add(singular.replace(/[-_]+([a-z0-9])/g, (_, letter: string) => letter.toUpperCase()));
151
+ }
152
+ return [...candidates];
153
+ }
154
+
155
+ /**
156
+ * Label of an attribute or relationship, in the active language. Tries, in
157
+ * order: the entity editor's own label (`features.<feature>.fields.<key>.label`),
158
+ * the generic `ui.fields.<key>`, then the humanised key — so a field no app
159
+ * catalogue knows still renders exactly as it did before translation existed.
160
+ */
161
+ function fieldLabel(t: LooseTranslate, type: string, key: string): string {
162
+ for (const feature of featureKeyCandidates(type)) {
163
+ const label = translateIfPresent(t, `features.${feature}.fields.${key}.label`);
164
+ if (label !== undefined) return label;
165
+ }
166
+ return translateIfPresent(t, `ui.fields.${key}`) ?? humaniseKey(key);
167
+ }
168
+
169
+ /** Singular entity name from the app's `entities.<type>` plural message, else the humanised type. */
170
+ function entityName(t: LooseTranslate, type: string): string {
171
+ return translateIfPresent(t, `entities.${type}`, { count: 1 }) ?? humaniseType(type);
172
+ }
173
+
111
174
  /**
112
175
  * Plain text of a BlockNote document, without importing BlockNote: walk the
113
176
  * blocks, concatenate the text runs of each block's `content`, recurse into
@@ -336,7 +399,9 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
336
399
  const presented = useMemo(() => parseOperatorProposal(actionProposal), [actionProposal]);
337
400
  const legacy = useMemo(() => (presented ? undefined : parseLegacyProposal(toolArgs)), [presented, toolArgs]);
338
401
  const isUpdate = toolName === "update_entity";
339
- const entityLabel = humaniseType(presented?.type ?? legacy?.type ?? "");
402
+ const entityType = presented?.type ?? legacy?.type ?? "";
403
+ const loose = t as unknown as LooseTranslate;
404
+ const entityLabel = entityName(loose, entityType);
340
405
  const targetLabel = presented?.target?.label ?? "";
341
406
 
342
407
  const rows = useMemo<ProposalRow[]>(() => {
@@ -345,7 +410,9 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
345
410
  const attributes = presented?.attributes ?? legacy?.attributes;
346
411
  const attributeRows = Object.entries(attributes ?? {}).flatMap<ProposalRow>(([key, value]) => {
347
412
  const text = formatAttribute(value, yes, no);
348
- return text === undefined ? [] : [{ key: `attribute:${key}`, label: humaniseKey(key), value: text }];
413
+ return text === undefined
414
+ ? []
415
+ : [{ key: `attribute:${key}`, label: fieldLabel(loose, entityType, key), value: text }];
349
416
  });
350
417
  if (!presented) return attributeRows;
351
418
 
@@ -353,7 +420,9 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
353
420
  const relationshipRows = Object.entries(presented.relationships ?? {}).flatMap<ProposalRow>(([key, refs]) => {
354
421
  if (hidden.has(key)) return [];
355
422
  const text = formatRefs(refs);
356
- return text === undefined ? [] : [{ key: `relationship:${key}`, label: humaniseKey(key), value: text }];
423
+ return text === undefined
424
+ ? []
425
+ : [{ key: `relationship:${key}`, label: fieldLabel(loose, entityType, key), value: text }];
357
426
  });
358
427
 
359
428
  const linkRows: ProposalRow[] = [];
@@ -361,7 +430,7 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
361
430
  linkRows.push({
362
431
  key: "link:relationship",
363
432
  label: t("features.assistant.approval.proposal.relationship"),
364
- value: humaniseKey(presented.relationship),
433
+ value: fieldLabel(loose, entityType, presented.relationship),
365
434
  });
366
435
  }
367
436
  const targetsText = formatRefs(presented.targets ?? []);
@@ -374,7 +443,7 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
374
443
  }
375
444
 
376
445
  return [...attributeRows, ...relationshipRows, ...linkRows];
377
- }, [presented, legacy, t, hiddenRelationshipKeys]);
446
+ }, [presented, legacy, t, loose, entityType, hiddenRelationshipKeys]);
378
447
 
379
448
  const hasProposal = rows.length > 0;
380
449
 
@@ -2,6 +2,25 @@ import { render, screen } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { ApprovalActionCard } from "../ApprovalActionCard";
4
4
 
5
+ /**
6
+ * A message catalogue the test fills in. Keys it does not hold behave like a
7
+ * host app without them: `t()` hands back the key path and `t.has()` is false,
8
+ * so the card must fall back to the humanised attribute name.
9
+ */
10
+ const messages = vi.hoisted(() => ({}) as Record<string, string>);
11
+
12
+ vi.mock("next-intl", () => {
13
+ const t = (key: string) => messages[key] ?? key;
14
+ (t as unknown as { has: (key: string) => boolean }).has = (key: string) => key in messages;
15
+ return {
16
+ useTranslations: () => t,
17
+ useLocale: () => "en",
18
+ useNow: () => new Date(),
19
+ useTimeZone: () => "UTC",
20
+ useFormatter: () => ({ dateTime: vi.fn(), number: vi.fn(), relativeTime: vi.fn() }),
21
+ };
22
+ });
23
+
5
24
  const findOne = vi.fn();
6
25
  const approve = vi.fn();
7
26
  const deny = vi.fn();
@@ -45,6 +64,7 @@ function makeAction(overrides: Record<string, unknown> = {}) {
45
64
 
46
65
  describe("ApprovalActionCard", () => {
47
66
  beforeEach(() => {
67
+ for (const key of Object.keys(messages)) delete messages[key];
48
68
  findOne.mockReset();
49
69
  approve.mockReset();
50
70
  deny.mockReset();
@@ -254,6 +274,67 @@ describe("ApprovalActionCard", () => {
254
274
  expect(screen.queryByText(UUID_LIKE)).toBeNull();
255
275
  });
256
276
 
277
+ it("labels fields with the entity editor's own message keys when the app defines them", async () => {
278
+ Object.assign(messages, {
279
+ "features.npc.fields.name.label": "Nome",
280
+ "features.npc.fields.tldr.label": "Riassunto",
281
+ "entities.npcs": "PNG",
282
+ });
283
+ findOne.mockResolvedValue(makeAction());
284
+
285
+ render(<ApprovalActionCard actionId="act-1" />);
286
+
287
+ expect(await screen.findByText("Nome")).toBeInTheDocument();
288
+ expect(screen.getByText("Riassunto")).toBeInTheDocument();
289
+ // `related` has no editor key and no generic key: humanised, as before
290
+ expect(screen.getByText("Related")).toBeInTheDocument();
291
+ expect(screen.queryByText("Name")).toBeNull();
292
+ expect(screen.queryByText("Tldr")).toBeNull();
293
+ });
294
+
295
+ it("falls back to the generic ui.fields key, and finds editor keys for irregular plurals", async () => {
296
+ Object.assign(messages, {
297
+ "ui.fields.related": "Collegati",
298
+ "features.knife.fields.name.label": "Nome del gancio",
299
+ });
300
+ findOne.mockResolvedValue(
301
+ makeAction({
302
+ proposal: JSON.stringify({
303
+ type: "knives",
304
+ attributes: { name: "The sister left behind", publicDescription: "Visible" },
305
+ relationships: { related: [{ id: "r1", type: "npcs", label: "Zoe" }] },
306
+ }),
307
+ }),
308
+ );
309
+
310
+ render(<ApprovalActionCard actionId="act-1" />);
311
+
312
+ expect(await screen.findByText("Nome del gancio")).toBeInTheDocument();
313
+ expect(screen.getByText("Collegati")).toBeInTheDocument();
314
+ // no key anywhere: the humanised attribute name, unchanged
315
+ expect(screen.getByText("Public Description")).toBeInTheDocument();
316
+ });
317
+
318
+ it("translates the relationship name on a link proposal", async () => {
319
+ Object.assign(messages, { "features.clue.fields.containedInScene.label": "Contenuto nella scena" });
320
+ findOne.mockResolvedValue(
321
+ makeAction({
322
+ toolName: "link_entities",
323
+ proposal: JSON.stringify({
324
+ type: "clues",
325
+ target: { id: "c1", type: "clues", label: "Bloody knife" },
326
+ relationship: "containedInScene",
327
+ targets: [{ id: "s1", type: "scenes", label: "The docks" }],
328
+ }),
329
+ }),
330
+ );
331
+
332
+ render(<ApprovalActionCard actionId="act-1" />);
333
+
334
+ expect(await screen.findByText("Contenuto nella scena")).toBeInTheDocument();
335
+ expect(screen.getByText("The docks")).toBeInTheDocument();
336
+ });
337
+
257
338
  it("still renders the summary and the buttons when neither payload parses", async () => {
258
339
  findOne.mockResolvedValue(makeAction({ proposal: "{not-json", toolArgs: "{not-json" }));
259
340
 
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
4
+
3
5
  interface Props {
4
6
  /** 0-1 (decimal) or 0-100 (percent). Auto-detected. */
5
7
  value: number;
@@ -7,6 +9,7 @@ interface Props {
7
9
  }
8
10
 
9
11
  export function RelevanceMeter({ value, className = "" }: Props) {
12
+ const t = useTranslations();
10
13
  const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
11
14
  const label = `${Math.round(pct)}%`;
12
15
  return (
@@ -15,7 +18,7 @@ export function RelevanceMeter({ value, className = "" }: Props) {
15
18
  aria-valuenow={Math.round(pct)}
16
19
  aria-valuemin={0}
17
20
  aria-valuemax={100}
18
- aria-label={`Relevance ${label}`}
21
+ aria-label={`${t("common.relevance")} ${label}`}
19
22
  className={`bg-muted relative mx-auto flex h-5 w-20 items-center justify-center overflow-hidden rounded border ${className}`}
20
23
  >
21
24
  <div className="bg-accent absolute top-0 start-0 h-full" style={{ width: `${pct}%` }} />
@@ -101,7 +101,8 @@ export function UsersTab({ users, citations, sources }: Props) {
101
101
  return null;
102
102
  }
103
103
  const href = generate({ page: module, id: user.id });
104
- const name = (user as any).name ?? (user as any).fullName ?? user.identifier ?? "User";
104
+ const name =
105
+ (user as any).name ?? (user as any).fullName ?? user.identifier ?? t("entities.users", { count: 1 });
105
106
  const avatarUrl = (user as any).avatar as string | undefined;
106
107
  const showCounts = citationCount > 0 || contentCount > 0;
107
108
  return (
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { getPublicApiUrl } from "../../../../client/config";
4
5
  import { isGoogleAuthEnabled } from "../../../../login";
5
6
  import { Button, Link } from "../../../../shadcnui";
@@ -9,6 +10,8 @@ interface GoogleSignInButtonProps {
9
10
  }
10
11
 
11
12
  export function GoogleSignInButton({ referralCode }: GoogleSignInButtonProps) {
13
+ const t = useTranslations();
14
+
12
15
  if (!isGoogleAuthEnabled()) {
13
16
  return null;
14
17
  }
@@ -45,7 +48,7 @@ export function GoogleSignInButton({ referralCode }: GoogleSignInButtonProps) {
45
48
  d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
46
49
  />
47
50
  </svg>
48
- Sign in with Google
51
+ {t("auth.buttons.sign_in_with_google")}
49
52
  </Button>
50
53
  </Link>
51
54
  );
@@ -55,7 +55,7 @@ export function LandingComponent() {
55
55
  {isDiscordAuthEnabled() && (
56
56
  <Link href={`${getPublicApiUrl()}auth/discord`} className="flex w-full justify-end">
57
57
  <Button className="w-full" variant={`outline`} data-testid="page-login-button-initial-login">
58
- Login with Discord
58
+ {t(`auth.buttons.login_with_discord`)}
59
59
  </Button>
60
60
  </Link>
61
61
  )}
@@ -49,7 +49,7 @@ export function ForgotPassword() {
49
49
  await AuthService.initialiseForgotPassword(payload);
50
50
  setShowConfirmation(true);
51
51
  } catch (e) {
52
- errorToast({ error: e });
52
+ errorToast({ title: t("common.errors.error"), error: e });
53
53
  }
54
54
  };
55
55
 
@@ -152,7 +152,7 @@ export function Login() {
152
152
  {isDiscordAuthEnabled() && (
153
153
  <Link href={buildDiscordOAuthUrl()} className="flex w-full justify-end">
154
154
  <Button className="w-full" variant={`outline`} data-testid="page-login-button-initial-login">
155
- Login with Discord
155
+ {t(`auth.buttons.login_with_discord`)}
156
156
  </Button>
157
157
  </Link>
158
158
  )}
@@ -159,7 +159,7 @@ export default function Register() {
159
159
  clearReferralCode();
160
160
  setShowConfirmation(true);
161
161
  } catch (e) {
162
- errorToast({ error: e });
162
+ errorToast({ title: t("common.errors.error"), error: e });
163
163
  }
164
164
  };
165
165
 
@@ -106,8 +106,8 @@ export function ResetPassword() {
106
106
  ) : (
107
107
  <Form {...form}>
108
108
  <form onSubmit={form.handleSubmit(onSubmit)}>
109
- <FormPassword form={form} id="password" name={"Password"} />
110
- <FormPassword form={form} id="passwordRetype" name={"Retype Password"} />
109
+ <FormPassword form={form} id="password" name={t(`user.fields.password.label`)} />
110
+ <FormPassword form={form} id="passwordRetype" name={t(`auth.fields.retype_password.retype_label`)} />
111
111
  <Button className="mt-4 w-full" type={"submit"}>
112
112
  {t(`auth.buttons.reset_password`)}
113
113
  </Button>
@@ -59,7 +59,7 @@ export function BackupCodesDialog({ remainingCodes, onRegenerate, trigger }: Bac
59
59
  };
60
60
 
61
61
  const handleDownload = () => {
62
- const text = `Backup Codes\n\n${codes.join("\n")}\n\nKeep these codes safe. Each code can only be used once.`;
62
+ const text = `${t("auth.two_factor.backup_codes")}\n\n${codes.join("\n")}\n\n${t("auth.two_factor.backup_codes_file_note")}`;
63
63
  const blob = new Blob([text], { type: "text/plain" });
64
64
  const url = URL.createObjectURL(blob);
65
65
  const a = document.createElement("a");
@@ -38,7 +38,7 @@ export function PasskeyButton({ pendingToken, onSuccess, onError, disabled = fal
38
38
 
39
39
  onSuccess(auth);
40
40
  } catch (error) {
41
- onError(error instanceof Error ? error : new Error("Passkey authentication failed"));
41
+ onError(error instanceof Error ? error : new Error(t("auth.two_factor.passkey_failed")));
42
42
  } finally {
43
43
  setIsLoading(false);
44
44
  }
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { Activity, ChevronRight } from "lucide-react";
4
5
  import { Card, CardContent, CardHeader, CardTitle, Skeleton } from "../../../../shadcnui";
6
+ import { useI18nLocale } from "../../../../i18n";
5
7
  import { MeterInterface, MeterSummaryInterface } from "../../stripe-usage";
6
8
 
7
9
  type BillingUsageSummaryCardProps = {
@@ -12,8 +14,8 @@ type BillingUsageSummaryCardProps = {
12
14
  onViewDetailsClick: () => void;
13
15
  };
14
16
 
15
- function formatNumber(value: number): string {
16
- return new Intl.NumberFormat(undefined, {
17
+ function formatNumber(value: number, locale: string): string {
18
+ return new Intl.NumberFormat(locale, {
17
19
  notation: "compact",
18
20
  compactDisplay: "short",
19
21
  }).format(value);
@@ -26,11 +28,13 @@ export function BillingUsageSummaryCard({
26
28
  error,
27
29
  onViewDetailsClick,
28
30
  }: BillingUsageSummaryCardProps) {
31
+ const t = useTranslations();
32
+ const locale = useI18nLocale();
29
33
  if (loading) {
30
34
  return (
31
35
  <Card>
32
36
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
33
- <CardTitle>Usage This Month</CardTitle>
37
+ <CardTitle>{t("billing.ui.cards.usage.title")}</CardTitle>
34
38
  <Activity className="h-4 w-4 text-muted-foreground" />
35
39
  </CardHeader>
36
40
  <CardContent>
@@ -45,7 +49,7 @@ export function BillingUsageSummaryCard({
45
49
  return (
46
50
  <Card>
47
51
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
48
- <CardTitle>Usage This Month</CardTitle>
52
+ <CardTitle>{t("billing.ui.cards.usage.title")}</CardTitle>
49
53
  <Activity className="h-4 w-4 text-muted-foreground" />
50
54
  </CardHeader>
51
55
  <CardContent>
@@ -67,26 +71,32 @@ export function BillingUsageSummaryCard({
67
71
  return (
68
72
  <Card className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={onViewDetailsClick}>
69
73
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
70
- <CardTitle>Usage This Month</CardTitle>
74
+ <CardTitle>{t("billing.ui.cards.usage.title")}</CardTitle>
71
75
  <Activity className="h-4 w-4 text-muted-foreground" />
72
76
  </CardHeader>
73
77
  <CardContent>
74
78
  {meters.length === 0 ? (
75
79
  <div className="space-y-2">
76
- <p className="text-xl font-bold text-muted-foreground">No meters</p>
77
- <p className="text-xs text-muted-foreground">No usage meters are configured</p>
80
+ <p className="text-xl font-bold text-muted-foreground">{t("billing.ui.cards.usage.no_meters")}</p>
81
+ <p className="text-xs text-muted-foreground">{t("billing.ui.cards.usage.no_meters_description")}</p>
78
82
  </div>
79
83
  ) : (
80
84
  <div className="space-y-2">
81
- <p className="text-xl font-bold">{formatNumber(totalUsage)} units</p>
85
+ <p className="text-xl font-bold">
86
+ {t("billing.ui.cards.usage.units", { value: formatNumber(totalUsage, locale) })}
87
+ </p>
82
88
  {primaryMeter && primarySummary && (
83
89
  <p className="text-sm text-muted-foreground">
84
- {primaryMeter.displayName}: {formatNumber(primarySummary.aggregatedValue)}
90
+ {primaryMeter.displayName}: {formatNumber(primarySummary.aggregatedValue, locale)}
91
+ </p>
92
+ )}
93
+ {meters.length > 1 && (
94
+ <p className="text-xs text-muted-foreground">
95
+ {t("billing.ui.cards.usage.across_meters", { count: meters.length })}
85
96
  </p>
86
97
  )}
87
- {meters.length > 1 && <p className="text-xs text-muted-foreground">Across {meters.length} meters</p>}
88
98
  <span className="flex items-center text-xs text-muted-foreground">
89
- View details
99
+ {t("billing.ui.cards.view_details")}
90
100
  <ChevronRight className="h-3 w-3 ms-1 rtl:rotate-180" />
91
101
  </span>
92
102
  </div>
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { ExternalLink, User } from "lucide-react";
4
5
  import { useState } from "react";
5
6
  import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "../../../../shadcnui";
7
+ import { useI18nLocale } from "../../../../i18n";
6
8
  import { StripeCustomerInterface, StripeCustomerService } from "../../stripe-customer";
7
9
 
8
10
  type CustomerInfoCardProps = {
@@ -11,18 +13,19 @@ type CustomerInfoCardProps = {
11
13
  error?: string;
12
14
  };
13
15
 
14
- function formatBalance(balance: number | undefined, currency: string | undefined): string {
15
- if (balance === undefined || balance === 0) return "$0.00";
16
+ function formatBalance(balance: number | undefined, currency: string | undefined, locale: string): string {
16
17
  const currencyCode = currency?.toUpperCase() || "USD";
17
18
  // Balance in Stripe is negative when customer has credit
18
- const displayBalance = -balance;
19
- return new Intl.NumberFormat(undefined, {
19
+ const displayBalance = balance ? -balance : 0;
20
+ return new Intl.NumberFormat(locale, {
20
21
  style: "currency",
21
22
  currency: currencyCode,
22
23
  }).format(displayBalance / 100);
23
24
  }
24
25
 
25
26
  export function CustomerInfoCard({ customer, loading, error }: CustomerInfoCardProps) {
27
+ const t = useTranslations();
28
+ const locale = useI18nLocale();
26
29
  const [portalLoading, setPortalLoading] = useState(false);
27
30
 
28
31
  const handlePortalClick = async (e: React.MouseEvent) => {
@@ -42,7 +45,7 @@ export function CustomerInfoCard({ customer, loading, error }: CustomerInfoCardP
42
45
  return (
43
46
  <Card>
44
47
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
45
- <CardTitle>Billing Account</CardTitle>
48
+ <CardTitle>{t("billing.ui.cards.customer.title")}</CardTitle>
46
49
  <User className="h-4 w-4 text-muted-foreground" />
47
50
  </CardHeader>
48
51
  <CardContent>
@@ -58,7 +61,7 @@ export function CustomerInfoCard({ customer, loading, error }: CustomerInfoCardP
58
61
  return (
59
62
  <Card>
60
63
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
61
- <CardTitle>Billing Account</CardTitle>
64
+ <CardTitle>{t("billing.ui.cards.customer.title")}</CardTitle>
62
65
  <User className="h-4 w-4 text-muted-foreground" />
63
66
  </CardHeader>
64
67
  <CardContent>
@@ -72,12 +75,12 @@ export function CustomerInfoCard({ customer, loading, error }: CustomerInfoCardP
72
75
  return (
73
76
  <Card>
74
77
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
75
- <CardTitle>Billing Account</CardTitle>
78
+ <CardTitle>{t("billing.ui.cards.customer.title")}</CardTitle>
76
79
  <User className="h-4 w-4 text-muted-foreground" />
77
80
  </CardHeader>
78
81
  <CardContent>
79
- <p className="text-xl font-bold text-muted-foreground">Not set up</p>
80
- <p className="text-xs text-muted-foreground">Billing account will be created when you subscribe</p>
82
+ <p className="text-xl font-bold text-muted-foreground">{t("billing.ui.cards.customer.not_set_up")}</p>
83
+ <p className="text-xs text-muted-foreground">{t("billing.ui.cards.customer.not_set_up_description")}</p>
81
84
  </CardContent>
82
85
  </Card>
83
86
  );
@@ -86,7 +89,7 @@ export function CustomerInfoCard({ customer, loading, error }: CustomerInfoCardP
86
89
  return (
87
90
  <Card>
88
91
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
89
- <CardTitle>Billing Account</CardTitle>
92
+ <CardTitle>{t("billing.ui.cards.customer.title")}</CardTitle>
90
93
  <User className="h-4 w-4 text-muted-foreground" />
91
94
  </CardHeader>
92
95
  <CardContent>
@@ -95,14 +98,14 @@ export function CustomerInfoCard({ customer, loading, error }: CustomerInfoCardP
95
98
  {customer.email && <p className="text-sm text-muted-foreground">{customer.email}</p>}
96
99
  {customer.balance !== undefined && customer.balance !== 0 && (
97
100
  <p className="text-sm">
98
- <span className="text-muted-foreground">Credit Balance: </span>
101
+ <span className="text-muted-foreground">{t("billing.ui.cards.customer.credit_balance")} </span>
99
102
  <span className={customer.balance < 0 ? "text-success" : "text-destructive"}>
100
- {formatBalance(customer.balance, customer.currency)}
103
+ {formatBalance(customer.balance, customer.currency, locale)}
101
104
  </span>
102
105
  </p>
103
106
  )}
104
107
  <Button variant="outline" size="sm" className="mt-2" onClick={handlePortalClick} disabled={portalLoading}>
105
- {portalLoading ? "Loading..." : "Manage in Stripe Portal"}
108
+ {portalLoading ? t("common.loading") : t("billing.ui.cards.customer.manage_portal")}
106
109
  <ExternalLink className="h-4 w-4 ms-1" />
107
110
  </Button>
108
111
  </div>
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { ChevronRight, ReceiptIcon } from "lucide-react";
4
5
  import { Badge, Card, CardContent, CardHeader, CardTitle, Skeleton } from "../../../../shadcnui";
5
6
  import { InvoiceStatus, StripeInvoiceInterface } from "../../stripe-invoice";
7
+ import { formatDate, useBillingFormatOptions } from "../utils";
6
8
 
7
9
  type InvoicesSummaryCardProps = {
8
10
  invoices: StripeInvoiceInterface[];
@@ -11,6 +13,9 @@ type InvoicesSummaryCardProps = {
11
13
  onViewAllClick: () => void;
12
14
  };
13
15
 
16
+ /** Statuses with a `billing.ui.invoice_status.*` label; anything else Stripe adds is shown raw. */
17
+ const INVOICE_STATUSES: string[] = Object.values(InvoiceStatus);
18
+
14
19
  function getStatusBadgeVariant(status: InvoiceStatus): "default" | "secondary" | "destructive" | "outline" {
15
20
  switch (status) {
16
21
  case InvoiceStatus.PAID:
@@ -25,28 +30,22 @@ function getStatusBadgeVariant(status: InvoiceStatus): "default" | "secondary" |
25
30
  }
26
31
  }
27
32
 
28
- function formatDate(date: Date): string {
29
- return new Date(date).toLocaleDateString(undefined, {
30
- year: "numeric",
31
- month: "short",
32
- day: "numeric",
33
- });
34
- }
35
-
36
- function formatAmount(amount: number, currency: string): string {
33
+ function formatAmount(amount: number, currency: string, locale: string): string {
37
34
  const currencyCode = currency?.toUpperCase() || "USD";
38
- return new Intl.NumberFormat(undefined, {
35
+ return new Intl.NumberFormat(locale, {
39
36
  style: "currency",
40
37
  currency: currencyCode,
41
38
  }).format(amount / 100);
42
39
  }
43
40
 
44
41
  export function InvoicesSummaryCard({ invoices, loading, error, onViewAllClick }: InvoicesSummaryCardProps) {
42
+ const t = useTranslations();
43
+ const fmt = useBillingFormatOptions();
45
44
  if (loading) {
46
45
  return (
47
46
  <Card>
48
47
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
49
- <CardTitle>Recent Invoices</CardTitle>
48
+ <CardTitle>{t("billing.ui.cards.invoices.title")}</CardTitle>
50
49
  <ReceiptIcon className="h-4 w-4 text-muted-foreground" />
51
50
  </CardHeader>
52
51
  <CardContent>
@@ -62,7 +61,7 @@ export function InvoicesSummaryCard({ invoices, loading, error, onViewAllClick }
62
61
  return (
63
62
  <Card>
64
63
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
65
- <CardTitle>Recent Invoices</CardTitle>
64
+ <CardTitle>{t("billing.ui.cards.invoices.title")}</CardTitle>
66
65
  <ReceiptIcon className="h-4 w-4 text-muted-foreground" />
67
66
  </CardHeader>
68
67
  <CardContent>
@@ -80,29 +79,42 @@ export function InvoicesSummaryCard({ invoices, loading, error, onViewAllClick }
80
79
  return (
81
80
  <Card className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={onViewAllClick}>
82
81
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
83
- <CardTitle>Recent Invoices</CardTitle>
82
+ <CardTitle>{t("billing.ui.cards.invoices.title")}</CardTitle>
84
83
  <ReceiptIcon className="h-4 w-4 text-muted-foreground" />
85
84
  </CardHeader>
86
85
  <CardContent>
87
86
  {invoices.length === 0 ? (
88
87
  <div className="space-y-2">
89
- <p className="text-xl font-bold text-muted-foreground">No invoices yet</p>
90
- <p className="text-xs text-muted-foreground">Invoices will appear after your first billing cycle</p>
88
+ <p className="text-xl font-bold text-muted-foreground">{t("billing.ui.invoices.empty_title")}</p>
89
+ <p className="text-xs text-muted-foreground">{t("billing.ui.cards.invoices.empty_description")}</p>
91
90
  </div>
92
91
  ) : latestInvoice ? (
93
92
  <div className="space-y-2">
94
93
  <div className="flex items-center gap-2">
95
- <p className="text-xl font-bold">{formatAmount(latestInvoice.total, latestInvoice.currency)}</p>
96
- <Badge variant={getStatusBadgeVariant(latestInvoice.status)}>{latestInvoice.status}</Badge>
94
+ <p className="text-xl font-bold">
95
+ {formatAmount(latestInvoice.total, latestInvoice.currency, fmt.locale)}
96
+ </p>
97
+ <Badge variant={getStatusBadgeVariant(latestInvoice.status)}>
98
+ {INVOICE_STATUSES.includes(latestInvoice.status)
99
+ ? t(`billing.ui.invoice_status.${latestInvoice.status}`)
100
+ : latestInvoice.status}
101
+ </Badge>
97
102
  </div>
98
103
  <p className="text-sm text-muted-foreground">
99
- {latestInvoice.stripeInvoiceNumber || `Invoice from ${formatDate(latestInvoice.periodStart)}`}
104
+ {latestInvoice.stripeInvoiceNumber ||
105
+ t("billing.ui.cards.invoices.invoice_from", { date: formatDate(latestInvoice.periodStart, fmt) })}
100
106
  </p>
101
107
  <div className="flex items-center gap-4 text-xs text-muted-foreground">
102
- {paidInvoices.length > 0 && <span>{paidInvoices.length} paid</span>}
103
- {openInvoices.length > 0 && <span className="text-warning">{openInvoices.length} open</span>}
108
+ {paidInvoices.length > 0 && (
109
+ <span>{t("billing.ui.cards.invoices.paid_count", { count: paidInvoices.length })}</span>
110
+ )}
111
+ {openInvoices.length > 0 && (
112
+ <span className="text-warning">
113
+ {t("billing.ui.cards.invoices.open_count", { count: openInvoices.length })}
114
+ </span>
115
+ )}
104
116
  <span className="flex items-center">
105
- View all
117
+ {t("billing.ui.cards.view_all")}
106
118
  <ChevronRight className="h-3 w-3 ms-1 rtl:rotate-180" />
107
119
  </span>
108
120
  </div>