@carlonicora/nextjs-jsonapi 6.23.0 → 6.25.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 (250) hide show
  1. package/dist/{BlockNoteEditor-PPRML5CO.js → BlockNoteEditor-TNX77GCD.js} +28 -25
  2. package/dist/BlockNoteEditor-TNX77GCD.js.map +1 -0
  3. package/dist/{BlockNoteEditor-UO4ZRCUM.mjs → BlockNoteEditor-Z5LDF47Z.mjs} +13 -10
  4. package/dist/{BlockNoteEditor-UO4ZRCUM.mjs.map → BlockNoteEditor-Z5LDF47Z.mjs.map} +1 -1
  5. package/dist/{BlockNoteViewer-K2HBF3IP.js → BlockNoteViewer-6KQJODNF.js} +9 -7
  6. package/dist/BlockNoteViewer-6KQJODNF.js.map +1 -0
  7. package/dist/{BlockNoteViewer-2TJZ6JET.mjs → BlockNoteViewer-TQ452ZYW.mjs} +8 -6
  8. package/dist/{BlockNoteViewer-2TJZ6JET.mjs.map → BlockNoteViewer-TQ452ZYW.mjs.map} +1 -1
  9. package/dist/JsonApiRequest-OKNQZLS6.js +26 -0
  10. package/dist/{JsonApiRequest-KC6DOHKT.js.map → JsonApiRequest-OKNQZLS6.js.map} +1 -1
  11. package/dist/{JsonApiRequest-ADLBJ5RX.mjs → JsonApiRequest-WNPDS356.mjs} +2 -2
  12. package/dist/billing/index.js +504 -517
  13. package/dist/billing/index.js.map +1 -1
  14. package/dist/billing/index.mjs +20 -33
  15. package/dist/billing/index.mjs.map +1 -1
  16. package/dist/{chunk-VNUT2D27.mjs → chunk-2PWYG2WJ.mjs} +2 -2
  17. package/dist/{chunk-W4ML6D2X.mjs → chunk-3U7YJ73T.mjs} +7 -7
  18. package/dist/{chunk-BQ2WAG4K.js → chunk-3VNDFKT7.js} +19 -19
  19. package/dist/{chunk-BQ2WAG4K.js.map → chunk-3VNDFKT7.js.map} +1 -1
  20. package/dist/{chunk-BE6UMM5X.mjs → chunk-47HNJQ7U.mjs} +3 -3
  21. package/dist/chunk-5T2AQMQ2.mjs +90 -0
  22. package/dist/chunk-5T2AQMQ2.mjs.map +1 -0
  23. package/dist/{chunk-A5M3MORY.js → chunk-5WM72MLE.js} +3 -3
  24. package/dist/{chunk-A5M3MORY.js.map → chunk-5WM72MLE.js.map} +1 -1
  25. package/dist/{chunk-S7SPAC6X.mjs → chunk-73NKUBMP.mjs} +3 -3
  26. package/dist/chunk-743M4TIB.mjs +76 -0
  27. package/dist/chunk-743M4TIB.mjs.map +1 -0
  28. package/dist/{chunk-HN7L6RRW.js → chunk-CSBQAPHD.js} +903 -955
  29. package/dist/chunk-CSBQAPHD.js.map +1 -0
  30. package/dist/{chunk-YTDGYI62.mjs → chunk-DWPHHUMV.mjs} +3 -3
  31. package/dist/{chunk-53SD4YRF.js → chunk-FBHUUPRV.js} +5 -5
  32. package/dist/{chunk-53SD4YRF.js.map → chunk-FBHUUPRV.js.map} +1 -1
  33. package/dist/{chunk-T6G2F2BK.mjs → chunk-HJT7TXGG.mjs} +16 -68
  34. package/dist/chunk-HJT7TXGG.mjs.map +1 -0
  35. package/dist/chunk-HROKKUBQ.js +156 -0
  36. package/dist/chunk-HROKKUBQ.js.map +1 -0
  37. package/dist/{chunk-TMV7TL7O.mjs → chunk-JELEQV5T.mjs} +8 -6
  38. package/dist/{chunk-TMV7TL7O.mjs.map → chunk-JELEQV5T.mjs.map} +1 -1
  39. package/dist/{chunk-TNTCLVUR.js → chunk-JGL4P3DS.js} +17 -15
  40. package/dist/chunk-JGL4P3DS.js.map +1 -0
  41. package/dist/{chunk-7OIWOPM5.mjs → chunk-K7KLUYS7.mjs} +4 -3
  42. package/dist/chunk-K7KLUYS7.mjs.map +1 -0
  43. package/dist/{chunk-HP6DT6K3.mjs → chunk-KVYYPKRO.mjs} +311 -241
  44. package/dist/chunk-KVYYPKRO.mjs.map +1 -0
  45. package/dist/{chunk-RCOKQ3R4.js → chunk-LNO2JLU3.js} +2 -2
  46. package/dist/{chunk-RCOKQ3R4.js.map → chunk-LNO2JLU3.js.map} +1 -1
  47. package/dist/{chunk-R5722NLL.js → chunk-M6SNTMK6.js} +1388 -1318
  48. package/dist/chunk-M6SNTMK6.js.map +1 -0
  49. package/dist/{chunk-GMMDXINC.js → chunk-N3NQS7X6.js} +12 -12
  50. package/dist/{chunk-GMMDXINC.js.map → chunk-N3NQS7X6.js.map} +1 -1
  51. package/dist/chunk-NP3AFULU.js +76 -0
  52. package/dist/chunk-NP3AFULU.js.map +1 -0
  53. package/dist/{chunk-SO3YUCL6.js → chunk-OOM54Z5Z.js} +6 -5
  54. package/dist/chunk-OOM54Z5Z.js.map +1 -0
  55. package/dist/{chunk-YSE2Z4RB.js → chunk-PQQFOONG.js} +12 -12
  56. package/dist/{chunk-YSE2Z4RB.js.map → chunk-PQQFOONG.js.map} +1 -1
  57. package/dist/chunk-RW6XZGZG.mjs +156 -0
  58. package/dist/chunk-RW6XZGZG.mjs.map +1 -0
  59. package/dist/chunk-SYMI4IDW.js +90 -0
  60. package/dist/chunk-SYMI4IDW.js.map +1 -0
  61. package/dist/{chunk-N2BFT7KI.mjs → chunk-W7U6O3OE.mjs} +2 -71
  62. package/dist/chunk-W7U6O3OE.mjs.map +1 -0
  63. package/dist/{chunk-YMCL3JXZ.mjs → chunk-YPBIPE2B.mjs} +2 -2
  64. package/dist/{chunk-P7L6OFX5.js → chunk-Z6T2575A.js} +12 -81
  65. package/dist/chunk-Z6T2575A.js.map +1 -0
  66. package/dist/client/index.js +13 -10
  67. package/dist/client/index.js.map +1 -1
  68. package/dist/client/index.mjs +17 -14
  69. package/dist/components/forms/DatePickerPopover.d.ts.map +1 -1
  70. package/dist/components/forms/DateRangeSelector.d.ts.map +1 -1
  71. package/dist/components/forms/EditorSheet.d.ts +6 -1
  72. package/dist/components/forms/EditorSheet.d.ts.map +1 -1
  73. package/dist/components/forms/FormDate.d.ts.map +1 -1
  74. package/dist/components/forms/FormDateTime.d.ts.map +1 -1
  75. package/dist/components/forms/time-cycle.d.ts +14 -0
  76. package/dist/components/forms/time-cycle.d.ts.map +1 -0
  77. package/dist/components/index.js +11 -8
  78. package/dist/components/index.js.map +1 -1
  79. package/dist/components/index.mjs +10 -7
  80. package/dist/components/tables/cells/cell.date.d.ts.map +1 -1
  81. package/dist/components/tables/cells/cell.dateTime.d.ts.map +1 -1
  82. package/dist/contexts/DateLocaleContext.d.ts +56 -0
  83. package/dist/contexts/DateLocaleContext.d.ts.map +1 -0
  84. package/dist/contexts/index.d.ts +1 -0
  85. package/dist/contexts/index.d.ts.map +1 -1
  86. package/dist/contexts/index.js +24 -8
  87. package/dist/contexts/index.js.map +1 -1
  88. package/dist/contexts/index.mjs +23 -7
  89. package/dist/core/index.js +17 -6
  90. package/dist/core/index.js.map +1 -1
  91. package/dist/core/index.mjs +19 -8
  92. package/dist/date-locale/index.d.ts +2 -0
  93. package/dist/date-locale/index.d.ts.map +1 -0
  94. package/dist/date-locale/index.js +21 -0
  95. package/dist/date-locale/index.js.map +1 -0
  96. package/dist/date-locale/index.mjs +21 -0
  97. package/dist/email-automations/index.js +131 -123
  98. package/dist/email-automations/index.js.map +1 -1
  99. package/dist/email-automations/index.mjs +23 -15
  100. package/dist/email-automations/index.mjs.map +1 -1
  101. package/dist/features/account-deletion/components/AccountDeletionAdminTrigger.d.ts.map +1 -1
  102. package/dist/features/analytics/components/AnalyticsJourneyContainer.d.ts.map +1 -1
  103. package/dist/features/analytics/components/AnalyticsSessionsTable.d.ts.map +1 -1
  104. package/dist/features/analytics/index.js +154 -147
  105. package/dist/features/analytics/index.js.map +1 -1
  106. package/dist/features/analytics/index.mjs +32 -25
  107. package/dist/features/analytics/index.mjs.map +1 -1
  108. package/dist/features/analytics/server.js +11 -9
  109. package/dist/features/analytics/server.js.map +1 -1
  110. package/dist/features/analytics/server.mjs +9 -7
  111. package/dist/features/analytics/server.mjs.map +1 -1
  112. package/dist/features/billing/components/utils/date.d.ts +3 -3
  113. package/dist/features/billing/components/utils/format-options.d.ts +6 -4
  114. package/dist/features/billing/components/utils/format-options.d.ts.map +1 -1
  115. package/dist/features/billing/components/utils/useBillingFormatOptions.d.ts +5 -1
  116. package/dist/features/billing/components/utils/useBillingFormatOptions.d.ts.map +1 -1
  117. package/dist/features/billing/stripe-usage/components/details/UsageSummaryCard.d.ts.map +1 -1
  118. package/dist/features/billing/stripe-usage/components/lists/UsageHistoryTable.d.ts.map +1 -1
  119. package/dist/features/blog/components/admin/BlogTranslationStatus.d.ts.map +1 -1
  120. package/dist/features/blog/components/public/BlogPostMeta.d.ts.map +1 -1
  121. package/dist/features/blog/hooks/useBlogPostTableStructure.d.ts.map +1 -1
  122. package/dist/features/blog/index.js +168 -162
  123. package/dist/features/blog/index.js.map +1 -1
  124. package/dist/features/blog/index.mjs +25 -19
  125. package/dist/features/blog/index.mjs.map +1 -1
  126. package/dist/features/blog/server.js +29 -27
  127. package/dist/features/blog/server.js.map +1 -1
  128. package/dist/features/blog/server.mjs +8 -6
  129. package/dist/features/blog/server.mjs.map +1 -1
  130. package/dist/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.d.ts.map +1 -1
  131. package/dist/features/handbook/components/parts/HandbookIndexStatus.d.ts.map +1 -1
  132. package/dist/features/help/components/HelpArticleBody.d.ts.map +1 -1
  133. package/dist/features/help/index.js +48 -43
  134. package/dist/features/help/index.js.map +1 -1
  135. package/dist/features/help/index.mjs +15 -10
  136. package/dist/features/help/index.mjs.map +1 -1
  137. package/dist/features/notification/components/notifications/Notification.d.ts +2 -1
  138. package/dist/features/notification/components/notifications/Notification.d.ts.map +1 -1
  139. package/dist/features/notification/contexts/NotificationContext.d.ts.map +1 -1
  140. package/dist/features/notification/hooks/useNotificationTableStructure.d.ts.map +1 -1
  141. package/dist/features/oauth/components/OAuthClientCard.d.ts.map +1 -1
  142. package/dist/features/oauth/components/OAuthClientDetail.d.ts.map +1 -1
  143. package/dist/features/tokenusage/index.js +133 -130
  144. package/dist/features/tokenusage/index.js.map +1 -1
  145. package/dist/features/tokenusage/index.mjs +10 -7
  146. package/dist/features/tokenusage/index.mjs.map +1 -1
  147. package/dist/features/tokenusage/lib/formatters.d.ts +6 -3
  148. package/dist/features/tokenusage/lib/formatters.d.ts.map +1 -1
  149. package/dist/index.js +19 -7
  150. package/dist/index.js.map +1 -1
  151. package/dist/index.mjs +23 -11
  152. package/dist/local-transport-AT4TE264.js +15 -0
  153. package/dist/{local-transport-MJPOKPTU.js.map → local-transport-AT4TE264.js.map} +1 -1
  154. package/dist/{local-transport-RVD4FZGA.mjs → local-transport-KWEUZ4GX.mjs} +5 -5
  155. package/dist/offline/index.js +56 -54
  156. package/dist/offline/index.js.map +1 -1
  157. package/dist/offline/index.mjs +23 -21
  158. package/dist/offline/index.mjs.map +1 -1
  159. package/dist/{request-4AWGPIGH.mjs → request-6GAIWWTG.mjs} +2 -2
  160. package/dist/{request-H5FZBZA2.js → request-BWO4Y434.js} +3 -3
  161. package/dist/{request-H5FZBZA2.js.map → request-BWO4Y434.js.map} +1 -1
  162. package/dist/server/ServerSession.d.ts +2 -0
  163. package/dist/server/ServerSession.d.ts.map +1 -1
  164. package/dist/server/index.js +10 -8
  165. package/dist/server/index.js.map +1 -1
  166. package/dist/server/index.mjs +9 -7
  167. package/dist/utils/date-formatter.d.ts +45 -4
  168. package/dist/utils/date-formatter.d.ts.map +1 -1
  169. package/dist/utils/index.d.ts +1 -1
  170. package/dist/utils/index.d.ts.map +1 -1
  171. package/dist/write-through-KNRI5L4V.mjs +15 -0
  172. package/dist/write-through-KNRI5L4V.mjs.map +1 -0
  173. package/dist/write-through-MYHQ6GJU.js +15 -0
  174. package/dist/{write-through-HE2QSOVI.js.map → write-through-MYHQ6GJU.js.map} +1 -1
  175. package/messages/en.json +1 -0
  176. package/messages/it.json +1 -0
  177. package/package.json +6 -1
  178. package/src/components/forms/DatePickerPopover.tsx +5 -3
  179. package/src/components/forms/DateRangeSelector.tsx +9 -7
  180. package/src/components/forms/EditorSheet.tsx +8 -0
  181. package/src/components/forms/FormDate.tsx +5 -3
  182. package/src/components/forms/FormDateTime.tsx +75 -39
  183. package/src/components/forms/__tests__/EditorSheet.spec.tsx +30 -0
  184. package/src/components/forms/__tests__/time-cycle.spec.ts +37 -0
  185. package/src/components/forms/time-cycle.ts +27 -0
  186. package/src/components/tables/cells/cell.date.tsx +3 -5
  187. package/src/components/tables/cells/cell.dateTime.tsx +3 -7
  188. package/src/contexts/DateLocaleContext.tsx +106 -0
  189. package/src/contexts/__tests__/DateLocaleContext.spec.tsx +175 -0
  190. package/src/contexts/index.ts +1 -0
  191. package/src/date-locale/index.ts +12 -0
  192. package/src/features/account-deletion/components/AccountDeletionAdminTrigger.tsx +3 -4
  193. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +8 -8
  194. package/src/features/analytics/components/AnalyticsSessionsTable.tsx +4 -6
  195. package/src/features/auth/components/two-factor/PasskeyList.tsx +3 -3
  196. package/src/features/auth/components/two-factor/TotpAuthenticatorList.tsx +3 -3
  197. package/src/features/billing/components/utils/__tests__/billing-formatters.spec.ts +14 -6
  198. package/src/features/billing/components/utils/date.ts +5 -5
  199. package/src/features/billing/components/utils/format-options.ts +6 -4
  200. package/src/features/billing/components/utils/useBillingFormatOptions.ts +8 -2
  201. package/src/features/billing/stripe-usage/components/details/UsageSummaryCard.tsx +4 -12
  202. package/src/features/billing/stripe-usage/components/lists/UsageHistoryTable.tsx +4 -17
  203. package/src/features/blog/components/admin/BlogTranslationStatus.tsx +3 -2
  204. package/src/features/blog/components/public/BlogPostMeta.tsx +5 -4
  205. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +1 -2
  206. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +2 -3
  207. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +4 -8
  208. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +11 -5
  209. package/src/features/email-blast/components/containers/EmailBlastContainer.tsx +3 -3
  210. package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +4 -4
  211. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +4 -3
  212. package/src/features/help/components/HelpArticleBody.tsx +4 -3
  213. package/src/features/notification/components/notifications/Notification.tsx +38 -7
  214. package/src/features/notification/contexts/NotificationContext.tsx +6 -3
  215. package/src/features/notification/hooks/useNotificationTableStructure.tsx +6 -5
  216. package/src/features/oauth/components/OAuthClientCard.tsx +8 -5
  217. package/src/features/oauth/components/OAuthClientDetail.tsx +3 -6
  218. package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +19 -0
  219. package/src/features/tokenusage/lib/formatters.ts +11 -6
  220. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +5 -5
  221. package/src/server/ServerSession.ts +3 -0
  222. package/src/server/__tests__/ServerSession.read.spec.ts +18 -1
  223. package/src/utils/__tests__/date-formatter.spec.ts +134 -10
  224. package/src/utils/date-formatter.ts +200 -44
  225. package/src/utils/index.ts +11 -1
  226. package/dist/BlockNoteEditor-PPRML5CO.js.map +0 -1
  227. package/dist/BlockNoteViewer-K2HBF3IP.js.map +0 -1
  228. package/dist/JsonApiRequest-KC6DOHKT.js +0 -26
  229. package/dist/chunk-7OIWOPM5.mjs.map +0 -1
  230. package/dist/chunk-HN7L6RRW.js.map +0 -1
  231. package/dist/chunk-HP6DT6K3.mjs.map +0 -1
  232. package/dist/chunk-N2BFT7KI.mjs.map +0 -1
  233. package/dist/chunk-P7L6OFX5.js.map +0 -1
  234. package/dist/chunk-R5722NLL.js.map +0 -1
  235. package/dist/chunk-SO3YUCL6.js.map +0 -1
  236. package/dist/chunk-T6G2F2BK.mjs.map +0 -1
  237. package/dist/chunk-TNTCLVUR.js.map +0 -1
  238. package/dist/local-transport-MJPOKPTU.js +0 -15
  239. package/dist/write-through-7I3LA2WN.mjs +0 -15
  240. package/dist/write-through-HE2QSOVI.js +0 -15
  241. /package/dist/{JsonApiRequest-ADLBJ5RX.mjs.map → JsonApiRequest-WNPDS356.mjs.map} +0 -0
  242. /package/dist/{chunk-VNUT2D27.mjs.map → chunk-2PWYG2WJ.mjs.map} +0 -0
  243. /package/dist/{chunk-W4ML6D2X.mjs.map → chunk-3U7YJ73T.mjs.map} +0 -0
  244. /package/dist/{chunk-BE6UMM5X.mjs.map → chunk-47HNJQ7U.mjs.map} +0 -0
  245. /package/dist/{chunk-S7SPAC6X.mjs.map → chunk-73NKUBMP.mjs.map} +0 -0
  246. /package/dist/{chunk-YTDGYI62.mjs.map → chunk-DWPHHUMV.mjs.map} +0 -0
  247. /package/dist/{chunk-YMCL3JXZ.mjs.map → chunk-YPBIPE2B.mjs.map} +0 -0
  248. /package/dist/{local-transport-RVD4FZGA.mjs.map → date-locale/index.mjs.map} +0 -0
  249. /package/dist/{request-4AWGPIGH.mjs.map → local-transport-KWEUZ4GX.mjs.map} +0 -0
  250. /package/dist/{write-through-7I3LA2WN.mjs.map → request-6GAIWWTG.mjs.map} +0 -0
