@carlonicora/nextjs-jsonapi 3.24.2 → 3.26.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 (220) hide show
  1. package/dist/{AssistantMessageInterface-1Kbz8RVs.d.ts → AssistantMessageInterface-CubxuBIJ.d.ts} +1 -1
  2. package/dist/{AssistantMessageInterface-CoxJyuFM.d.mts → AssistantMessageInterface-sHjI2zPN.d.mts} +1 -1
  3. package/dist/{BlockNoteEditor-35D4R7HJ.js → BlockNoteEditor-4EIKMNY3.js} +31 -21
  4. package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
  5. package/dist/{BlockNoteEditor-65HR6ULV.mjs → BlockNoteEditor-AXYVDDXM.mjs} +16 -6
  6. package/dist/{BlockNoteEditor-65HR6ULV.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
  7. package/dist/{BlockNoteViewer-QDXDVYEM.js → BlockNoteViewer-7BFNTN4E.js} +4 -4
  8. package/dist/{BlockNoteViewer-QDXDVYEM.js.map → BlockNoteViewer-7BFNTN4E.js.map} +1 -1
  9. package/dist/{BlockNoteViewer-KDKGUIG5.mjs → BlockNoteViewer-KRTAFTUX.mjs} +3 -3
  10. package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-Ck1wZJ81.d.ts} +1 -1
  11. package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-DgYH--Ik.d.mts} +1 -1
  12. package/dist/JsonApiRequest-TE5WD3WS.js +26 -0
  13. package/dist/{JsonApiRequest-G625QAH5.js.map → JsonApiRequest-TE5WD3WS.js.map} +1 -1
  14. package/dist/{JsonApiRequest-RRMNV7YE.mjs → JsonApiRequest-UHOWNI3O.mjs} +2 -2
  15. package/dist/analytics.modules-CfczU12P.d.ts +25 -0
  16. package/dist/analytics.modules-pzGwMASI.d.mts +25 -0
  17. package/dist/{auth.interface-BC1jp_ni.d.ts → auth.interface-BzI4onI_.d.ts} +1 -1
  18. package/dist/{auth.interface-dR2-6gPM.d.mts → auth.interface-Df4vXbNz.d.mts} +1 -1
  19. package/dist/billing/index.js +402 -402
  20. package/dist/billing/index.mjs +4 -4
  21. package/dist/{chunk-YXRVPEEJ.js → chunk-26MQIJNB.js} +1 -1
  22. package/dist/{chunk-YXRVPEEJ.js.map → chunk-26MQIJNB.js.map} +1 -1
  23. package/dist/{chunk-T76WLDE5.mjs → chunk-37DMFVLN.mjs} +1 -1
  24. package/dist/{chunk-3AR4KA5D.js → chunk-4Z45NYPR.js} +13 -13
  25. package/dist/{chunk-3AR4KA5D.js.map → chunk-4Z45NYPR.js.map} +1 -1
  26. package/dist/chunk-7B4E42R5.mjs +280 -0
  27. package/dist/chunk-7B4E42R5.mjs.map +1 -0
  28. package/dist/{chunk-2XCUAHQT.js → chunk-ABVTYQIJ.js} +1563 -1336
  29. package/dist/chunk-ABVTYQIJ.js.map +1 -0
  30. package/dist/{chunk-U7QVISQS.js → chunk-CAKOFJZD.js} +450 -40
  31. package/dist/chunk-CAKOFJZD.js.map +1 -0
  32. package/dist/{chunk-IBVKPLHT.mjs → chunk-E3I4MT53.mjs} +422 -12
  33. package/dist/chunk-E3I4MT53.mjs.map +1 -0
  34. package/dist/chunk-GHFMKH3M.js +280 -0
  35. package/dist/chunk-GHFMKH3M.js.map +1 -0
  36. package/dist/chunk-GKDHYB2R.js +110 -0
  37. package/dist/chunk-GKDHYB2R.js.map +1 -0
  38. package/dist/{chunk-ZJI5ZZW5.mjs → chunk-Q6J2ICKO.mjs} +3 -3
  39. package/dist/{chunk-4O7R2Z2O.mjs → chunk-QWZAGQWJ.mjs} +2175 -1948
  40. package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
  41. package/dist/chunk-U65JPKO3.mjs +110 -0
  42. package/dist/chunk-U65JPKO3.mjs.map +1 -0
  43. package/dist/client/index.d.mts +6 -5
  44. package/dist/client/index.d.ts +6 -5
  45. package/dist/client/index.js +5 -5
  46. package/dist/client/index.js.map +1 -1
  47. package/dist/client/index.mjs +4 -4
  48. package/dist/components/index.d.mts +33 -10
  49. package/dist/components/index.d.ts +33 -10
  50. package/dist/components/index.js +7 -5
  51. package/dist/components/index.js.map +1 -1
  52. package/dist/components/index.mjs +6 -4
  53. package/dist/{config-B6UB7j69.d.ts → config-3N1LSrcC.d.ts} +1 -1
  54. package/dist/{config-Cd5xTEfZ.d.ts → config-AVtuvfM1.d.ts} +1 -1
  55. package/dist/{config-n-ZpPmOL.d.mts → config-BSKTYDoh.d.mts} +1 -1
  56. package/dist/{config-YyhFHxkU.d.mts → config-BmufyEj6.d.mts} +1 -1
  57. package/dist/contexts/index.d.mts +5 -4
  58. package/dist/contexts/index.d.ts +5 -4
  59. package/dist/contexts/index.js +5 -5
  60. package/dist/contexts/index.mjs +4 -4
  61. package/dist/core/index.d.mts +31 -14
  62. package/dist/core/index.d.ts +31 -14
  63. package/dist/core/index.js +9 -5
  64. package/dist/core/index.js.map +1 -1
  65. package/dist/core/index.mjs +8 -4
  66. package/dist/features/analytics/index.css +30 -0
  67. package/dist/features/analytics/index.css.map +1 -0
  68. package/dist/features/analytics/index.d.mts +508 -0
  69. package/dist/features/analytics/index.d.ts +508 -0
  70. package/dist/features/analytics/index.js +1166 -0
  71. package/dist/features/analytics/index.js.map +1 -0
  72. package/dist/features/analytics/index.mjs +1166 -0
  73. package/dist/features/analytics/index.mjs.map +1 -0
  74. package/dist/features/analytics/server.d.mts +28 -0
  75. package/dist/features/analytics/server.d.ts +28 -0
  76. package/dist/features/analytics/server.js +41 -0
  77. package/dist/features/analytics/server.js.map +1 -0
  78. package/dist/features/analytics/server.mjs +41 -0
  79. package/dist/features/analytics/server.mjs.map +1 -0
  80. package/dist/features/help/index.d.mts +2 -2
  81. package/dist/features/help/index.d.ts +2 -2
  82. package/dist/features/help/index.js +43 -43
  83. package/dist/features/help/index.js.map +1 -1
  84. package/dist/features/help/index.mjs +9 -9
  85. package/dist/features/help/index.mjs.map +1 -1
  86. package/dist/features/tokenusage/index.d.mts +2 -2
  87. package/dist/features/tokenusage/index.d.ts +2 -2
  88. package/dist/features/tokenusage/index.js +141 -223
  89. package/dist/features/tokenusage/index.js.map +1 -1
  90. package/dist/features/tokenusage/index.mjs +28 -110
  91. package/dist/features/tokenusage/index.mjs.map +1 -1
  92. package/dist/index.d.mts +11 -9
  93. package/dist/index.d.ts +11 -9
  94. package/dist/index.js +10 -6
  95. package/dist/index.js.map +1 -1
  96. package/dist/index.mjs +9 -5
  97. package/dist/notification.interface-BNCvnVCW.d.ts +34 -0
  98. package/dist/notification.interface-P_pNfzZQ.d.mts +34 -0
  99. package/dist/{s3.service-Bqr-fQmi.d.ts → s3.service-CbUa6a-8.d.ts} +3 -2
  100. package/dist/{s3.service-Buiy1ncj.d.mts → s3.service-DAp8FbuG.d.mts} +3 -2
  101. package/dist/server/index.d.mts +5 -4
  102. package/dist/server/index.d.ts +5 -4
  103. package/dist/server/index.js +39 -279
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/server/index.mjs +20 -260
  106. package/dist/server/index.mjs.map +1 -1
  107. package/dist/{types-bsm8Fp55.d.ts → types-Cn153-mH.d.ts} +1 -1
  108. package/dist/{types-Bq_UA8Lg.d.mts → types-D0uLYV2P.d.mts} +1 -1
  109. package/dist/{usePageUrlGenerator-J_ECv-oQ.d.ts → usePageUrlGenerator-B-3FRKBD.d.ts} +1 -1
  110. package/dist/{usePageUrlGenerator-uKBjAKqC.d.mts → usePageUrlGenerator-CCJ_cdQy.d.mts} +1 -1
  111. package/dist/{useSocket-BcH7l7B8.d.mts → useSocket-B9gTbuCj.d.mts} +1 -1
  112. package/dist/{useSocket-DCZd_eJk.d.ts → useSocket-BHVIyigI.d.ts} +1 -1
  113. package/dist/{notification.interface-MnRt1Tjm.d.mts → user.interface-CSaGWFbg.d.mts} +1 -31
  114. package/dist/{notification.interface-CjzV_KII.d.ts → user.interface-CrmwJLYW.d.ts} +1 -31
  115. package/package.json +11 -1
  116. package/src/client/index.ts +2 -0
  117. package/src/components/containers/PageContainer.tsx +1 -1
  118. package/src/components/containers/RoundPageContainer.tsx +60 -16
  119. package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
  120. package/src/components/editors/BlockNoteEditor.tsx +13 -1
  121. package/src/components/forms/EntityMultiSelector.tsx +4 -4
  122. package/src/components/forms/EntitySelector.tsx +4 -4
  123. package/src/components/forms/FormBlockNote.tsx +21 -1
  124. package/src/components/forms/FormCheckbox.tsx +6 -2
  125. package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
  126. package/src/components/forms/useEditorDialog.ts +22 -0
  127. package/src/components/navigations/Breadcrumb.tsx +45 -16
  128. package/src/components/navigations/Header.tsx +16 -3
  129. package/src/components/navigations/MobileNavigationBar.tsx +12 -0
  130. package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
  131. package/src/components/tables/ContentListTable.tsx +52 -32
  132. package/src/core/index.ts +4 -0
  133. package/src/core/registry/ModuleRegistry.ts +7 -0
  134. package/src/features/__tests__/i18n-keys.contract.spec.ts +1 -0
  135. package/src/features/analytics/__tests__/server-entry.spec.tsx +64 -0
  136. package/src/features/analytics/analytics.modules.ts +30 -0
  137. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +97 -0
  138. package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +120 -0
  139. package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +25 -0
  140. package/src/features/analytics/components/AnalyticsAdminTiles.tsx +191 -0
  141. package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +105 -0
  142. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +246 -0
  143. package/src/features/analytics/components/AnalyticsJourneyPageContainer.tsx +14 -0
  144. package/src/features/analytics/components/AnalyticsSessionsTable.tsx +107 -0
  145. package/src/features/analytics/components/AnalyticsTimelineChart.tsx +175 -0
  146. package/src/features/analytics/components/AnalyticsTracker.tsx +112 -0
  147. package/src/features/analytics/components/__tests__/AnalyticsAdminTiles.spec.tsx +39 -0
  148. package/src/features/analytics/components/__tests__/AnalyticsBreakdownTable.spec.tsx +39 -0
  149. package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +124 -0
  150. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +117 -0
  151. package/src/features/analytics/components/__tests__/AnalyticsSessionsTable.spec.tsx +121 -0
  152. package/src/features/analytics/components/__tests__/AnalyticsTimelineChart.spec.tsx +35 -0
  153. package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +248 -0
  154. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +28 -0
  155. package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +25 -0
  156. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +273 -0
  157. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +140 -0
  158. package/src/features/analytics/data/AnalyticsAdminService.ts +91 -0
  159. package/src/features/analytics/data/AnalyticsService.ts +23 -0
  160. package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +117 -0
  161. package/src/features/analytics/data/__tests__/AnalyticsService.spec.ts +56 -0
  162. package/src/features/analytics/data/__tests__/models.spec.ts +300 -0
  163. package/src/features/analytics/data/analytics-breakdown.interface.ts +9 -0
  164. package/src/features/analytics/data/analytics-breakdown.ts +65 -0
  165. package/src/features/analytics/data/analytics-event.interface.ts +7 -0
  166. package/src/features/analytics/data/analytics-event.ts +63 -0
  167. package/src/features/analytics/data/analytics-page-view.interface.ts +9 -0
  168. package/src/features/analytics/data/analytics-page-view.ts +57 -0
  169. package/src/features/analytics/data/analytics-session.interface.ts +23 -0
  170. package/src/features/analytics/data/analytics-session.ts +144 -0
  171. package/src/features/analytics/data/analytics-summary.interface.ts +11 -0
  172. package/src/features/analytics/data/analytics-summary.ts +79 -0
  173. package/src/features/analytics/data/analytics-timeline.interface.ts +10 -0
  174. package/src/features/analytics/data/analytics-timeline.ts +67 -0
  175. package/src/features/analytics/data/analytics.types.ts +97 -0
  176. package/src/features/analytics/data/index.ts +15 -0
  177. package/src/features/analytics/i18n-keys.ts +76 -0
  178. package/src/features/analytics/index.ts +31 -0
  179. package/src/features/analytics/lib/__tests__/utm.spec.ts +20 -0
  180. package/src/features/analytics/lib/__tests__/visitorCookie.spec.ts +63 -0
  181. package/src/features/analytics/lib/utm.ts +29 -0
  182. package/src/features/analytics/lib/visitorCookie.ts +32 -0
  183. package/src/features/analytics/server-entry.ts +8 -0
  184. package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
  185. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  186. package/src/features/assistant-message/components/MessageItem.tsx +2 -2
  187. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
  188. package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
  189. package/src/features/company/components/details/CompanyContent.tsx +3 -1
  190. package/src/features/company/components/details/CompanyDetails.tsx +9 -1
  191. package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
  192. package/src/features/help/components/HelpHeader.tsx +7 -5
  193. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
  194. package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
  195. package/src/features/tokenusage/lib/formatters.ts +5 -1
  196. package/src/features/waitlist/components/lists/WaitlistList.tsx +68 -121
  197. package/src/features/waitlist/data/WaitlistService.ts +2 -2
  198. package/src/features/waitlist/data/index.ts +1 -0
  199. package/src/features/waitlist/data/waitlist.fields.ts +8 -0
  200. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +69 -30
  201. package/src/shadcnui/ui/button.tsx +1 -1
  202. package/src/shadcnui/ui/checkbox.tsx +1 -1
  203. package/src/shadcnui/ui/command.tsx +4 -4
  204. package/src/shadcnui/ui/dialog.tsx +2 -0
  205. package/src/shadcnui/ui/input.tsx +1 -1
  206. package/src/shadcnui/ui/popover.tsx +9 -0
  207. package/src/shadcnui/ui/select.tsx +1 -1
  208. package/src/shadcnui/ui/sheet.tsx +8 -1
  209. package/src/shadcnui/ui/table.tsx +2 -2
  210. package/src/shadcnui/ui/textarea.tsx +1 -1
  211. package/dist/BlockNoteEditor-35D4R7HJ.js.map +0 -1
  212. package/dist/JsonApiRequest-G625QAH5.js +0 -26
  213. package/dist/chunk-2XCUAHQT.js.map +0 -1
  214. package/dist/chunk-4O7R2Z2O.mjs.map +0 -1
  215. package/dist/chunk-IBVKPLHT.mjs.map +0 -1
  216. package/dist/chunk-U7QVISQS.js.map +0 -1
  217. /package/dist/{BlockNoteViewer-KDKGUIG5.mjs.map → BlockNoteViewer-KRTAFTUX.mjs.map} +0 -0
  218. /package/dist/{JsonApiRequest-RRMNV7YE.mjs.map → JsonApiRequest-UHOWNI3O.mjs.map} +0 -0
  219. /package/dist/{chunk-T76WLDE5.mjs.map → chunk-37DMFVLN.mjs.map} +0 -0
  220. /package/dist/{chunk-ZJI5ZZW5.mjs.map → chunk-Q6J2ICKO.mjs.map} +0 -0
