@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
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { Wallet, ChevronRight } from "lucide-react";
4
5
  import { Card, CardContent, CardHeader, CardTitle, Button, Skeleton } from "../../../../shadcnui";
5
6
  import { PaymentMethodInterface } from "../../stripe-customer";
@@ -32,11 +33,12 @@ export function PaymentMethodSummaryCard({
32
33
  error,
33
34
  onManageClick,
34
35
  }: PaymentMethodSummaryCardProps) {
36
+ const t = useTranslations();
35
37
  if (loading) {
36
38
  return (
37
39
  <Card>
38
40
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
39
- <CardTitle>Payment Method</CardTitle>
41
+ <CardTitle>{t("billing.ui.payment_method.title")}</CardTitle>
40
42
  <Wallet className="h-4 w-4 text-muted-foreground" />
41
43
  </CardHeader>
42
44
  <CardContent>
@@ -51,7 +53,7 @@ export function PaymentMethodSummaryCard({
51
53
  return (
52
54
  <Card>
53
55
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
54
- <CardTitle>Payment Method</CardTitle>
56
+ <CardTitle>{t("billing.ui.payment_method.title")}</CardTitle>
55
57
  <Wallet className="h-4 w-4 text-muted-foreground" />
56
58
  </CardHeader>
57
59
  <CardContent>
@@ -67,14 +69,16 @@ export function PaymentMethodSummaryCard({
67
69
  return (
68
70
  <Card className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={onManageClick}>
69
71
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
70
- <CardTitle>Payment Method</CardTitle>
72
+ <CardTitle>{t("billing.ui.payment_method.title")}</CardTitle>
71
73
  <Wallet className="h-4 w-4 text-muted-foreground" />
72
74
  </CardHeader>
73
75
  <CardContent>
74
76
  {paymentMethods.length === 0 ? (
75
77
  <div className="space-y-2">
76
- <p className="text-xl font-bold text-muted-foreground">No payment method</p>
77
- <p className="text-xs text-muted-foreground">Add a card to enable subscriptions</p>
78
+ <p className="text-xl font-bold text-muted-foreground">
79
+ {t("billing.ui.cards.payment_method.empty_title")}
80
+ </p>
81
+ <p className="text-xs text-muted-foreground">{t("billing.ui.cards.payment_method.empty_description")}</p>
78
82
  <Button
79
83
  variant="outline"
80
84
  size="sm"
@@ -84,7 +88,7 @@ export function PaymentMethodSummaryCard({
84
88
  onManageClick();
85
89
  }}
86
90
  >
87
- Add Card
91
+ {t("billing.ui.cards.payment_method.add_card")}
88
92
  <ChevronRight className="h-4 w-4 ms-1 rtl:rotate-180" />
89
93
  </Button>
90
94
  </div>
@@ -94,17 +98,23 @@ export function PaymentMethodSummaryCard({
94
98
  {getCardBrandIcon(defaultMethod.card.brand)} ****{defaultMethod.card.last4}
95
99
  </p>
96
100
  <p className="text-sm text-muted-foreground">
97
- Expires {String(defaultMethod.card.expMonth).padStart(2, "0")}/{defaultMethod.card.expYear}
101
+ {t("billing.ui.payment_method.expires", {
102
+ date: `${String(defaultMethod.card.expMonth).padStart(2, "0")}/${defaultMethod.card.expYear}`,
103
+ })}
98
104
  </p>
99
105
  {paymentMethods.length > 1 && (
100
- <p className="text-xs text-muted-foreground">+{paymentMethods.length - 1} more card(s)</p>
106
+ <p className="text-xs text-muted-foreground">
107
+ {t("billing.ui.cards.payment_method.more_cards", { count: paymentMethods.length - 1 })}
108
+ </p>
101
109
  )}
102
110
  </div>
103
111
  ) : (
104
112
  <div className="space-y-2">
105
- <p className="text-xl font-bold">{defaultMethod?.type || "Payment Method"}</p>
113
+ <p className="text-xl font-bold">{defaultMethod?.type || t("billing.ui.payment_method.title")}</p>
106
114
  {paymentMethods.length > 1 && (
107
- <p className="text-xs text-muted-foreground">+{paymentMethods.length - 1} more method(s)</p>
115
+ <p className="text-xs text-muted-foreground">
116
+ {t("billing.ui.cards.payment_method.more_methods", { count: paymentMethods.length - 1 })}
117
+ </p>
108
118
  )}
109
119
  </div>
110
120
  )}
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { ChevronRight, CreditCard } from "lucide-react";
4
5
  import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "../../../../shadcnui";
5
6
  import { StripeSubscriptionInterface, SubscriptionStatus } from "../../stripe-subscription";
7
+ import { formatDate, useBillingFormatOptions } from "../utils";
6
8
 
7
9
  type SubscriptionSummaryCardProps = {
8
10
  subscriptions: StripeSubscriptionInterface[];
@@ -11,6 +13,9 @@ type SubscriptionSummaryCardProps = {
11
13
  onManageClick: () => void;
12
14
  };
13
15
 
16
+ /** Statuses with a `billing.ui.subscription_status.*` label; anything else Stripe adds is shown raw. */
17
+ const SUBSCRIPTION_STATUSES: string[] = Object.values(SubscriptionStatus);
18
+
14
19
  function getStatusBadgeVariant(status: SubscriptionStatus): "default" | "secondary" | "destructive" | "outline" {
15
20
  switch (status) {
16
21
  case SubscriptionStatus.ACTIVE:
@@ -26,22 +31,14 @@ function getStatusBadgeVariant(status: SubscriptionStatus): "default" | "seconda
26
31
  }
27
32
  }
28
33
 
29
- function formatDate(date: Date): string {
30
- return new Date(date).toLocaleDateString(undefined, {
31
- year: "numeric",
32
- month: "short",
33
- day: "numeric",
34
- });
35
- }
36
-
37
- function formatPlanName(subscription: StripeSubscriptionInterface): string {
34
+ function formatPlanName(subscription: StripeSubscriptionInterface, fallback: string): string {
38
35
  const productName = subscription.price?.product?.name || "";
39
36
  const nickname = subscription.price?.nickname || "";
40
37
 
41
38
  if (productName && nickname) {
42
39
  return `${productName} - ${nickname}`;
43
40
  }
44
- return productName || nickname || "Subscription";
41
+ return productName || nickname || fallback;
45
42
  }
46
43
 
47
44
  export function SubscriptionSummaryCard({
@@ -50,11 +47,13 @@ export function SubscriptionSummaryCard({
50
47
  error,
51
48
  onManageClick,
52
49
  }: SubscriptionSummaryCardProps) {
50
+ const t = useTranslations();
51
+ const fmt = useBillingFormatOptions();
53
52
  if (loading) {
54
53
  return (
55
54
  <Card>
56
55
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
57
- <CardTitle>Subscriptions</CardTitle>
56
+ <CardTitle>{t("billing.ui.subscriptions.title")}</CardTitle>
58
57
  <CreditCard className="h-4 w-4 text-muted-foreground" />
59
58
  </CardHeader>
60
59
  <CardContent>
@@ -70,7 +69,7 @@ export function SubscriptionSummaryCard({
70
69
  return (
71
70
  <Card>
72
71
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
73
- <CardTitle>Subscriptions</CardTitle>
72
+ <CardTitle>{t("billing.ui.subscriptions.title")}</CardTitle>
74
73
  <CreditCard className="h-4 w-4 text-muted-foreground" />
75
74
  </CardHeader>
76
75
  <CardContent>
@@ -88,14 +87,14 @@ export function SubscriptionSummaryCard({
88
87
  return (
89
88
  <Card className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={onManageClick}>
90
89
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
91
- <CardTitle>Subscriptions</CardTitle>
90
+ <CardTitle>{t("billing.ui.subscriptions.title")}</CardTitle>
92
91
  <CreditCard className="h-4 w-4 text-muted-foreground" />
93
92
  </CardHeader>
94
93
  <CardContent>
95
94
  {subscriptions.length === 0 ? (
96
95
  <div className="space-y-2">
97
- <p className="text-xl font-bold text-muted-foreground">No active plan</p>
98
- <p className="text-xs text-muted-foreground">Subscribe to get started</p>
96
+ <p className="text-xl font-bold text-muted-foreground">{t("billing.ui.cards.subscriptions.empty_title")}</p>
97
+ <p className="text-xs text-muted-foreground">{t("billing.ui.cards.subscriptions.empty_description")}</p>
99
98
  <Button
100
99
  variant="outline"
101
100
  size="sm"
@@ -105,14 +104,16 @@ export function SubscriptionSummaryCard({
105
104
  onManageClick();
106
105
  }}
107
106
  >
108
- View Plans
107
+ {t("billing.ui.cards.subscriptions.view_plans")}
109
108
  <ChevronRight className="h-4 w-4 ms-1 rtl:rotate-180" />
110
109
  </Button>
111
110
  </div>
112
111
  ) : primarySubscription ? (
113
112
  <div className="space-y-2">
114
113
  <div className="flex items-center gap-2">
115
- <p className="text-xl font-bold">{formatPlanName(primarySubscription)}</p>
114
+ <p className="text-xl font-bold">
115
+ {formatPlanName(primarySubscription, t("billing.ui.cards.subscriptions.fallback_name"))}
116
+ </p>
116
117
  <Badge
117
118
  variant={
118
119
  primarySubscription.cancelAtPeriodEnd
@@ -120,18 +121,30 @@ export function SubscriptionSummaryCard({
120
121
  : getStatusBadgeVariant(primarySubscription.status)
121
122
  }
122
123
  >
123
- {primarySubscription.cancelAtPeriodEnd ? "Canceling" : primarySubscription.status}
124
+ {primarySubscription.cancelAtPeriodEnd
125
+ ? t("billing.ui.cards.subscriptions.canceling")
126
+ : SUBSCRIPTION_STATUSES.includes(primarySubscription.status)
127
+ ? t(`billing.ui.subscription_status.${primarySubscription.status}`)
128
+ : primarySubscription.status}
124
129
  </Badge>
125
130
  </div>
126
131
  <p className="text-xs text-muted-foreground">
127
132
  {!primarySubscription.price?.isTrial
128
- ? `Ends on ${formatDate(primarySubscription.currentPeriodEnd)}`
133
+ ? t("billing.ui.cards.subscriptions.ends_on", {
134
+ date: formatDate(primarySubscription.currentPeriodEnd, fmt),
135
+ })
129
136
  : primarySubscription.cancelAtPeriodEnd
130
- ? `Cancels on ${formatDate(primarySubscription.currentPeriodEnd)}`
131
- : `Renews on ${formatDate(primarySubscription.currentPeriodEnd)}`}
137
+ ? t("billing.ui.cards.subscriptions.cancels_on", {
138
+ date: formatDate(primarySubscription.currentPeriodEnd, fmt),
139
+ })
140
+ : t("billing.ui.cards.subscriptions.renews_on", {
141
+ date: formatDate(primarySubscription.currentPeriodEnd, fmt),
142
+ })}
132
143
  </p>
133
144
  {activeSubscriptions.length > 1 && (
134
- <p className="text-xs text-muted-foreground">+{activeSubscriptions.length - 1} more subscription(s)</p>
145
+ <p className="text-xs text-muted-foreground">
146
+ {t("billing.ui.cards.subscriptions.more", { count: activeSubscriptions.length - 1 })}
147
+ </p>
135
148
  )}
136
149
  </div>
137
150
  ) : null}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { CreditCard, Loader2, Wallet } from "lucide-react";
4
5
  import { useSearchParams } from "next/navigation";
5
6
  import { useCallback, useEffect, useMemo, useState } from "react";
@@ -51,6 +52,7 @@ type ErrorState = {
51
52
  };
52
53
 
53
54
  export function BillingDashboardContainer() {
55
+ const t = useTranslations();
54
56
  const [data, setData] = useState<DataState>({
55
57
  customer: null,
56
58
  subscriptions: [],
@@ -68,6 +70,7 @@ export function BillingDashboardContainer() {
68
70
  usage: true,
69
71
  });
70
72
 
73
+ // Error state holds message keys; they are translated at render time.
71
74
  const [errors, setErrors] = useState<ErrorState>({
72
75
  customer: null,
73
76
  subscriptions: null,
@@ -118,7 +121,7 @@ export function BillingDashboardContainer() {
118
121
  if ((error as { status?: number })?.status === 404) {
119
122
  setNoCustomerExists(true);
120
123
  } else {
121
- setErrors((prev) => ({ ...prev, customer: "Failed to load billing account" }));
124
+ setErrors((prev) => ({ ...prev, customer: "billing.ui.dashboard.errors.load_customer" }));
122
125
  }
123
126
  } finally {
124
127
  setLoading((prev) => ({ ...prev, customer: false }));
@@ -146,7 +149,7 @@ export function BillingDashboardContainer() {
146
149
  return subscriptions;
147
150
  } catch (error) {
148
151
  console.error("[BillingDashboard] Failed to load subscriptions:", error);
149
- setErrors((prev) => ({ ...prev, subscriptions: "Failed to load subscriptions" }));
152
+ setErrors((prev) => ({ ...prev, subscriptions: "billing.ui.dashboard.errors.load_subscriptions" }));
150
153
  return [];
151
154
  } finally {
152
155
  setLoading((prev) => ({ ...prev, subscriptions: false }));
@@ -161,7 +164,7 @@ export function BillingDashboardContainer() {
161
164
  setErrors((prev) => ({ ...prev, paymentMethods: null }));
162
165
  } catch (error) {
163
166
  console.error("[BillingDashboard] Failed to load payment methods:", error);
164
- setErrors((prev) => ({ ...prev, paymentMethods: "Failed to load payment methods" }));
167
+ setErrors((prev) => ({ ...prev, paymentMethods: "billing.ui.dashboard.errors.load_payment_methods" }));
165
168
  } finally {
166
169
  setLoading((prev) => ({ ...prev, paymentMethods: false }));
167
170
  }
@@ -175,7 +178,7 @@ export function BillingDashboardContainer() {
175
178
  setErrors((prev) => ({ ...prev, invoices: null }));
176
179
  } catch (error) {
177
180
  console.error("[BillingDashboard] Failed to load invoices:", error);
178
- setErrors((prev) => ({ ...prev, invoices: "Failed to load invoices" }));
181
+ setErrors((prev) => ({ ...prev, invoices: "billing.ui.dashboard.errors.load_invoices" }));
179
182
  } finally {
180
183
  setLoading((prev) => ({ ...prev, invoices: false }));
181
184
  }
@@ -206,7 +209,7 @@ export function BillingDashboardContainer() {
206
209
  await fetchAllData();
207
210
  } catch (error) {
208
211
  console.error("[BillingDashboard] Failed to create customer:", error);
209
- setErrors((prev) => ({ ...prev, customer: "Failed to set up billing" }));
212
+ setErrors((prev) => ({ ...prev, customer: "billing.ui.dashboard.errors.setup" }));
210
213
  } finally {
211
214
  setCreatingCustomer(false);
212
215
  }
@@ -242,7 +245,7 @@ export function BillingDashboardContainer() {
242
245
  setErrors((prev) => ({ ...prev, usage: null }));
243
246
  } catch (error) {
244
247
  console.error("[BillingDashboard] Failed to load usage data:", error);
245
- setErrors((prev) => ({ ...prev, usage: "Failed to load usage data" }));
248
+ setErrors((prev) => ({ ...prev, usage: "billing.ui.dashboard.errors.load_usage" }));
246
249
  } finally {
247
250
  setLoading((prev) => ({ ...prev, usage: false }));
248
251
  }
@@ -311,13 +314,13 @@ export function BillingDashboardContainer() {
311
314
  const getModalTitle = (type: ModalType): string => {
312
315
  switch (type) {
313
316
  case "subscriptions":
314
- return "Manage Subscriptions";
317
+ return t("billing.ui.dashboard.modal.subscriptions");
315
318
  case "payment-methods":
316
- return "Payment Methods";
319
+ return t("billing.ui.payment_methods.title");
317
320
  case "invoices":
318
- return "Invoice History";
321
+ return t("billing.ui.dashboard.modal.invoices");
319
322
  case "usage":
320
- return "Usage Tracking";
323
+ return t("billing.ui.usage.title");
321
324
  default:
322
325
  return "";
323
326
  }
@@ -331,7 +334,7 @@ export function BillingDashboardContainer() {
331
334
  {/* Header */}
332
335
  <div className="flex items-center gap-x-3">
333
336
  <Wallet className="h-8 w-8" />
334
- <h1 className="text-primary text-3xl font-semibold">Billing</h1>
337
+ <h1 className="text-primary text-3xl font-semibold">{t("billing.title")}</h1>
335
338
  </div>
336
339
 
337
340
  {/* Initial Loading State */}
@@ -350,27 +353,24 @@ export function BillingDashboardContainer() {
350
353
  <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
351
354
  <CreditCard className="h-8 w-8 text-primary" />
352
355
  </div>
353
- <CardTitle>Set Up Billing</CardTitle>
354
- <CardDescription>
355
- Your company doesn&apos;t have a billing account yet. Set one up to manage subscriptions, payment methods,
356
- and view invoices.
357
- </CardDescription>
356
+ <CardTitle>{t("billing.ui.dashboard.setup_title")}</CardTitle>
357
+ <CardDescription>{t("billing.ui.dashboard.setup_description")}</CardDescription>
358
358
  </CardHeader>
359
359
  <CardContent className="flex justify-center pb-8">
360
360
  <Button onClick={handleCreateCustomer} disabled={creatingCustomer} size="lg">
361
361
  {creatingCustomer ? (
362
362
  <>
363
363
  <Loader2 className="me-2 h-4 w-4 animate-spin" />
364
- Setting up...
364
+ {t("billing.ui.dashboard.setting_up")}
365
365
  </>
366
366
  ) : (
367
- "Set Up Billing Account"
367
+ t("billing.ui.dashboard.setup_button")
368
368
  )}
369
369
  </Button>
370
370
  </CardContent>
371
371
  {errors.customer && (
372
372
  <CardContent className="pt-0">
373
- <p className="text-center text-destructive text-xs/relaxed">{errors.customer}</p>
373
+ <p className="text-center text-destructive text-xs/relaxed">{t(errors.customer)}</p>
374
374
  </CardContent>
375
375
  )}
376
376
  </Card>
@@ -394,7 +394,7 @@ export function BillingDashboardContainer() {
394
394
  <SubscriptionSummaryCard
395
395
  subscriptions={data.subscriptions}
396
396
  loading={loading.subscriptions}
397
- error={errors.subscriptions || undefined}
397
+ error={errors.subscriptions ? t(errors.subscriptions) : undefined}
398
398
  onManageClick={() => {
399
399
  if (data.subscriptions.length === 0) {
400
400
  // No subscriptions - open wizard directly
@@ -410,20 +410,20 @@ export function BillingDashboardContainer() {
410
410
  paymentMethods={data.paymentMethods}
411
411
  defaultPaymentMethodId={data.customer?.defaultPaymentMethodId}
412
412
  loading={loading.paymentMethods}
413
- error={errors.paymentMethods || undefined}
413
+ error={errors.paymentMethods ? t(errors.paymentMethods) : undefined}
414
414
  onManageClick={() => setActiveModal("payment-methods")}
415
415
  />
416
416
 
417
417
  <CustomerInfoCard
418
418
  customer={data.customer}
419
419
  loading={loading.customer}
420
- error={errors.customer || undefined}
420
+ error={errors.customer ? t(errors.customer) : undefined}
421
421
  />
422
422
 
423
423
  <InvoicesSummaryCard
424
424
  invoices={data.invoices}
425
425
  loading={loading.invoices}
426
- error={errors.invoices || undefined}
426
+ error={errors.invoices ? t(errors.invoices) : undefined}
427
427
  onViewAllClick={() => setActiveModal("invoices")}
428
428
  />
429
429
 
@@ -433,7 +433,7 @@ export function BillingDashboardContainer() {
433
433
  meters={data.meters}
434
434
  summaries={data.meterSummaries}
435
435
  loading={loading.usage}
436
- error={errors.usage || undefined}
436
+ error={errors.usage ? t(errors.usage) : undefined}
437
437
  onViewDetailsClick={() => setActiveModal("usage")}
438
438
  />
439
439
  )}
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
3
+ import { formatCurrency, formatFixed, formatInterval } from "../currency";
4
+ import { formatDate } from "../date";
5
+
6
+ const price = (recurring?: { interval: string; intervalCount: number }, priceType = "recurring") =>
7
+ ({ priceType, recurring }) as unknown as StripePriceInterface;
8
+
9
+ /** Echoes the key and values so a test can see exactly what was asked for. */
10
+ const t = (key: string, values?: Record<string, any>) => (values ? `${key}|${JSON.stringify(values)}` : key);
11
+
12
+ describe("formatInterval", () => {
13
+ it("keeps the English output when no translator is passed", () => {
14
+ expect(formatInterval(price(undefined, "one_time"))).toBe("one-time");
15
+ expect(formatInterval(price({ interval: "month", intervalCount: 1 }))).toBe("/month");
16
+ expect(formatInterval(price({ interval: "week", intervalCount: 2 }))).toBe("/2 weeks");
17
+ });
18
+
19
+ it("asks the translator for every word it emits", () => {
20
+ expect(formatInterval(price(undefined, "one_time"), { t })).toBe("billing.ui.interval.one_time");
21
+ expect(formatInterval(price({ interval: "day", intervalCount: 1 }), { t })).toBe("billing.ui.interval.per_day");
22
+ expect(formatInterval(price({ interval: "year", intervalCount: 1 }), { t })).toBe("billing.ui.interval.per_year");
23
+ expect(formatInterval(price({ interval: "month", intervalCount: 3 }), { t })).toBe(
24
+ 'billing.ui.interval.per_months|{"count":3}',
25
+ );
26
+ });
27
+ });
28
+
29
+ describe("formatCurrency", () => {
30
+ it("keeps the en-US output and the $0.00 literal without a locale", () => {
31
+ expect(formatCurrency(999, "usd")).toBe("$9.99");
32
+ expect(formatCurrency(undefined, "usd")).toBe("$0.00");
33
+ });
34
+
35
+ it("formats with the given locale", () => {
36
+ expect(formatCurrency(999, "eur", { locale: "it" })).toMatch(/^9,99\s€$/);
37
+ expect(formatCurrency(undefined, "eur", { locale: "it" })).toMatch(/^0,00\s€$/);
38
+ });
39
+ });
40
+
41
+ describe("formatDate", () => {
42
+ const date = new Date(2025, 0, 15, 12);
43
+
44
+ it("keeps the English output without options", () => {
45
+ expect(formatDate(date)).toBe("Jan 15, 2025");
46
+ expect(formatDate(undefined)).toBe("N/A");
47
+ expect(formatDate(new Date("not a date"))).toBe("Invalid Date");
48
+ });
49
+
50
+ it("uses the locale and the translator when given", () => {
51
+ expect(formatDate(date, { locale: "it", t })).toBe("15 gen 2025");
52
+ expect(formatDate(undefined, { locale: "it", t })).toBe("billing.ui.not_available");
53
+ expect(formatDate(new Date("not a date"), { locale: "it", t })).toBe("billing.ui.invalid_date");
54
+ });
55
+ });
56
+
57
+ describe("formatFixed", () => {
58
+ it("matches toFixed in English and uses the locale's decimal separator otherwise", () => {
59
+ expect(formatFixed(12.345, "en", 1)).toBe((12.345).toFixed(1));
60
+ expect(formatFixed(1234.5, "en", 1)).toBe("1234.5");
61
+ expect(formatFixed(12.5, "it", 1)).toBe("12,5");
62
+ });
63
+ });
@@ -1,11 +1,8 @@
1
1
  import { StripePriceInterface } from "../../stripe-price/data/stripe-price.interface";
2
+ import { BillingFormatOptions, DEFAULT_BILLING_LOCALE } from "./format-options";
2
3
 
3
- /**
4
- * Format price recurring interval for display
5
- * @param price - Stripe price object
6
- * @returns Formatted interval string (e.g., "/month", "/year", "/2 weeks", "one-time")
7
- */
8
- export function formatInterval(price: StripePriceInterface): string {
4
+ /** English output kept for callers that pass no translator. */
5
+ function formatIntervalEnglish(price: StripePriceInterface): string {
9
6
  if (price.priceType === "one_time" || !price.recurring) {
10
7
  return "one-time";
11
8
  }
@@ -22,20 +19,68 @@ export function formatInterval(price: StripePriceInterface): string {
22
19
  return `/${intervalCount} ${pluralInterval}`;
23
20
  }
24
21
 
22
+ /**
23
+ * Format price recurring interval for display
24
+ * @param price - Stripe price object
25
+ * @param options - `t` translates the interval words; without it the output is English
26
+ * @returns Formatted interval string (e.g., "/month", "/year", "/2 weeks", "one-time")
27
+ */
28
+ export function formatInterval(price: StripePriceInterface, options: BillingFormatOptions = {}): string {
29
+ const { t } = options;
30
+ if (!t) return formatIntervalEnglish(price);
31
+
32
+ if (price.priceType === "one_time" || !price.recurring) {
33
+ return t("billing.ui.interval.one_time");
34
+ }
35
+
36
+ const { interval, intervalCount } = price.recurring;
37
+
38
+ if (intervalCount === 1) {
39
+ switch (interval) {
40
+ case "day":
41
+ return t("billing.ui.interval.per_day");
42
+ case "week":
43
+ return t("billing.ui.interval.per_week");
44
+ case "month":
45
+ return t("billing.ui.interval.per_month");
46
+ default:
47
+ return t("billing.ui.interval.per_year");
48
+ }
49
+ }
50
+
51
+ switch (interval) {
52
+ case "day":
53
+ return t("billing.ui.interval.per_days", { count: intervalCount });
54
+ case "week":
55
+ return t("billing.ui.interval.per_weeks", { count: intervalCount });
56
+ case "month":
57
+ return t("billing.ui.interval.per_months", { count: intervalCount });
58
+ default:
59
+ return t("billing.ui.interval.per_years", { count: intervalCount });
60
+ }
61
+ }
62
+
25
63
  /**
26
64
  * Format currency amount from cents to localized currency string
27
65
  * @param amount - Amount in cents
28
66
  * @param currency - Currency code (e.g., "USD", "EUR", "GBP")
67
+ * @param options - `locale` drives number formatting; defaults to "en-US"
29
68
  * @returns Formatted currency string (e.g., "$9.99", "€9,99")
30
69
  */
31
- export function formatCurrency(amount: number | undefined, currency: string): string {
32
- if (amount === undefined) return "$0.00";
70
+ export function formatCurrency(
71
+ amount: number | undefined,
72
+ currency: string,
73
+ options: BillingFormatOptions = {},
74
+ ): string {
75
+ const { locale } = options;
76
+ // No locale: the historical literal. With one, zero goes through Intl like any amount.
77
+ if (amount === undefined && !locale) return "$0.00";
33
78
 
34
79
  // Convert cents to dollars
35
- const dollars = amount / 100;
80
+ const dollars = (amount ?? 0) / 100;
36
81
 
37
82
  try {
38
- return new Intl.NumberFormat("en-US", {
83
+ return new Intl.NumberFormat(locale ?? DEFAULT_BILLING_LOCALE, {
39
84
  style: "currency",
40
85
  currency: currency.toUpperCase(),
41
86
  minimumFractionDigits: 2,
@@ -47,3 +92,15 @@ export function formatCurrency(amount: number | undefined, currency: string): st
47
92
  return `$${dollars.toFixed(2)}`;
48
93
  }
49
94
  }
95
+
96
+ /**
97
+ * `value.toFixed(digits)` with the locale's decimal separator ("12.5" in English,
98
+ * "12,5" in Italian). No grouping, so English output matches `toFixed` exactly.
99
+ */
100
+ export function formatFixed(value: number, locale: string | undefined, digits: number): string {
101
+ return new Intl.NumberFormat(locale ?? DEFAULT_BILLING_LOCALE, {
102
+ minimumFractionDigits: digits,
103
+ maximumFractionDigits: digits,
104
+ useGrouping: false,
105
+ }).format(value);
106
+ }
@@ -1,21 +1,26 @@
1
+ import { BillingFormatOptions, DEFAULT_BILLING_LOCALE } from "./format-options";
2
+
1
3
  /**
2
4
  * Format a date to a localized date string
3
5
  * @param date - Date object or ISO string
6
+ * @param options - `locale` drives the date format (default "en-US"); `t` translates the
7
+ * placeholder shown for a missing or invalid date (default English)
4
8
  * @returns Formatted date string (e.g., "Jan 15, 2025")
5
9
  */
6
- export function formatDate(date: Date | string | undefined): string {
7
- if (!date) return "N/A";
10
+ export function formatDate(date: Date | string | undefined, options: BillingFormatOptions = {}): string {
11
+ const { locale, t } = options;
12
+ if (!date) return t ? t("billing.ui.not_available") : "N/A";
8
13
 
9
14
  const dateObj = typeof date === "string" ? new Date(date) : date;
10
15
 
11
16
  try {
12
- return new Intl.DateTimeFormat("en-US", {
17
+ return new Intl.DateTimeFormat(locale ?? DEFAULT_BILLING_LOCALE, {
13
18
  month: "short",
14
19
  day: "numeric",
15
20
  year: "numeric",
16
21
  }).format(dateObj);
17
22
  } catch (error) {
18
23
  console.error("Error formatting date:", error);
19
- return "Invalid Date";
24
+ return t ? t("billing.ui.invalid_date") : "Invalid Date";
20
25
  }
21
26
  }
@@ -0,0 +1,20 @@
1
+ /** A message translator, as returned by `useTranslations()`. */
2
+ export type BillingTranslator = (key: string, values?: Record<string, any>) => string;
3
+
4
+ /**
5
+ * What the billing formatters need from the caller's React context. The
6
+ * formatters are plain functions and cannot call hooks, so a component reads
7
+ * `useI18nLocale()` and `useTranslations()` and passes both here.
8
+ *
9
+ * Both are optional so existing callers keep their output: without a `locale`
10
+ * the formatters use "en-US", without `t` they return the English literals they
11
+ * always returned.
12
+ */
13
+ export type BillingFormatOptions = {
14
+ /** BCP 47 locale for `Intl` (the app's active locale, from `useI18nLocale()`). */
15
+ locale?: string;
16
+ /** Translator for the words the formatters emit. */
17
+ t?: BillingTranslator;
18
+ };
19
+
20
+ export const DEFAULT_BILLING_LOCALE = "en-US";
@@ -1,2 +1,4 @@
1
- export { formatCurrency, formatInterval } from "./currency";
1
+ export { formatCurrency, formatFixed, formatInterval } from "./currency";
2
2
  export { formatDate } from "./date";
3
+ export type { BillingFormatOptions, BillingTranslator } from "./format-options";
4
+ export { useBillingFormatOptions } from "./useBillingFormatOptions";
@@ -0,0 +1,11 @@
1
+ import { useTranslations } from "next-intl";
2
+ import { useMemo } from "react";
3
+ import { useI18nLocale } from "../../../../i18n";
4
+ import { BillingFormatOptions } from "./format-options";
5
+
6
+ /** The active locale and translator, ready to pass to the billing formatters. */
7
+ export function useBillingFormatOptions(): Required<BillingFormatOptions> {
8
+ const t = useTranslations();
9
+ const locale = useI18nLocale();
10
+ return useMemo(() => ({ locale, t }), [locale, t]);
11
+ }