@@ -1,13 +1,9 @@
1
1
  import { ColumnDef } from "@tanstack/react-table";
2
- import { useI18nLocale } from "../../../i18n";
2
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
3
3
 
4
4
  function DateTimeCell({ date }: { date: Date }) {
5
- const locale = useI18nLocale();
6
- return (
7
- <span className="text-muted-foreground text-xs">
8
- {date.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short", hour12: false })}
9
- </span>
10
- );
5
+ const { format } = useDateFormatter();
6
+ return <span className="text-muted-foreground text-xs">{format(date, "dateTimeMedium")}</span>;
11
7
  }
12
8
 
13
9
  export const cellDateTime = (params: { name: string; title: string }): ColumnDef<any> => {
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useI18nLocale } from "../i18n/config";
5
+ import type { SessionSnapshot } from "../server/ServerSession";
6
+ import {
7
+ type DateFormatStyle,
8
+ formatDateStyle,
9
+ formatRelativeDate,
10
+ resolveDateLocale,
11
+ usesTwelveHourClock,
12
+ } from "../utils/date-formatter";
13
+ import { mapThenable } from "../utils/map-thenable";
14
+
15
+ /**
16
+ * The locale every real-world date is formatted in.
17
+ *
18
+ * Signed-in shells pass the request's session promise: the provider maps it to
19
+ * the `Accept-Language` header once and never awaits it, so its children render
20
+ * at once; only `useDateLocale()` callers suspend until it settles (the shape of
21
+ * `ViewportProvider`). Public layouts pass `mode="public"`: the app language,
22
+ * with all-numeric styles spelled out. Without a provider: the app language.
23
+ *
24
+ * THIS LIVES IN `contexts/` for the same reason as `ViewportProvider`: the
25
+ * server layouts render it directly, so it needs the "use client" boundary that
26
+ * tsup stamps onto `dist/contexts/*`.
27
+ */
28
+ export type DateLocaleSource = { mode: "session"; acceptLanguage: Promise<string | undefined> } | { mode: "public" };
29
+
30
+ const DateLocaleContext = React.createContext<DateLocaleSource | null>(null);
31
+
32
+ export function DateLocaleProvider(
33
+ props:
34
+ | { session: Promise<SessionSnapshot>; mode?: "session"; children: React.ReactNode }
35
+ | { mode: "public"; session?: never; children: React.ReactNode },
36
+ ) {
37
+ const { session, children } = props;
38
+ const value = React.useMemo<DateLocaleSource>(
39
+ () =>
40
+ props.mode === "public" || !session
41
+ ? { mode: "public" }
42
+ : { mode: "session", acceptLanguage: mapThenable(session, (snapshot) => snapshot.acceptLanguage) },
43
+ // Created once, like ViewportProvider's seed.
44
+ [],
45
+ );
46
+
47
+ return <DateLocaleContext.Provider value={value}>{children}</DateLocaleContext.Provider>;
48
+ }
49
+
50
+ /** The provider's raw context value (mapped thenable + mode), read WITHOUT suspending — for re-providing it to a tree rendered outside the provider (sonner toasts). */
51
+ export function useDateLocaleSource(): DateLocaleSource | null {
52
+ return React.useContext(DateLocaleContext);
53
+ }
54
+
55
+ export function DateLocaleSourceProvider({
56
+ value,
57
+ children,
58
+ }: {
59
+ value: DateLocaleSource | null;
60
+ children: React.ReactNode;
61
+ }) {
62
+ return <DateLocaleContext.Provider value={value}>{children}</DateLocaleContext.Provider>;
63
+ }
64
+
65
+ export type DateLocale = { locale: string; isPublic: boolean };
66
+
67
+ /** Suspends on the session (put the caller behind its own `<Suspense>`) when a session provider is above it. */
68
+ export function useDateLocale(): DateLocale {
69
+ const source = React.useContext(DateLocaleContext);
70
+ const appLocale = useI18nLocale();
71
+ const acceptLanguage = source?.mode === "session" ? React.use(source.acceptLanguage) : undefined;
72
+ const isPublic = source?.mode === "public";
73
+ const locale = source?.mode === "session" ? resolveDateLocale({ appLocale, acceptLanguage }) : appLocale;
74
+
75
+ return React.useMemo(() => ({ locale, isPublic }), [locale, isPublic]);
76
+ }
77
+
78
+ export type DateFormatter = DateLocale & {
79
+ format(date: Date | string | undefined, style: DateFormatStyle, options?: { timeZone?: string }): string;
80
+ relative(date: Date | string, options: { now: Date }): string;
81
+ twelveHourClock: boolean;
82
+ };
83
+
84
+ export function useDateFormatter(): DateFormatter {
85
+ const { locale, isPublic } = useDateLocale();
86
+
87
+ return React.useMemo<DateFormatter>(
88
+ () => ({
89
+ locale,
90
+ isPublic,
91
+ format: (date, style, options) => formatDateStyle({ date, style, locale, timeZone: options?.timeZone, isPublic }),
92
+ relative: (date, options) => formatRelativeDate({ date, locale, now: options.now }),
93
+ twelveHourClock: usesTwelveHourClock(locale),
94
+ }),
95
+ [locale, isPublic],
96
+ );
97
+ }
98
+
99
+ /** undefined on the server render and the first client render; the current time after mount (set in useEffect). */
100
+ export function useMountedNow(): Date | undefined {
101
+ const [now, setNow] = React.useState<Date | undefined>(undefined);
102
+ React.useEffect(() => {
103
+ setNow(new Date());
104
+ }, []);
105
+ return now;
106
+ }
@@ -0,0 +1,175 @@
1
+ import { act, render, screen } from "@testing-library/react";
2
+ import { Suspense, useEffect, useSyncExternalStore } from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { configureI18n } from "../../i18n/config";
5
+ import type { SessionSnapshot } from "../../server/ServerSession";
6
+ import {
7
+ type DateLocaleSource,
8
+ DateLocaleProvider,
9
+ DateLocaleSourceProvider,
10
+ useDateFormatter,
11
+ useDateLocaleSource,
12
+ useMountedNow,
13
+ } from "../DateLocaleContext";
14
+
15
+ const sample = new Date("2026-04-25T21:30:00Z");
16
+
17
+ let appLocale = "en";
18
+ const listeners = new Set<() => void>();
19
+ const setAppLocale = (next: string) => {
20
+ appLocale = next;
21
+ listeners.forEach((listener) => listener());
22
+ };
23
+ const useStubLocale = () =>
24
+ useSyncExternalStore(
25
+ (listener) => {
26
+ listeners.add(listener);
27
+ return () => listeners.delete(listener);
28
+ },
29
+ () => appLocale,
30
+ );
31
+
32
+ const snapshot = (acceptLanguage?: string): SessionSnapshot => ({
33
+ isLogged: true,
34
+ isAdministrator: false,
35
+ sidebarOpen: true,
36
+ isMobile: false,
37
+ acceptLanguage,
38
+ });
39
+
40
+ function Probe() {
41
+ const { format, locale, isPublic } = useDateFormatter();
42
+ return (
43
+ <span data-testid="probe" data-locale={locale} data-public={String(isPublic)}>
44
+ {format(sample, "date", { timeZone: "UTC" })}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ beforeEach(() => {
50
+ appLocale = "en";
51
+ configureI18n({
52
+ useRouter: () => ({
53
+ push: () => {},
54
+ replace: () => {},
55
+ back: () => {},
56
+ forward: () => {},
57
+ refresh: () => {},
58
+ prefetch: () => {},
59
+ }),
60
+ useTranslations: () => (key: string) => key,
61
+ useLocale: useStubLocale,
62
+ Link: ({ children }) => <>{children}</>,
63
+ usePathname: () => "/",
64
+ getLocale: async () => appLocale,
65
+ });
66
+ });
67
+
68
+ describe("DateLocaleProvider", () => {
69
+ it("resolves the browser's regional locale from the session", async () => {
70
+ const session = Promise.resolve(snapshot("en-GB"));
71
+ await act(async () => {
72
+ render(
73
+ <DateLocaleProvider session={session}>
74
+ <Suspense fallback={<span>loading</span>}>
75
+ <Probe />
76
+ </Suspense>
77
+ </DateLocaleProvider>,
78
+ );
79
+ });
80
+ expect(await screen.findByTestId("probe")).toHaveTextContent("25/04/2026");
81
+ expect(screen.getByTestId("probe")).toHaveAttribute("data-locale", "en-GB");
82
+ });
83
+
84
+ it("uses the app language and month-as-word in public mode", () => {
85
+ render(
86
+ <DateLocaleProvider mode="public">
87
+ <Probe />
88
+ </DateLocaleProvider>,
89
+ );
90
+ // Rule 3: the app language ("en"), never the browser's region; numeric "date" becomes dateMedium.
91
+ expect(screen.getByTestId("probe")).toHaveTextContent("Apr 25, 2026");
92
+ expect(screen.getByTestId("probe")).toHaveAttribute("data-public", "true");
93
+ expect(screen.getByTestId("probe")).toHaveAttribute("data-locale", "en");
94
+ });
95
+
96
+ it("falls back to the app language without a provider", () => {
97
+ render(<Probe />);
98
+ expect(screen.getByTestId("probe")).toHaveTextContent("04/25/2026");
99
+ expect(screen.getByTestId("probe")).toHaveAttribute("data-public", "false");
100
+ });
101
+
102
+ it("useDateLocale re-resolves when the app locale changes", async () => {
103
+ const session = Promise.resolve(snapshot("en-US,en;q=0.9"));
104
+ await act(async () => {
105
+ render(
106
+ <DateLocaleProvider session={session}>
107
+ <Suspense fallback={<span>loading</span>}>
108
+ <Probe />
109
+ </Suspense>
110
+ </DateLocaleProvider>,
111
+ );
112
+ });
113
+ expect(await screen.findByTestId("probe")).toHaveTextContent("04/25/2026");
114
+
115
+ act(() => setAppLocale("it"));
116
+
117
+ expect(screen.getByTestId("probe")).toHaveTextContent("25/04/2026");
118
+ expect(screen.getByTestId("probe")).toHaveAttribute("data-locale", "it");
119
+ });
120
+ });
121
+
122
+ describe("useDateLocaleSource + DateLocaleSourceProvider", () => {
123
+ it("re-provides a captured source to a tree mounted outside the provider", async () => {
124
+ let captured: DateLocaleSource | null = null;
125
+ function Capture() {
126
+ captured = useDateLocaleSource();
127
+ return null;
128
+ }
129
+ const session = Promise.resolve(snapshot("en-GB"));
130
+ await act(async () => {
131
+ render(
132
+ <DateLocaleProvider session={session}>
133
+ <Capture />
134
+ </DateLocaleProvider>,
135
+ );
136
+ });
137
+ expect(captured).not.toBeNull();
138
+
139
+ await act(async () => {
140
+ render(
141
+ <DateLocaleSourceProvider value={captured}>
142
+ <Suspense fallback={<span>loading</span>}>
143
+ <Probe />
144
+ </Suspense>
145
+ </DateLocaleSourceProvider>,
146
+ );
147
+ });
148
+ expect(await screen.findByTestId("probe")).toHaveTextContent("25/04/2026");
149
+ });
150
+ });
151
+
152
+ describe("useMountedNow", () => {
153
+ afterEach(() => {
154
+ vi.useRealTimers();
155
+ });
156
+
157
+ it("useMountedNow is undefined until mounted", () => {
158
+ vi.useFakeTimers();
159
+ vi.setSystemTime(sample);
160
+ const seen: Array<Date | undefined> = [];
161
+ function NowProbe() {
162
+ const now = useMountedNow();
163
+ useEffect(() => {
164
+ seen.push(now);
165
+ });
166
+ seen.push(now);
167
+ return null;
168
+ }
169
+ render(<NowProbe />);
170
+ expect(seen[0]).toBeUndefined();
171
+ const last = seen[seen.length - 1];
172
+ expect(last).toBeInstanceOf(Date);
173
+ expect(last?.getTime()).toBe(sample.getTime());
174
+ });
175
+ });
@@ -10,6 +10,7 @@ export * from "../features/assistant/contexts/AssistantContext";
10
10
  export * from "./ActionBarContext";
11
11
  export * from "./BreadcrumbRootContext";
12
12
  export * from "./CommonContext";
13
+ export * from "./DateLocaleContext";
13
14
  export * from "./DirectionContext";
14
15
  export * from "./HeaderChildrenContext";
15
16
  export * from "./HeaderLeftContentContext";
@@ -0,0 +1,12 @@
1
+ // Client-only barrel for the date-locale hooks, consumed via
2
+ // `@carlonicora/nextjs-jsonapi/date-locale`. Tsup adds a top-level "use client"
3
+ // directive to the bundled output via clientEntries.
4
+ //
5
+ // The same exports are on `@carlonicora/nextjs-jsonapi/contexts`. This entry
6
+ // exists for modules an app loads on the server outside any component tree:
7
+ // the app's `useDateFnsLocale`, handed to `configureI18n` from `config/env.ts`,
8
+ // which every page imports. Importing `contexts` there pulls the contexts bundle,
9
+ // whose shared chunk holds the `next/dynamic` BlockNote import, into that server
10
+ // graph, and Turbopack dev then fails every page with "Parent client reference
11
+ // not found for next/dynamic import". This entry reaches only the light chunks.
12
+ export * from "../contexts/DateLocaleContext";
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
5
5
  import { useEffect, useState } from "react";
6
6
  import { errorToast } from "../../../components/errors/errorToast";
7
7
  import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
8
- import { useI18nLocale } from "../../../i18n";
8
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
9
9
  import { getRoleId } from "../../../roles";
10
10
  import {
11
11
  AlertDialog,
@@ -47,7 +47,7 @@ function matchesConfirmation(status: AccountDeletionInterface, email: string, va
47
47
 
48
48
  function AccountDeletionAdminTriggerInternal({ user }: AccountDeletionAdminTriggerProps) {
49
49
  const t = useTranslations();
50
- const locale = useI18nLocale();
50
+ const dates = useDateFormatter();
51
51
  const [status, setStatus] = useState<AccountDeletionInterface | undefined>();
52
52
  const [open, setOpen] = useState<boolean>(false);
53
53
  const [confirmation, setConfirmation] = useState<string>("");
@@ -69,8 +69,7 @@ function AccountDeletionAdminTriggerInternal({ user }: AccountDeletionAdminTrigg
69
69
 
70
70
  if (!status) return null;
71
71
 
72
- const formatDate = (date: Date) =>
73
- date.toLocaleDateString(locale, { day: "numeric", month: "long", year: "numeric" });
72
+ const formatDate = (date: Date) => dates.format(date, "dateLong");
74
73
 
75
74
  if (status.scheduledAt) {
76
75
  return (
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { useLocale, useTranslations } from "next-intl";
4
- import { useEffect, useMemo, useState } from "react";
4
+ import { useEffect, useState } from "react";
5
5
  import { DetailField, RoundPageContainer } from "../../../components";
6
6
  import { SharedProvider } from "../../../contexts";
7
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
7
8
  import { Modules } from "../../../core";
8
9
  import { usePageUrlGenerator } from "../../../hooks";
9
10
  import { BreadcrumbItemData } from "../../../interfaces";
@@ -52,6 +53,7 @@ type Props = {
52
53
  export function AnalyticsJourneyContainer({ sessionId, pageUrl = ANALYTICS_ADMIN_PAGE_URL }: Props) {
53
54
  const t = useTranslations();
54
55
  const locale = useLocale();
56
+ const dates = useDateFormatter();
55
57
  const generateUrl = usePageUrlGenerator();
56
58
 
57
59
  const [session, setSession] = useState<AnalyticsSessionInterface | undefined>(undefined);
@@ -103,11 +105,6 @@ export function AnalyticsJourneyContainer({ sessionId, pageUrl = ANALYTICS_ADMIN
103
105
  };
104
106
  }, [sessionId]);
105
107
 
106
- const dateTime = useMemo(
107
- () => new Intl.DateTimeFormat(locale, { dateStyle: "short", timeStyle: "medium" }),
108
- [locale],
109
- );
110
-
111
108
  /** Time since the previous page view, in the largest whole unit that fits. */
112
109
  const formatGap = (milliseconds: number): string => {
113
110
  const seconds = Math.max(0, Math.round(milliseconds / 1000));
@@ -166,7 +163,10 @@ export function AnalyticsJourneyContainer({ sessionId, pageUrl = ANALYTICS_ADMIN
166
163
  )}
167
164
  </div>
168
165
  <div className="grid gap-3 sm:grid-cols-3">
169
- <DetailField label={t("analytics.admin.columns.started")} value={dateTime.format(session.startedAt)} />
166
+ <DetailField
167
+ label={t("analytics.admin.columns.started")}
168
+ value={dates.format(session.startedAt, "dateTimeSeconds")}
169
+ />
170
170
  <DetailField label={t("analytics.admin.columns.source")} value={source} />
171
171
  <DetailField label={t("analytics.admin.columns.landing")} value={session.landingRoute} />
172
172
  <DetailField label={t("analytics.admin.columns.device")} value={session.deviceType} />
@@ -205,7 +205,7 @@ export function AnalyticsJourneyContainer({ sessionId, pageUrl = ANALYTICS_ADMIN
205
205
  return (
206
206
  <TableRow key={pageView.id} data-testid={`analytics-pageview-row-${pageView.id}`}>
207
207
  <TableCell className="text-xs tabular-nums">
208
- {dateTime.format(pageView.createdAt)}
208
+ {dates.format(pageView.createdAt, "dateTimeSeconds")}
209
209
  </TableCell>
210
210
  <TableCell className="text-end text-xs tabular-nums">
211
211
  {previous ? formatGap(pageView.createdAt.getTime() - previous.createdAt.getTime()) : "—"}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useLocale, useTranslations } from "next-intl";
4
- import { useMemo } from "react";
3
+ import { useTranslations } from "next-intl";
4
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
5
5
  import { usePageUrlGenerator } from "../../../hooks";
6
6
  import { Badge, Button, Link, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../shadcnui";
7
7
  import { useUsageFormatters } from "../../tokenusage/lib/formatters";
@@ -28,12 +28,10 @@ const VISITOR_SUFFIX_LENGTH = 6;
28
28
  */
29
29
  export function AnalyticsSessionsTable({ rows, sessionPageUrl, onLoadMore, hasMore }: Props) {
30
30
  const t = useTranslations();
31
- const locale = useLocale();
31
+ const dates = useDateFormatter();
32
32
  const generateUrl = usePageUrlGenerator();
33
33
  const { decimal } = useUsageFormatters();
34
34
 
35
- const dateTime = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "short", timeStyle: "short" }), [locale]);
36
-
37
35
  if (rows.length === 0) {
38
36
  return <p className="text-muted-foreground text-sm">{t("analytics.admin.no_data")}</p>;
39
37
  }
@@ -66,7 +64,7 @@ export function AnalyticsSessionsTable({ rows, sessionPageUrl, onLoadMore, hasMo
66
64
  href={generateUrl({ page: sessionPageUrl, id: row.id })}
67
65
  data-testid={`analytics-session-link-${row.id}`}
68
66
  >
69
- {dateTime.format(row.startedAt)}
67
+ {dates.format(row.startedAt, "dateTime")}
70
68
  </Link>
71
69
  </TableCell>
72
70
  <TableCell className="text-xs">
@@ -4,7 +4,7 @@ import { Edit, Key, Trash2 } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { useState } from "react";
6
6
  import { errorToast } from "../../../../components/errors/errorToast";
7
- import { useI18nLocale } from "../../../../i18n";
7
+ import { useDateFormatter } from "../../../../contexts/DateLocaleContext";
8
8
  import {
9
9
  AlertDialog,
10
10
  AlertDialogAction,
@@ -34,7 +34,7 @@ interface PasskeyListProps {
34
34
 
35
35
  export function PasskeyList({ passkeys, onRefresh }: PasskeyListProps) {
36
36
  const t = useTranslations();
37
- const locale = useI18nLocale();
37
+ const dates = useDateFormatter();
38
38
  const [renameDialogOpen, setRenameDialogOpen] = useState(false);
39
39
  const [selectedPasskey, setSelectedPasskey] = useState<PasskeyInterface | null>(null);
40
40
  const [newName, setNewName] = useState("");
@@ -108,7 +108,7 @@ export function PasskeyList({ passkeys, onRefresh }: PasskeyListProps) {
108
108
  <p className="text-sm text-muted-foreground">
109
109
  {passkey.backedUp && "☁️ "}
110
110
  {passkey.lastUsedAt
111
- ? `${t("auth.two_factor.last_used")}: ${new Date(passkey.lastUsedAt).toLocaleDateString(locale)}`
111
+ ? `${t("auth.two_factor.last_used")}: ${dates.format(passkey.lastUsedAt, "dateMedium")}`
112
112
  : t("auth.two_factor.never_used")}
113
113
  </p>
114
114
  </div>
@@ -4,7 +4,7 @@ import { Smartphone, Trash2 } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { useState } from "react";
6
6
  import { errorToast } from "../../../../components/errors/errorToast";
7
- import { useI18nLocale } from "../../../../i18n";
7
+ import { useDateFormatter } from "../../../../contexts/DateLocaleContext";
8
8
  import {
9
9
  AlertDialog,
10
10
  AlertDialogAction,
@@ -30,7 +30,7 @@ interface TotpAuthenticatorListProps {
30
30
 
31
31
  export function TotpAuthenticatorList({ authenticators, onDelete }: TotpAuthenticatorListProps) {
32
32
  const t = useTranslations();
33
- const locale = useI18nLocale();
33
+ const dates = useDateFormatter();
34
34
  const [deletingId, setDeletingId] = useState<string | null>(null);
35
35
 
36
36
  const handleDelete = async (id: string) => {
@@ -64,7 +64,7 @@ export function TotpAuthenticatorList({ authenticators, onDelete }: TotpAuthenti
64
64
  <p className="font-medium">{auth.name}</p>
65
65
  {auth.lastUsedAt && (
66
66
  <p className="text-xs text-muted-foreground">
67
- {t("auth.two_factor.last_used")}: {auth.lastUsedAt.toLocaleDateString(locale)}
67
+ {t("auth.two_factor.last_used")}: {dates.format(auth.lastUsedAt, "dateMedium")}
68
68
  </p>
69
69
  )}
70
70
  </div>
@@ -41,17 +41,25 @@ describe("formatCurrency", () => {
41
41
  describe("formatDate", () => {
42
42
  const date = new Date(2025, 0, 15, 12);
43
43
 
44
- it("uses en-GB day-month order without options", () => {
45
- expect(formatDate(date)).toBe("15 Jan 2025");
44
+ it("uses the app language without options (no forced en-GB)", () => {
45
+ expect(formatDate(date)).toBe("Jan 15, 2025");
46
46
  expect(formatDate(undefined)).toBe("N/A");
47
47
  expect(formatDate(new Date("not a date"))).toBe("Invalid Date");
48
48
  });
49
49
 
50
- it("maps the English app locale to en-GB, never US order", () => {
50
+ it("formats with the app language when no dateLocale is given", () => {
51
51
  const feb6 = new Date(2026, 1, 6, 12);
52
- expect(formatDate(feb6, { locale: "en", t })).toBe("6 Feb 2026");
53
- expect(formatDate(feb6, { locale: "en-US", t })).toBe("6 Feb 2026");
54
- expect(formatDate("2026-02-06T12:00:00Z", { locale: "en", t })).toBe("6 Feb 2026");
52
+ expect(formatDate(feb6, { locale: "en", t })).toBe("Feb 6, 2026");
53
+ expect(formatDate(feb6, { locale: "en-US", t })).toBe("Feb 6, 2026");
54
+ expect(formatDate("2026-02-06T12:00:00Z", { locale: "en", t })).toBe("Feb 6, 2026");
55
+ });
56
+
57
+ it("uses the resolved dateLocale over the app language", () => {
58
+ const apr25 = new Date("2026-04-25T12:00:00Z");
59
+ expect(formatDate(apr25, { locale: "en", dateLocale: "en-GB" })).toBe("25 Apr 2026");
60
+ expect(formatDate(apr25, { locale: "en" })).toBe("Apr 25, 2026");
61
+ expect(formatDate(apr25, { locale: "it" })).toBe("25 apr 2026");
62
+ expect(formatDate(undefined, { locale: "en", dateLocale: "en-GB" })).toBe("N/A");
55
63
  });
56
64
 
57
65
  it("formats Italian with the Italian locale", () => {
@@ -4,19 +4,19 @@ import { BillingFormatOptions } from "./format-options";
4
4
  /**
5
5
  * Format a date to a localized date string
6
6
  * @param date - Date object or ISO string
7
- * @param options - `locale` is the app locale, mapped through `dateFormatLocale` (English and
8
- * no locale give "en-GB"); `t` translates the placeholder shown for a missing or invalid date
7
+ * @param options - `dateLocale` is the resolved regional locale; without it `locale` (the app
8
+ * locale) is mapped through `dateFormatLocale` (no locale gives "en"); `t` translates the placeholder shown for a missing or invalid date
9
9
  * (default English)
10
- * @returns Formatted date string (e.g., "15 Jan 2025", "15 gen 2025")
10
+ * @returns Formatted date string (e.g., "Jan 15, 2025", "15 Jan 2025", "15 gen 2025")
11
11
  */
12
12
  export function formatDate(date: Date | string | undefined, options: BillingFormatOptions = {}): string {
13
- const { locale, t } = options;
13
+ const { locale, dateLocale, t } = options;
14
14
  if (!date) return t ? t("billing.ui.not_available") : "N/A";
15
15
 
16
16
  const dateObj = typeof date === "string" ? new Date(date) : date;
17
17
 
18
18
  try {
19
- return new Intl.DateTimeFormat(dateFormatLocale(locale), {
19
+ return new Intl.DateTimeFormat(dateLocale ?? dateFormatLocale(locale), {
20
20
  month: "short",
21
21
  day: "numeric",
22
22
  year: "numeric",
@@ -6,14 +6,16 @@ export type BillingTranslator = (key: string, values?: Record<string, any>) => s
6
6
  * formatters are plain functions and cannot call hooks, so a component reads
7
7
  * `useI18nLocale()` and `useTranslations()` and passes both here.
8
8
  *
9
- * Both are optional so existing callers keep their output: without a `locale`
10
- * the number formatters use "en-US" and `formatDate` uses "en-GB" (via
11
- * `dateFormatLocale`), without `t` they return the English literals they always
12
- * returned.
9
+ * All are optional so existing callers keep their output: without a `locale`
10
+ * the number formatters use "en-US"; `formatDate` uses `dateLocale`, else
11
+ * `dateFormatLocale(locale)` (the app language, "en" without one); without `t`
12
+ * they return the English literals they always returned.
13
13
  */
14
14
  export type BillingFormatOptions = {
15
15
  /** BCP 47 locale for `Intl` (the app's active locale, from `useI18nLocale()`). */
16
16
  locale?: string;
17
+ /** BCP 47 locale for dates only (the resolved regional locale, from `useDateFormatter().locale`). Numbers keep `locale`. */
18
+ dateLocale?: string;
17
19
  /** Translator for the words the formatters emit. */
18
20
  t?: BillingTranslator;
19
21
  };
@@ -1,11 +1,17 @@
1
1
  import { useTranslations } from "next-intl";
2
2
  import { useMemo } from "react";
3
+ import { useDateFormatter } from "../../../../contexts/DateLocaleContext";
3
4
  import { useI18nLocale } from "../../../../i18n";
4
5
  import { BillingFormatOptions } from "./format-options";
5
6
 
6
- /** The active locale and translator, ready to pass to the billing formatters. */
7
+ /**
8
+ * The active locale, the resolved date locale and the translator, ready to pass to the billing
9
+ * formatters. Suspends on the session under a session `DateLocaleProvider` (via
10
+ * `useDateFormatter()`), so its callers sit behind a page `loading.tsx` or their own `<Suspense>`.
11
+ */
7
12
  export function useBillingFormatOptions(): Required<BillingFormatOptions> {
8
13
  const t = useTranslations();
9
14
  const locale = useI18nLocale();
10
- return useMemo(() => ({ locale, t }), [locale, t]);
15
+ const { locale: dateLocale } = useDateFormatter();
16
+ return useMemo(() => ({ locale, dateLocale, t }), [locale, dateLocale, t]);
11
17
  }
@@ -3,7 +3,7 @@
3
3
  import { useTranslations } from "next-intl";
4
4
  import { Activity } from "lucide-react";
5
5
  import { Card, CardContent, CardHeader } from "../../../../../shadcnui";
6
- import { dateFormatLocale } from "../../../../../utils";
6
+ import { formatDateStyle } from "../../../../../utils";
7
7
  import { type BillingFormatOptions, formatFixed, useBillingFormatOptions } from "../../../components/utils";
8
8
  import { MeterInterface, MeterSummaryInterface } from "../../data/stripe-usage.interface";
9
9
 
@@ -23,19 +23,11 @@ function getProgressColor(percentage: number | null): string {
23
23
  }
24
24
 
25
25
  /**
26
- * Format a Unix timestamp to readable date
26
+ * Format a period boundary in the resolved date locale
27
27
  */
28
- function formatDate(date: Date | undefined, { locale, t }: Required<BillingFormatOptions>): string {
28
+ function formatDate(date: Date | undefined, { dateLocale, t }: Required<BillingFormatOptions>): string {
29
29
  if (!date) return t("billing.ui.not_available");
30
-
31
- try {
32
- return new Intl.DateTimeFormat(dateFormatLocale(locale), {
33
- month: "short",
34
- day: "numeric",
35
- }).format(new Date(date));
36
- } catch (_error) {
37
- return t("billing.ui.invalid_date");
38
- }
30
+ return formatDateStyle({ date, style: "dateMedium", locale: dateLocale }) || t("billing.ui.invalid_date");
39
31
  }
40
32
 
41
33
  export function UsageSummaryCard({ meter, summary }: UsageSummaryCardProps) {