@@ -7,6 +7,7 @@ import { ReactNode } from "react";
7
7
  import { AttributeElement } from "../../../../components";
8
8
  import { SectionHeader, MicroLabel } from "../../../../components/typography";
9
9
  import { FiscalDataDisplay } from "../../../../components/fiscal/FiscalDataDisplay";
10
+ import { cn } from "../../../../utils";
10
11
  import { CompanyInterface } from "../../data";
11
12
 
12
13
  type CompanyContentProps = {
@@ -34,7 +35,8 @@ export function CompanyContent({ company, actions }: CompanyContentProps) {
34
35
  return (
35
36
  <div className="flex flex-col gap-y-8">
36
37
  {/* Title Row */}
37
- <div className="flex w-full items-center justify-between">
38
+ {/* Without actions the row only repeats the name the page header shows; hide it on a phone. */}
39
+ <div className={cn("flex w-full items-center justify-between", !actions && "max-md:hidden")}>
38
40
  <SectionHeader level={2}>{company.name}</SectionHeader>
39
41
  {actions && <div className="flex items-center gap-x-2">{actions}</div>}
40
42
  </div>
@@ -17,7 +17,15 @@ export function CompanyDetails() {
17
17
 
18
18
  return (
19
19
  <div className="flex w-full flex-col gap-y-2">
20
- <ContentTitle module={Modules.Company} type={title.type} element={title.element} functions={title.functions} />
20
+ {/* On a phone the page header already shows the type, the name and the
21
+ commands, so this inner copy is hidden there. */}
22
+ <ContentTitle
23
+ module={Modules.Company}
24
+ type={title.type}
25
+ element={title.element}
26
+ functions={title.functions}
27
+ className="max-md:hidden"
28
+ />
21
29
  <TokenStatusIndicator size="md" />
22
30
  <CompanyContent company={company} />
23
31
  </div>
@@ -12,6 +12,12 @@ interface TokenStatusIndicatorProps {
12
12
  className?: string;
13
13
  size?: "sm" | "md" | "lg";
14
14
  showExtraPages?: boolean;
15
+ /**
16
+ * Render the indicator as a link to this URL instead of a hover tooltip.
17
+ * For touch surfaces (the mobile header), where a hover tooltip never opens:
18
+ * the link carries a 40px tap area and leads to the usage page.
19
+ */
20
+ href?: string;
15
21
  }
16
22
 
17
23
  /**
@@ -36,7 +42,12 @@ const asWholeCredits = (value: number): string => Math.trunc(value).toLocaleStri
36
42
  * - BatteryLow: 5-25% available
37
43
  * - Battery (empty): <5% available
38
44
  */
39
- export function TokenStatusIndicator({ className, size = "md", showExtraPages = true }: TokenStatusIndicatorProps) {
45
+ export function TokenStatusIndicator({
46
+ className,
47
+ size = "md",
48
+ showExtraPages = true,
49
+ href,
50
+ }: TokenStatusIndicatorProps) {
40
51
  const { company } = useCurrentUserContext();
41
52
  const t = useTranslations();
42
53
 
@@ -131,6 +142,35 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
131
142
  </div>
132
143
  );
133
144
 
145
+ const badge = (
146
+ <>
147
+ {getBatteryIcon()}
148
+ <span className={cn(textSize, "text-muted-foreground font-medium leading-none")}>
149
+ {asWholeCredits(availableMonthlyCredits)}
150
+ </span>
151
+ {showExtraPages && availableExtraCredits > 0 && (
152
+ <span className="inline-flex items-center gap-0.5">
153
+ <PlusCircle className={cn(smallIconSize, "text-blue-500")} />
154
+ <span className={cn(textSize, "text-blue-500 font-medium leading-none")}>
155
+ {asWholeCredits(availableExtraCredits)}
156
+ </span>
157
+ </span>
158
+ )}
159
+ </>
160
+ );
161
+
162
+ if (href) {
163
+ return (
164
+ <Link
165
+ href={href}
166
+ className={cn("inline-flex min-h-10 min-w-10 items-center justify-center gap-1.5 px-1", className)}
167
+ aria-label={t("billing.tokens.status", { defaultValue: "Page Status" })}
168
+ >
169
+ {badge}
170
+ </Link>
171
+ );
172
+ }
173
+
134
174
  return (
135
175
  <Tooltip>
136
176
  {/* The layout classes live on the trigger itself, which renders the
@@ -142,18 +182,7 @@ export function TokenStatusIndicator({ className, size = "md", showExtraPages =
142
182
  className={cn("inline-flex items-center gap-1.5 cursor-default", className)}
143
183
  aria-label={t("billing.tokens.status", { defaultValue: "Page Status" })}
144
184
  >
145
- {getBatteryIcon()}
146
- <span className={cn(textSize, "text-muted-foreground font-medium leading-none")}>
147
- {asWholeCredits(availableMonthlyCredits)}
148
- </span>
149
- {showExtraPages && availableExtraCredits > 0 && (
150
- <span className="inline-flex items-center gap-0.5">
151
- <PlusCircle className={cn(smallIconSize, "text-blue-500")} />
152
- <span className={cn(textSize, "text-blue-500 font-medium leading-none")}>
153
- {asWholeCredits(availableExtraCredits)}
154
- </span>
155
- </span>
156
- )}
185
+ {badge}
157
186
  </TooltipTrigger>
158
187
  <TooltipContent side="top" className="max-w-xs">
159
188
  {tooltipContent}
@@ -16,14 +16,16 @@ export function HelpHeader() {
16
16
  const appHref = brand?.appHref ?? "/";
17
17
 
18
18
  return (
19
- <header className="border-border bg-background flex items-center justify-between border-b px-4 py-3">
20
- <Link href="/help" className="flex items-center gap-2">
21
- {logo ? <Image src={logo} alt={label} width={28} height={28} /> : null}
22
- <span className="text-base font-semibold">
19
+ <header className="border-border bg-background flex items-center justify-between border-b px-4 py-3 max-md:gap-2">
20
+ {/* Phone: the title shrinks and the "· Help & Documentation" suffix
21
+ truncates with an ellipsis instead of wrapping onto a second line. */}
22
+ <Link href="/help" className="flex items-center gap-2 max-md:min-w-0">
23
+ {logo ? <Image src={logo} alt={label} width={28} height={28} className="max-md:shrink-0" /> : null}
24
+ <span className="text-base font-semibold max-md:truncate">
23
25
  {label} · {t("help.footerLink")}
24
26
  </span>
25
27
  </Link>
26
- <div className="flex items-center gap-2">
28
+ <div className="flex items-center gap-2 max-md:shrink-0">
27
29
  <HelpAskAi />
28
30
  {/* Styled Links, not <Button render={<Link/>} nativeButton={false}>. Base UI's
29
31
  Button primitive warns when rendered as a non-button, and nativeButton={false}
@@ -41,12 +41,15 @@ export function TokenUsageAdminFilterBar({ granularity, companyId, metric, compa
41
41
 
42
42
  return (
43
43
  <div className="flex flex-wrap items-center gap-2">
44
- <DateRangeSelector
45
- onDateChange={(range) => {
46
- if (!range?.from || !range?.to) return;
47
- onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
48
- }}
49
- />
44
+ {/* The date button is a fixed 300px; on a phone it takes the full row instead. */}
45
+ <div className="max-md:w-full max-md:[&_#date]:w-full">
46
+ <DateRangeSelector
47
+ onDateChange={(range) => {
48
+ if (!range?.from || !range?.to) return;
49
+ onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
50
+ }}
51
+ />
52
+ </div>
50
53
 
51
54
  <Segmented
52
55
  ariaLabel={t("token_usage.admin.granularity.label")}
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createUsageFormatters } from "../formatters";
3
+
4
+ describe("createUsageFormatters.compact", () => {
5
+ const { compact } = createUsageFormatters("en-US", "EUR");
6
+
7
+ it("keeps sub-unit axis ticks distinct", () => {
8
+ const ticks = [0, 0.05, 0.1, 0.15, 0.2, 0.25];
9
+ const labels = ticks.map(compact);
10
+ expect(labels).toEqual(["0", "0.05", "0.1", "0.15", "0.2", "0.25"]);
11
+ expect(new Set(labels).size).toBe(ticks.length);
12
+ });
13
+
14
+ it("keeps thousands ticks distinct and exact", () => {
15
+ expect([0, 250, 500, 750, 1000, 1250].map(compact)).toEqual(["0", "250", "500", "750", "1K", "1.25K"]);
16
+ });
17
+
18
+ it("still abbreviates large values", () => {
19
+ expect(compact(1500000)).toBe("1.5M");
20
+ });
21
+ });
@@ -50,7 +50,11 @@ export function createUsageFormatters(locale: string, currency: string): UsageFo
50
50
 
51
51
  const dayFormat = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", timeZone: "UTC" });
52
52
  const monthFormat = new Intl.DateTimeFormat(locale, { month: "short", year: "numeric", timeZone: "UTC" });
53
- const compactFormat = new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 });
53
+ // Axis ticks step by 1, 2, 2.5 or 5 times a power of ten, so a tick never needs
54
+ // more than three significant digits. A fixed one-decimal cap rounded a 0.05
55
+ // step into repeated labels (0.1, 0.1, 0.2, 0.2); significant digits keep every
56
+ // tick distinct at any scale (0.05, 0.15, 1.25K).
57
+ const compactFormat = new Intl.NumberFormat(locale, { notation: "compact", maximumSignificantDigits: 3 });
54
58
  const collator = new Intl.Collator(locale);
55
59
 
56
60
  return {
@@ -1,68 +1,52 @@
1
1
  "use client";
2
2
 
3
- import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
4
- import { RefreshCw, Users } from "lucide-react";
3
+ import { RefreshCw } from "lucide-react";
5
4
  import { useTranslations } from "next-intl";
6
- import { useCallback, useEffect, useMemo, useState } from "react";
7
- import { errorToast } from "../../../../components";
8
- import { SectionHeader } from "../../../../components/typography";
9
- import {
10
- Button,
11
- Select,
12
- SelectContent,
13
- SelectItem,
14
- SelectTrigger,
15
- SelectValue,
16
- Table,
17
- TableBody,
18
- TableCell,
19
- TableHead,
20
- TableHeader,
21
- TableRow,
22
- } from "../../../../shadcnui";
5
+ import { useMemo, useState } from "react";
6
+ import { ContentListTable, errorToast } from "../../../../components";
7
+ import { Modules } from "../../../../core";
8
+ import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
9
+ import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../shadcnui";
23
10
  import { showToast } from "../../../../utils/toast";
11
+ import { WaitlistFields } from "../../data/waitlist.fields";
24
12
  import { WaitlistInterface } from "../../data/WaitlistInterface";
25
13
  import { WaitlistService } from "../../data/WaitlistService";
26
- import { useWaitlistTableStructure } from "../../hooks/useWaitlistTableStructure";
27
14
 
28
- export function WaitlistList() {
15
+ type WaitlistListProps = {
16
+ /**
17
+ * Pass this whenever the list is the page's own content inside a
18
+ * `RoundPageContainer fullWidth`. Without it ContentListTable wraps itself in
19
+ * `rounded-md border`, drawing a second bordered card inside the page's
20
+ * rounded shell.
21
+ */
22
+ fullWidth?: boolean;
23
+ };
24
+
25
+ export function WaitlistList({ fullWidth }: WaitlistListProps = {}) {
29
26
  const t = useTranslations();
30
- const [entries, setEntries] = useState<WaitlistInterface[]>([]);
31
- const [total, setTotal] = useState(0);
32
- const [isLoading, setIsLoading] = useState(true);
33
27
  const [statusFilter, setStatusFilter] = useState<string>("all");
34
28
 
35
- const loadEntries = useCallback(async () => {
36
- setIsLoading(true);
37
- try {
38
- const result = await WaitlistService.findMany({
39
- status: statusFilter === "all" ? undefined : statusFilter,
40
- fetchAll: true,
41
- });
42
- setEntries(result);
43
- setTotal(result.length);
44
- } catch (error) {
45
- errorToast({ error });
46
- } finally {
47
- setIsLoading(false);
48
- }
49
- }, [statusFilter]);
50
-
51
- useEffect(() => {
52
- loadEntries();
53
- }, [loadEntries]);
29
+ const data: DataListRetriever<WaitlistInterface> = useDataListRetriever({
30
+ retriever: (params) => WaitlistService.findMany(params),
31
+ retrieverParams: {},
32
+ module: Modules.Waitlist,
33
+ });
54
34
 
55
35
  const handleInvite = async (entry: WaitlistInterface) => {
56
36
  try {
57
37
  await WaitlistService.invite(entry.id);
58
38
  showToast(t("waitlist.admin.invite_sent", { email: entry.email }));
59
- loadEntries();
39
+ void data.refresh();
60
40
  } catch (error) {
61
41
  errorToast({ error });
62
42
  }
63
43
  };
64
44
 
65
- const columns = useWaitlistTableStructure({ onInvite: handleInvite });
45
+ const handleStatusChange = (value: string | null) => {
46
+ const status = value ?? "all";
47
+ setStatusFilter(status);
48
+ data.addAdditionalParameter("status", status === "all" ? null : status);
49
+ };
66
50
 
67
51
  const statusItems = useMemo(
68
52
  () => ({
@@ -75,83 +59,46 @@ export function WaitlistList() {
75
59
  [t],
76
60
  );
77
61
 
78
- const table = useReactTable({
79
- data: entries,
80
- columns,
81
- getCoreRowModel: getCoreRowModel(),
82
- });
83
-
84
- return (
85
- <div className="space-y-4">
86
- {/* Header */}
87
- <div className="flex items-center justify-between">
88
- <div className="flex items-center gap-2">
89
- <Users className="h-5 w-5" />
90
- <SectionHeader level={2}>{t("waitlist.admin.title")}</SectionHeader>
91
- <span className="text-muted-foreground">({t("waitlist.admin.entries_count", { count: total })})</span>
92
- </div>
93
-
94
- <div className="flex items-center gap-4">
95
- {/* Status Filter */}
96
- <Select items={statusItems} value={statusFilter} onValueChange={(value) => setStatusFilter(value ?? "all")}>
97
- <SelectTrigger className="w-40">
98
- <SelectValue placeholder={t("waitlist.admin.filter_placeholder")} />
99
- </SelectTrigger>
100
- <SelectContent>
101
- <SelectItem value="all">{t("waitlist.admin.all_statuses")}</SelectItem>
102
- <SelectItem value="pending">{t("waitlist.admin.status.pending")}</SelectItem>
103
- <SelectItem value="confirmed">{t("waitlist.admin.status.confirmed")}</SelectItem>
104
- <SelectItem value="invited">{t("waitlist.admin.status.invited")}</SelectItem>
105
- <SelectItem value="registered">{t("waitlist.admin.status.registered")}</SelectItem>
106
- </SelectContent>
107
- </Select>
62
+ const filters = (
63
+ <div className="flex items-center gap-4">
64
+ <Select items={statusItems} value={statusFilter} onValueChange={handleStatusChange}>
65
+ <SelectTrigger className="w-40">
66
+ <SelectValue placeholder={t("waitlist.admin.filter_placeholder")} />
67
+ </SelectTrigger>
68
+ <SelectContent>
69
+ <SelectItem value="all">{t("waitlist.admin.all_statuses")}</SelectItem>
70
+ <SelectItem value="pending">{t("waitlist.admin.status.pending")}</SelectItem>
71
+ <SelectItem value="confirmed">{t("waitlist.admin.status.confirmed")}</SelectItem>
72
+ <SelectItem value="invited">{t("waitlist.admin.status.invited")}</SelectItem>
73
+ <SelectItem value="registered">{t("waitlist.admin.status.registered")}</SelectItem>
74
+ </SelectContent>
75
+ </Select>
108
76
 
109
- {/* Refresh Button */}
110
- <Button variant="outline" size="icon" onClick={loadEntries} disabled={isLoading}>
111
- <RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
112
- </Button>
113
- </div>
114
- </div>
115
-
116
- {/* Table */}
117
- <div className="rounded-md border">
118
- <Table>
119
- <TableHeader>
120
- {table.getHeaderGroups().map((headerGroup) => (
121
- <TableRow key={headerGroup.id}>
122
- {headerGroup.headers.map((header) => (
123
- <TableHead key={header.id}>
124
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
125
- </TableHead>
126
- ))}
127
- </TableRow>
128
- ))}
129
- </TableHeader>
130
- <TableBody>
131
- {isLoading ? (
132
- <TableRow>
133
- <TableCell colSpan={columns.length} className="h-24 text-center">
134
- {t("waitlist.admin.loading")}
135
- </TableCell>
136
- </TableRow>
137
- ) : entries.length === 0 ? (
138
- <TableRow>
139
- <TableCell colSpan={columns.length} className="h-24 text-center">
140
- {t("waitlist.admin.empty")}
141
- </TableCell>
142
- </TableRow>
143
- ) : (
144
- table.getRowModel().rows.map((row) => (
145
- <TableRow key={row.id}>
146
- {row.getVisibleCells().map((cell) => (
147
- <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
148
- ))}
149
- </TableRow>
150
- ))
151
- )}
152
- </TableBody>
153
- </Table>
154
- </div>
77
+ <Button variant="outline" size="icon" onClick={() => void data.refresh()} disabled={!data.isLoaded}>
78
+ <RefreshCw className={`h-4 w-4 ${!data.isLoaded ? "animate-spin" : ""}`} />
79
+ </Button>
155
80
  </div>
156
81
  );
82
+
83
+ return (
84
+ <ContentListTable
85
+ data={data}
86
+ fields={[
87
+ WaitlistFields.email,
88
+ WaitlistFields.status,
89
+ WaitlistFields.createdAt,
90
+ WaitlistFields.questionnaire,
91
+ WaitlistFields.actions,
92
+ ]}
93
+ tableGeneratorType={Modules.Waitlist}
94
+ title={t("waitlist.admin.title")}
95
+ filters={filters}
96
+ /* The waitlist endpoint takes no search term, so rendering the box would
97
+ give the page a control that silently does nothing. */
98
+ allowSearch={false}
99
+ context={{ onInvite: handleInvite }}
100
+ fullWidth={fullWidth}
101
+ emptyState={t("waitlist.admin.empty")}
102
+ />
103
+ );
157
104
  }
@@ -42,7 +42,7 @@ export class WaitlistService extends AbstractService {
42
42
  search?: string;
43
43
  fetchAll?: boolean;
44
44
  next?: NextRef;
45
- prev?: PreviousRef;
45
+ previous?: PreviousRef;
46
46
  }): Promise<WaitlistInterface[]> {
47
47
  const endpoint = new EndpointCreator({ endpoint: Modules.Waitlist });
48
48
 
@@ -55,7 +55,7 @@ export class WaitlistService extends AbstractService {
55
55
  method: HttpMethod.GET,
56
56
  endpoint: endpoint.generate(),
57
57
  next: params?.next,
58
- previous: params?.prev,
58
+ previous: params?.previous,
59
59
  });
60
60
  }
61
61
 
@@ -3,3 +3,4 @@ export * from "./Waitlist";
3
3
  export * from "./WaitlistService";
4
4
  export * from "./waitlist-stats.interface";
5
5
  export * from "./waitlist-stats";
6
+ export * from "./waitlist.fields";
@@ -0,0 +1,8 @@
1
+ export enum WaitlistFields {
2
+ waitlistId = "waitlistId",
3
+ email = "email",
4
+ status = "status",
5
+ createdAt = "createdAt",
6
+ questionnaire = "questionnaire",
7
+ actions = "actions",
8
+ }
@@ -3,13 +3,12 @@
3
3
  import { ColumnDef } from "@tanstack/react-table";
4
4
  import { Send } from "lucide-react";
5
5
  import { useTranslations } from "next-intl";
6
+ import { useMemo } from "react";
7
+ import { TableContent, UseTableStructureHook } from "../../../hooks";
6
8
  import { Badge, Button } from "../../../shadcnui";
9
+ import { WaitlistFields } from "../data/waitlist.fields";
7
10
  import { WaitlistInterface, WaitlistStatus } from "../data/WaitlistInterface";
8
11
 
9
- interface UseWaitlistTableStructureProps {
10
- onInvite: (entry: WaitlistInterface) => void;
11
- }
12
-
13
12
  /**
14
13
  * Parse questionnaire JSON string safely
15
14
  */
@@ -22,10 +21,25 @@ function parseQuestionnaire(questionnaire: string | undefined): Record<string, a
22
21
  }
23
22
  }
24
23
 
25
- export function useWaitlistTableStructure({
26
- onInvite,
27
- }: UseWaitlistTableStructureProps): ColumnDef<WaitlistInterface>[] {
24
+ /**
25
+ * Columns for the administrative waitlist. The invite action needs the list's
26
+ * own handler, so WaitlistList passes it through ContentListTable's `context`
27
+ * as `onInvite`.
28
+ */
29
+ export const useWaitlistTableStructure: UseTableStructureHook<WaitlistInterface, WaitlistFields> = (params) => {
28
30
  const t = useTranslations();
31
+ const onInvite: ((entry: WaitlistInterface) => void) | undefined = params.context?.onInvite;
32
+
33
+ const tableData = useMemo(() => {
34
+ return params.data.map((entry: WaitlistInterface) => {
35
+ const row: TableContent<WaitlistInterface> = { jsonApiData: entry };
36
+ row[WaitlistFields.waitlistId] = entry.id;
37
+ params.fields.forEach((field) => {
38
+ row[field] = (entry as any)[field as keyof WaitlistInterface];
39
+ });
40
+ return row;
41
+ });
42
+ }, [params.data, params.fields]);
29
43
 
30
44
  const getStatusBadge = (status: WaitlistStatus) => {
31
45
  const variants: Record<WaitlistStatus, { variant: "default" | "secondary" | "outline" | "destructive" }> = {
@@ -39,31 +53,44 @@ export function useWaitlistTableStructure({
39
53
  return <Badge variant={config.variant}>{t(`waitlist.admin.status.${status}`)}</Badge>;
40
54
  };
41
55
 
42
- return [
43
- {
56
+ const fieldColumnMap: Partial<Record<WaitlistFields, () => any>> = {
57
+ [WaitlistFields.email]: () => ({
58
+ id: "email",
44
59
  accessorKey: "email",
45
60
  header: t("waitlist.admin.columns.email"),
46
- cell: ({ row }) => <span className="font-medium">{row.original.email}</span>,
47
- },
48
- {
61
+ cell: ({ row }: { row: TableContent<WaitlistInterface> }) => (
62
+ <span className="font-medium">{row.original.jsonApiData.email}</span>
63
+ ),
64
+ enableSorting: false,
65
+ enableHiding: false,
66
+ }),
67
+ [WaitlistFields.status]: () => ({
68
+ id: "status",
49
69
  accessorKey: "status",
50
70
  header: t("waitlist.admin.columns.status"),
51
- cell: ({ row }) => getStatusBadge(row.original.status),
52
- },
53
- {
71
+ cell: ({ row }: { row: TableContent<WaitlistInterface> }) => getStatusBadge(row.original.jsonApiData.status),
72
+ enableSorting: false,
73
+ enableHiding: false,
74
+ }),
75
+ [WaitlistFields.createdAt]: () => ({
76
+ id: "createdAt",
54
77
  accessorKey: "createdAt",
55
78
  header: t("waitlist.admin.columns.submitted"),
56
- cell: ({ row }) => {
57
- if (!row.original.createdAt) return "-";
58
- return new Date(row.original.createdAt).toLocaleDateString();
79
+ cell: ({ row }: { row: TableContent<WaitlistInterface> }) => {
80
+ const entry: WaitlistInterface = row.original.jsonApiData;
81
+ if (!entry.createdAt) return "-";
82
+ return new Date(entry.createdAt).toLocaleDateString();
59
83
  },
60
- },
61
- {
84
+ enableSorting: false,
85
+ enableHiding: false,
86
+ }),
87
+ [WaitlistFields.questionnaire]: () => ({
88
+ id: "questionnaire",
62
89
  accessorKey: "questionnaire",
63
90
  header: t("waitlist.admin.columns.questionnaire"),
64
- cell: ({ row }) => {
91
+ cell: ({ row }: { row: TableContent<WaitlistInterface> }) => {
65
92
  // IMPORTANT: Parse JSON string from backend
66
- const questionnaire = parseQuestionnaire(row.original.questionnaire);
93
+ const questionnaire = parseQuestionnaire(row.original.jsonApiData.questionnaire);
67
94
  if (!questionnaire || Object.keys(questionnaire).length === 0) {
68
95
  return <span className="text-muted-foreground">-</span>;
69
96
  }
@@ -84,16 +111,18 @@ export function useWaitlistTableStructure({
84
111
  </details>
85
112
  );
86
113
  },
87
- },
88
- {
114
+ enableSorting: false,
115
+ enableHiding: false,
116
+ }),
117
+ [WaitlistFields.actions]: () => ({
89
118
  id: "actions",
90
119
  header: t("waitlist.admin.columns.actions"),
91
- cell: ({ row }) => {
92
- const entry = row.original;
120
+ cell: ({ row }: { row: TableContent<WaitlistInterface> }) => {
121
+ const entry: WaitlistInterface = row.original.jsonApiData;
93
122
 
94
123
  if (entry.status === "confirmed") {
95
124
  return (
96
- <Button size="sm" variant="outline" onClick={() => onInvite(entry)}>
125
+ <Button size="sm" variant="outline" onClick={() => onInvite?.(entry)}>
97
126
  <Send className="me-2 h-4 w-4" />
98
127
  {t("waitlist.admin.actions.invite")}
99
128
  </Button>
@@ -116,6 +145,16 @@ export function useWaitlistTableStructure({
116
145
  <span className="text-muted-foreground text-sm">{t("waitlist.admin.actions.awaiting_confirmation")}</span>
117
146
  );
118
147
  },
119
- },
120
- ];
121
- }
148
+ enableSorting: false,
149
+ enableHiding: false,
150
+ }),
151
+ };
152
+
153
+ const columns = useMemo(() => {
154
+ return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
155
+ TableContent<WaitlistInterface>
156
+ >[];
157
+ }, [params.fields, fieldColumnMap, t]);
158
+
159
+ return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
160
+ };
@@ -21,7 +21,7 @@ const buttonVariants = cva(
21
21
  },
22
22
  size: {
23
23
  default:
24
- "h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
24
+ "h-7 max-md:h-9 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
25
25
  xs: "h-5 gap-1 rounded-sm px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-2.5",
26
26
  sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
27
27
  lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-4",
@@ -10,7 +10,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
10
10
  <CheckboxPrimitive.Root
11
11
  data-slot="checkbox"
12
12
  className={cn(
13
- "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-[2px] aria-invalid:ring-[2px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 cursor-pointer data-disabled:cursor-not-allowed data-disabled:opacity-50",
13
+ "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-[2px] aria-invalid:ring-[2px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 max-md:after:-inset-y-3 cursor-pointer data-disabled:cursor-not-allowed data-disabled:opacity-50",
14
14
  className,
15
15
  )}
16
16
  {...props}