@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.
- package/dist/{BlockNoteEditor-PPRML5CO.js → BlockNoteEditor-TNX77GCD.js} +28 -25
- package/dist/BlockNoteEditor-TNX77GCD.js.map +1 -0
- package/dist/{BlockNoteEditor-UO4ZRCUM.mjs → BlockNoteEditor-Z5LDF47Z.mjs} +13 -10
- package/dist/{BlockNoteEditor-UO4ZRCUM.mjs.map → BlockNoteEditor-Z5LDF47Z.mjs.map} +1 -1
- package/dist/{BlockNoteViewer-K2HBF3IP.js → BlockNoteViewer-6KQJODNF.js} +9 -7
- package/dist/BlockNoteViewer-6KQJODNF.js.map +1 -0
- package/dist/{BlockNoteViewer-2TJZ6JET.mjs → BlockNoteViewer-TQ452ZYW.mjs} +8 -6
- package/dist/{BlockNoteViewer-2TJZ6JET.mjs.map → BlockNoteViewer-TQ452ZYW.mjs.map} +1 -1
- package/dist/JsonApiRequest-OKNQZLS6.js +26 -0
- package/dist/{JsonApiRequest-KC6DOHKT.js.map → JsonApiRequest-OKNQZLS6.js.map} +1 -1
- package/dist/{JsonApiRequest-ADLBJ5RX.mjs → JsonApiRequest-WNPDS356.mjs} +2 -2
- package/dist/billing/index.js +504 -517
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +20 -33
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-VNUT2D27.mjs → chunk-2PWYG2WJ.mjs} +2 -2
- package/dist/{chunk-W4ML6D2X.mjs → chunk-3U7YJ73T.mjs} +7 -7
- package/dist/{chunk-BQ2WAG4K.js → chunk-3VNDFKT7.js} +19 -19
- package/dist/{chunk-BQ2WAG4K.js.map → chunk-3VNDFKT7.js.map} +1 -1
- package/dist/{chunk-BE6UMM5X.mjs → chunk-47HNJQ7U.mjs} +3 -3
- package/dist/chunk-5T2AQMQ2.mjs +90 -0
- package/dist/chunk-5T2AQMQ2.mjs.map +1 -0
- package/dist/{chunk-A5M3MORY.js → chunk-5WM72MLE.js} +3 -3
- package/dist/{chunk-A5M3MORY.js.map → chunk-5WM72MLE.js.map} +1 -1
- package/dist/{chunk-S7SPAC6X.mjs → chunk-73NKUBMP.mjs} +3 -3
- package/dist/chunk-743M4TIB.mjs +76 -0
- package/dist/chunk-743M4TIB.mjs.map +1 -0
- package/dist/{chunk-HN7L6RRW.js → chunk-CSBQAPHD.js} +903 -955
- package/dist/chunk-CSBQAPHD.js.map +1 -0
- package/dist/{chunk-YTDGYI62.mjs → chunk-DWPHHUMV.mjs} +3 -3
- package/dist/{chunk-53SD4YRF.js → chunk-FBHUUPRV.js} +5 -5
- package/dist/{chunk-53SD4YRF.js.map → chunk-FBHUUPRV.js.map} +1 -1
- package/dist/{chunk-T6G2F2BK.mjs → chunk-HJT7TXGG.mjs} +16 -68
- package/dist/chunk-HJT7TXGG.mjs.map +1 -0
- package/dist/chunk-HROKKUBQ.js +156 -0
- package/dist/chunk-HROKKUBQ.js.map +1 -0
- package/dist/{chunk-TMV7TL7O.mjs → chunk-JELEQV5T.mjs} +8 -6
- package/dist/{chunk-TMV7TL7O.mjs.map → chunk-JELEQV5T.mjs.map} +1 -1
- package/dist/{chunk-TNTCLVUR.js → chunk-JGL4P3DS.js} +17 -15
- package/dist/chunk-JGL4P3DS.js.map +1 -0
- package/dist/{chunk-7OIWOPM5.mjs → chunk-K7KLUYS7.mjs} +4 -3
- package/dist/chunk-K7KLUYS7.mjs.map +1 -0
- package/dist/{chunk-HP6DT6K3.mjs → chunk-KVYYPKRO.mjs} +311 -241
- package/dist/chunk-KVYYPKRO.mjs.map +1 -0
- package/dist/{chunk-RCOKQ3R4.js → chunk-LNO2JLU3.js} +2 -2
- package/dist/{chunk-RCOKQ3R4.js.map → chunk-LNO2JLU3.js.map} +1 -1
- package/dist/{chunk-R5722NLL.js → chunk-M6SNTMK6.js} +1388 -1318
- package/dist/chunk-M6SNTMK6.js.map +1 -0
- package/dist/{chunk-GMMDXINC.js → chunk-N3NQS7X6.js} +12 -12
- package/dist/{chunk-GMMDXINC.js.map → chunk-N3NQS7X6.js.map} +1 -1
- package/dist/chunk-NP3AFULU.js +76 -0
- package/dist/chunk-NP3AFULU.js.map +1 -0
- package/dist/{chunk-SO3YUCL6.js → chunk-OOM54Z5Z.js} +6 -5
- package/dist/chunk-OOM54Z5Z.js.map +1 -0
- package/dist/{chunk-YSE2Z4RB.js → chunk-PQQFOONG.js} +12 -12
- package/dist/{chunk-YSE2Z4RB.js.map → chunk-PQQFOONG.js.map} +1 -1
- package/dist/chunk-RW6XZGZG.mjs +156 -0
- package/dist/chunk-RW6XZGZG.mjs.map +1 -0
- package/dist/chunk-SYMI4IDW.js +90 -0
- package/dist/chunk-SYMI4IDW.js.map +1 -0
- package/dist/{chunk-N2BFT7KI.mjs → chunk-W7U6O3OE.mjs} +2 -71
- package/dist/chunk-W7U6O3OE.mjs.map +1 -0
- package/dist/{chunk-YMCL3JXZ.mjs → chunk-YPBIPE2B.mjs} +2 -2
- package/dist/{chunk-P7L6OFX5.js → chunk-Z6T2575A.js} +12 -81
- package/dist/chunk-Z6T2575A.js.map +1 -0
- package/dist/client/index.js +13 -10
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +17 -14
- package/dist/components/forms/DatePickerPopover.d.ts.map +1 -1
- package/dist/components/forms/DateRangeSelector.d.ts.map +1 -1
- package/dist/components/forms/EditorSheet.d.ts +6 -1
- package/dist/components/forms/EditorSheet.d.ts.map +1 -1
- package/dist/components/forms/FormDate.d.ts.map +1 -1
- package/dist/components/forms/FormDateTime.d.ts.map +1 -1
- package/dist/components/forms/time-cycle.d.ts +14 -0
- package/dist/components/forms/time-cycle.d.ts.map +1 -0
- package/dist/components/index.js +11 -8
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +10 -7
- package/dist/components/tables/cells/cell.date.d.ts.map +1 -1
- package/dist/components/tables/cells/cell.dateTime.d.ts.map +1 -1
- package/dist/contexts/DateLocaleContext.d.ts +56 -0
- package/dist/contexts/DateLocaleContext.d.ts.map +1 -0
- package/dist/contexts/index.d.ts +1 -0
- package/dist/contexts/index.d.ts.map +1 -1
- package/dist/contexts/index.js +24 -8
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +23 -7
- package/dist/core/index.js +17 -6
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +19 -8
- package/dist/date-locale/index.d.ts +2 -0
- package/dist/date-locale/index.d.ts.map +1 -0
- package/dist/date-locale/index.js +21 -0
- package/dist/date-locale/index.js.map +1 -0
- package/dist/date-locale/index.mjs +21 -0
- package/dist/email-automations/index.js +131 -123
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +23 -15
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/account-deletion/components/AccountDeletionAdminTrigger.d.ts.map +1 -1
- package/dist/features/analytics/components/AnalyticsJourneyContainer.d.ts.map +1 -1
- package/dist/features/analytics/components/AnalyticsSessionsTable.d.ts.map +1 -1
- package/dist/features/analytics/index.js +154 -147
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +32 -25
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +11 -9
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +9 -7
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/billing/components/utils/date.d.ts +3 -3
- package/dist/features/billing/components/utils/format-options.d.ts +6 -4
- package/dist/features/billing/components/utils/format-options.d.ts.map +1 -1
- package/dist/features/billing/components/utils/useBillingFormatOptions.d.ts +5 -1
- package/dist/features/billing/components/utils/useBillingFormatOptions.d.ts.map +1 -1
- package/dist/features/billing/stripe-usage/components/details/UsageSummaryCard.d.ts.map +1 -1
- package/dist/features/billing/stripe-usage/components/lists/UsageHistoryTable.d.ts.map +1 -1
- package/dist/features/blog/components/admin/BlogTranslationStatus.d.ts.map +1 -1
- package/dist/features/blog/components/public/BlogPostMeta.d.ts.map +1 -1
- package/dist/features/blog/hooks/useBlogPostTableStructure.d.ts.map +1 -1
- package/dist/features/blog/index.js +168 -162
- package/dist/features/blog/index.js.map +1 -1
- package/dist/features/blog/index.mjs +25 -19
- package/dist/features/blog/index.mjs.map +1 -1
- package/dist/features/blog/server.js +29 -27
- package/dist/features/blog/server.js.map +1 -1
- package/dist/features/blog/server.mjs +8 -6
- package/dist/features/blog/server.mjs.map +1 -1
- package/dist/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.d.ts.map +1 -1
- package/dist/features/handbook/components/parts/HandbookIndexStatus.d.ts.map +1 -1
- package/dist/features/help/components/HelpArticleBody.d.ts.map +1 -1
- package/dist/features/help/index.js +48 -43
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +15 -10
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/notification/components/notifications/Notification.d.ts +2 -1
- package/dist/features/notification/components/notifications/Notification.d.ts.map +1 -1
- package/dist/features/notification/contexts/NotificationContext.d.ts.map +1 -1
- package/dist/features/notification/hooks/useNotificationTableStructure.d.ts.map +1 -1
- package/dist/features/oauth/components/OAuthClientCard.d.ts.map +1 -1
- package/dist/features/oauth/components/OAuthClientDetail.d.ts.map +1 -1
- package/dist/features/tokenusage/index.js +133 -130
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +10 -7
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/features/tokenusage/lib/formatters.d.ts +6 -3
- package/dist/features/tokenusage/lib/formatters.d.ts.map +1 -1
- package/dist/index.js +19 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -11
- package/dist/local-transport-AT4TE264.js +15 -0
- package/dist/{local-transport-MJPOKPTU.js.map → local-transport-AT4TE264.js.map} +1 -1
- package/dist/{local-transport-RVD4FZGA.mjs → local-transport-KWEUZ4GX.mjs} +5 -5
- package/dist/offline/index.js +56 -54
- package/dist/offline/index.js.map +1 -1
- package/dist/offline/index.mjs +23 -21
- package/dist/offline/index.mjs.map +1 -1
- package/dist/{request-4AWGPIGH.mjs → request-6GAIWWTG.mjs} +2 -2
- package/dist/{request-H5FZBZA2.js → request-BWO4Y434.js} +3 -3
- package/dist/{request-H5FZBZA2.js.map → request-BWO4Y434.js.map} +1 -1
- package/dist/server/ServerSession.d.ts +2 -0
- package/dist/server/ServerSession.d.ts.map +1 -1
- package/dist/server/index.js +10 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +9 -7
- package/dist/utils/date-formatter.d.ts +45 -4
- package/dist/utils/date-formatter.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/write-through-KNRI5L4V.mjs +15 -0
- package/dist/write-through-KNRI5L4V.mjs.map +1 -0
- package/dist/write-through-MYHQ6GJU.js +15 -0
- package/dist/{write-through-HE2QSOVI.js.map → write-through-MYHQ6GJU.js.map} +1 -1
- package/messages/en.json +1 -0
- package/messages/it.json +1 -0
- package/package.json +6 -1
- package/src/components/forms/DatePickerPopover.tsx +5 -3
- package/src/components/forms/DateRangeSelector.tsx +9 -7
- package/src/components/forms/EditorSheet.tsx +8 -0
- package/src/components/forms/FormDate.tsx +5 -3
- package/src/components/forms/FormDateTime.tsx +75 -39
- package/src/components/forms/__tests__/EditorSheet.spec.tsx +30 -0
- package/src/components/forms/__tests__/time-cycle.spec.ts +37 -0
- package/src/components/forms/time-cycle.ts +27 -0
- package/src/components/tables/cells/cell.date.tsx +3 -5
- package/src/components/tables/cells/cell.dateTime.tsx +3 -7
- package/src/contexts/DateLocaleContext.tsx +106 -0
- package/src/contexts/__tests__/DateLocaleContext.spec.tsx +175 -0
- package/src/contexts/index.ts +1 -0
- package/src/date-locale/index.ts +12 -0
- package/src/features/account-deletion/components/AccountDeletionAdminTrigger.tsx +3 -4
- package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +8 -8
- package/src/features/analytics/components/AnalyticsSessionsTable.tsx +4 -6
- package/src/features/auth/components/two-factor/PasskeyList.tsx +3 -3
- package/src/features/auth/components/two-factor/TotpAuthenticatorList.tsx +3 -3
- package/src/features/billing/components/utils/__tests__/billing-formatters.spec.ts +14 -6
- package/src/features/billing/components/utils/date.ts +5 -5
- package/src/features/billing/components/utils/format-options.ts +6 -4
- package/src/features/billing/components/utils/useBillingFormatOptions.ts +8 -2
- package/src/features/billing/stripe-usage/components/details/UsageSummaryCard.tsx +4 -12
- package/src/features/billing/stripe-usage/components/lists/UsageHistoryTable.tsx +4 -17
- package/src/features/blog/components/admin/BlogTranslationStatus.tsx +3 -2
- package/src/features/blog/components/public/BlogPostMeta.tsx +5 -4
- package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +1 -2
- package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +2 -3
- package/src/features/blog/hooks/useBlogPostTableStructure.tsx +4 -8
- package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +11 -5
- package/src/features/email-blast/components/containers/EmailBlastContainer.tsx +3 -3
- package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +4 -4
- package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +4 -3
- package/src/features/help/components/HelpArticleBody.tsx +4 -3
- package/src/features/notification/components/notifications/Notification.tsx +38 -7
- package/src/features/notification/contexts/NotificationContext.tsx +6 -3
- package/src/features/notification/hooks/useNotificationTableStructure.tsx +6 -5
- package/src/features/oauth/components/OAuthClientCard.tsx +8 -5
- package/src/features/oauth/components/OAuthClientDetail.tsx +3 -6
- package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +19 -0
- package/src/features/tokenusage/lib/formatters.ts +11 -6
- package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +5 -5
- package/src/server/ServerSession.ts +3 -0
- package/src/server/__tests__/ServerSession.read.spec.ts +18 -1
- package/src/utils/__tests__/date-formatter.spec.ts +134 -10
- package/src/utils/date-formatter.ts +200 -44
- package/src/utils/index.ts +11 -1
- package/dist/BlockNoteEditor-PPRML5CO.js.map +0 -1
- package/dist/BlockNoteViewer-K2HBF3IP.js.map +0 -1
- package/dist/JsonApiRequest-KC6DOHKT.js +0 -26
- package/dist/chunk-7OIWOPM5.mjs.map +0 -1
- package/dist/chunk-HN7L6RRW.js.map +0 -1
- package/dist/chunk-HP6DT6K3.mjs.map +0 -1
- package/dist/chunk-N2BFT7KI.mjs.map +0 -1
- package/dist/chunk-P7L6OFX5.js.map +0 -1
- package/dist/chunk-R5722NLL.js.map +0 -1
- package/dist/chunk-SO3YUCL6.js.map +0 -1
- package/dist/chunk-T6G2F2BK.mjs.map +0 -1
- package/dist/chunk-TNTCLVUR.js.map +0 -1
- package/dist/local-transport-MJPOKPTU.js +0 -15
- package/dist/write-through-7I3LA2WN.mjs +0 -15
- package/dist/write-through-HE2QSOVI.js +0 -15
- /package/dist/{JsonApiRequest-ADLBJ5RX.mjs.map → JsonApiRequest-WNPDS356.mjs.map} +0 -0
- /package/dist/{chunk-VNUT2D27.mjs.map → chunk-2PWYG2WJ.mjs.map} +0 -0
- /package/dist/{chunk-W4ML6D2X.mjs.map → chunk-3U7YJ73T.mjs.map} +0 -0
- /package/dist/{chunk-BE6UMM5X.mjs.map → chunk-47HNJQ7U.mjs.map} +0 -0
- /package/dist/{chunk-S7SPAC6X.mjs.map → chunk-73NKUBMP.mjs.map} +0 -0
- /package/dist/{chunk-YTDGYI62.mjs.map → chunk-DWPHHUMV.mjs.map} +0 -0
- /package/dist/{chunk-YMCL3JXZ.mjs.map → chunk-YPBIPE2B.mjs.map} +0 -0
- /package/dist/{local-transport-RVD4FZGA.mjs.map → date-locale/index.mjs.map} +0 -0
- /package/dist/{request-4AWGPIGH.mjs.map → local-transport-KWEUZ4GX.mjs.map} +0 -0
- /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 {
|
|
2
|
+
import { useDateFormatter } from "../../../contexts/DateLocaleContext";
|
|
3
3
|
|
|
4
4
|
function DateTimeCell({ date }: { date: Date }) {
|
|
5
|
-
const
|
|
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
|
+
});
|
package/src/contexts/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
{
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
{
|
|
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 {
|
|
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
|
|
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")}: ${
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
45
|
-
expect(formatDate(date)).toBe("15
|
|
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("
|
|
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
|
|
53
|
-
expect(formatDate(feb6, { locale: "en-US", t })).toBe("6
|
|
54
|
-
expect(formatDate("2026-02-06T12:00:00Z", { locale: "en", t })).toBe("6
|
|
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 - `
|
|
8
|
-
* no locale
|
|
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
|
-
*
|
|
10
|
-
* the number formatters use "en-US"
|
|
11
|
-
* `dateFormatLocale`
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
|
26
|
+
* Format a period boundary in the resolved date locale
|
|
27
27
|
*/
|
|
28
|
-
function formatDate(date: Date | undefined, {
|
|
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) {
|