@carlonicora/nextjs-jsonapi 5.4.0 → 5.6.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 (237) hide show
  1. package/dist/{BlockNoteEditor-4YCAIEPT.js → BlockNoteEditor-AE3ZTEEH.js} +20 -20
  2. package/dist/{BlockNoteEditor-4YCAIEPT.js.map → BlockNoteEditor-AE3ZTEEH.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-YQL2SGFR.mjs → BlockNoteEditor-NEYG5L6W.mjs} +5 -5
  4. package/dist/{BlockNoteViewer-AEJKJ27Q.mjs → BlockNoteViewer-3OUT5UFY.mjs} +3 -3
  5. package/dist/{BlockNoteViewer-YL3J7J2Y.js → BlockNoteViewer-4CFKQM7U.js} +4 -4
  6. package/dist/{BlockNoteViewer-YL3J7J2Y.js.map → BlockNoteViewer-4CFKQM7U.js.map} +1 -1
  7. package/dist/billing/index.d.mts +186 -25
  8. package/dist/billing/index.d.ts +186 -25
  9. package/dist/billing/index.js +1149 -588
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +786 -225
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-5PPG6PL7.js → chunk-3I5AAJXV.js} +3 -3
  14. package/dist/{chunk-5PPG6PL7.js.map → chunk-3I5AAJXV.js.map} +1 -1
  15. package/dist/{chunk-YW4ZCEWB.js → chunk-53BAULDP.js} +4 -4
  16. package/dist/{chunk-YW4ZCEWB.js.map → chunk-53BAULDP.js.map} +1 -1
  17. package/dist/{chunk-XCHSX3VC.mjs → chunk-BTPUYUQ3.mjs} +2 -2
  18. package/dist/{chunk-WC63PJSC.mjs → chunk-EYA2F6AN.mjs} +756 -24
  19. package/dist/chunk-EYA2F6AN.mjs.map +1 -0
  20. package/dist/{chunk-NFBOSPNJ.js → chunk-HKZAESS6.js} +954 -222
  21. package/dist/chunk-HKZAESS6.js.map +1 -0
  22. package/dist/{chunk-6DC2WVVB.mjs → chunk-KQHBONL4.mjs} +2 -2
  23. package/dist/{chunk-3NLAXVIN.js → chunk-LQ4DK4JF.js} +1988 -1089
  24. package/dist/chunk-LQ4DK4JF.js.map +1 -0
  25. package/dist/{chunk-YNCQSWQW.js → chunk-RL3PL3KV.js} +8 -8
  26. package/dist/{chunk-YNCQSWQW.js.map → chunk-RL3PL3KV.js.map} +1 -1
  27. package/dist/{chunk-EUKTBOI2.mjs → chunk-SFYPXCK4.mjs} +3382 -2483
  28. package/dist/chunk-SFYPXCK4.mjs.map +1 -0
  29. package/dist/{chunk-QMYQRPQC.mjs → chunk-Y7RR57EW.mjs} +2 -2
  30. package/dist/client/index.js +5 -5
  31. package/dist/client/index.js.map +1 -1
  32. package/dist/client/index.mjs +4 -4
  33. package/dist/components/editors/server.d.mts +15 -0
  34. package/dist/components/editors/server.d.ts +15 -0
  35. package/dist/components/editors/server.js +38 -0
  36. package/dist/components/editors/server.js.map +1 -0
  37. package/dist/components/editors/server.mjs +38 -0
  38. package/dist/components/editors/server.mjs.map +1 -0
  39. package/dist/components/index.d.mts +33 -10
  40. package/dist/components/index.d.ts +33 -10
  41. package/dist/components/index.js +7 -5
  42. package/dist/components/index.js.map +1 -1
  43. package/dist/components/index.mjs +8 -6
  44. package/dist/contexts/index.d.mts +9 -1
  45. package/dist/contexts/index.d.ts +9 -1
  46. package/dist/contexts/index.js +5 -5
  47. package/dist/contexts/index.mjs +4 -4
  48. package/dist/core/index.d.mts +242 -3
  49. package/dist/core/index.d.ts +242 -3
  50. package/dist/core/index.js +36 -4
  51. package/dist/core/index.js.map +1 -1
  52. package/dist/core/index.mjs +35 -3
  53. package/dist/email-automations/index.d.mts +3 -0
  54. package/dist/email-automations/index.d.ts +3 -0
  55. package/dist/email-automations/index.js +99 -95
  56. package/dist/email-automations/index.js.map +1 -1
  57. package/dist/email-automations/index.mjs +30 -26
  58. package/dist/email-automations/index.mjs.map +1 -1
  59. package/dist/features/analytics/index.d.mts +11 -4
  60. package/dist/features/analytics/index.d.ts +11 -4
  61. package/dist/features/analytics/index.js +127 -135
  62. package/dist/features/analytics/index.js.map +1 -1
  63. package/dist/features/analytics/index.mjs +16 -24
  64. package/dist/features/analytics/index.mjs.map +1 -1
  65. package/dist/features/analytics/server.d.mts +5 -2
  66. package/dist/features/analytics/server.d.ts +5 -2
  67. package/dist/features/analytics/server.js +7 -7
  68. package/dist/features/analytics/server.js.map +1 -1
  69. package/dist/features/analytics/server.mjs +5 -5
  70. package/dist/features/analytics/server.mjs.map +1 -1
  71. package/dist/features/blog/index.d.mts +12 -2
  72. package/dist/features/blog/index.d.ts +12 -2
  73. package/dist/features/blog/index.js +206 -195
  74. package/dist/features/blog/index.js.map +1 -1
  75. package/dist/features/blog/index.mjs +71 -60
  76. package/dist/features/blog/index.mjs.map +1 -1
  77. package/dist/features/blog/server.d.mts +4 -1
  78. package/dist/features/blog/server.d.ts +4 -1
  79. package/dist/features/blog/server.js +49 -21
  80. package/dist/features/blog/server.js.map +1 -1
  81. package/dist/features/blog/server.mjs +36 -8
  82. package/dist/features/blog/server.mjs.map +1 -1
  83. package/dist/features/help/index.js +38 -38
  84. package/dist/features/help/index.mjs +4 -4
  85. package/dist/features/tokenusage/index.d.mts +1 -3
  86. package/dist/features/tokenusage/index.d.ts +1 -3
  87. package/dist/features/tokenusage/index.js +127 -135
  88. package/dist/features/tokenusage/index.js.map +1 -1
  89. package/dist/features/tokenusage/index.mjs +10 -18
  90. package/dist/features/tokenusage/index.mjs.map +1 -1
  91. package/dist/{stripe-promotion-code.interface-C3qqh3mi.d.ts → format-options-BtX3o0Yu.d.ts} +104 -1
  92. package/dist/{stripe-promotion-code.interface-ClZ7DxS9.d.mts → format-options-D6c7FNDi.d.mts} +104 -1
  93. package/dist/index.d.mts +2 -2
  94. package/dist/index.d.ts +2 -2
  95. package/dist/index.js +37 -5
  96. package/dist/index.js.map +1 -1
  97. package/dist/index.mjs +36 -4
  98. package/dist/server/index.js +4 -4
  99. package/dist/server/index.mjs +3 -3
  100. package/messages/en.json +125 -3
  101. package/messages/it.json +125 -3
  102. package/package.json +9 -1
  103. package/scripts/validate-messages.mjs +1 -0
  104. package/src/billing/index.ts +1 -0
  105. package/src/client/index.ts +4 -0
  106. package/src/components/editors/server/BlockNoteServerContent.tsx +28 -0
  107. package/src/components/editors/server/__tests__/renderBlockNoteHTML.spec.ts +44 -0
  108. package/src/components/editors/server/renderBlockNoteHTML.ts +26 -0
  109. package/src/components/editors/server-entry.ts +7 -0
  110. package/src/components/index.ts +1 -0
  111. package/src/components/viewers/BlockNoteStaticViewer.tsx +44 -0
  112. package/src/core/index.ts +1 -0
  113. package/src/core/registry/ModuleRegistry.ts +3 -0
  114. package/src/features/administration/components/AdminIndexContainer.tsx +61 -33
  115. package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +61 -1
  116. package/src/features/administration/components/index.ts +2 -1
  117. package/src/features/administration/contexts/AdministrationContext.tsx +11 -3
  118. package/src/features/administration/contexts/__tests__/AdministrationContext.spec.tsx +40 -0
  119. package/src/features/ai-connection/ai-connection.module.ts +2 -0
  120. package/src/features/ai-connection/contexts/AiConnectionsContext.tsx +3 -16
  121. package/src/features/ai-connection/contexts/__tests__/AiConnectionsContext.spec.tsx +33 -0
  122. package/src/features/analytics/__tests__/server-entry.spec.tsx +2 -1
  123. package/src/features/analytics/analytics.modules.ts +22 -5
  124. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +4 -3
  125. package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +6 -3
  126. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +4 -3
  127. package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +11 -0
  128. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +12 -1
  129. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +6 -3
  130. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +8 -17
  131. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +17 -0
  132. package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +3 -2
  133. package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +4 -3
  134. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +22 -5
  135. package/src/features/billing/components/utils/__tests__/discount.spec.ts +59 -0
  136. package/src/features/billing/components/utils/__tests__/url.spec.ts +32 -0
  137. package/src/features/billing/components/utils/discount.ts +38 -0
  138. package/src/features/billing/components/utils/index.ts +2 -0
  139. package/src/features/billing/components/utils/url.ts +12 -0
  140. package/src/features/billing/contexts/CouponContext.tsx +211 -0
  141. package/src/features/billing/contexts/index.ts +1 -0
  142. package/src/features/billing/i18n-keys.ts +79 -0
  143. package/src/features/billing/stripe-coupon/components/containers/CouponContainer.tsx +38 -0
  144. package/src/features/billing/stripe-coupon/components/containers/index.ts +1 -0
  145. package/src/features/billing/stripe-coupon/components/details/CouponDetails.tsx +69 -0
  146. package/src/features/billing/stripe-coupon/components/details/index.ts +1 -0
  147. package/src/features/billing/stripe-coupon/components/forms/CouponCodeEditor.tsx +79 -0
  148. package/src/features/billing/stripe-coupon/components/forms/CouponCodeFields.tsx +192 -0
  149. package/src/features/billing/stripe-coupon/components/forms/CouponEditor.tsx +333 -0
  150. package/src/features/billing/stripe-coupon/components/forms/CouponRefresher.tsx +45 -0
  151. package/src/features/billing/stripe-coupon/components/forms/index.ts +6 -0
  152. package/src/features/billing/stripe-coupon/components/index.ts +5 -0
  153. package/src/features/billing/stripe-coupon/components/lists/CouponCodesList.tsx +61 -0
  154. package/src/features/billing/stripe-coupon/components/lists/CouponsList.tsx +67 -0
  155. package/src/features/billing/stripe-coupon/components/lists/index.ts +2 -0
  156. package/src/features/billing/stripe-coupon/components/widgets/CouponCodeActions.tsx +80 -0
  157. package/src/features/billing/stripe-coupon/components/widgets/index.ts +1 -0
  158. package/src/features/billing/stripe-coupon/data/__tests__/stripe-coupon-models.spec.ts +114 -0
  159. package/src/features/billing/stripe-coupon/data/index.ts +10 -0
  160. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.fields.ts +8 -0
  161. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.interface.ts +44 -0
  162. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.service.ts +92 -0
  163. package/src/features/billing/stripe-coupon/data/stripe-coupon-code.ts +132 -0
  164. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.interface.ts +17 -0
  165. package/src/features/billing/stripe-coupon/data/stripe-coupon-offer.ts +70 -0
  166. package/src/features/billing/stripe-coupon/data/stripe-coupon.fields.ts +7 -0
  167. package/src/features/billing/stripe-coupon/data/stripe-coupon.interface.ts +51 -0
  168. package/src/features/billing/stripe-coupon/data/stripe-coupon.service.ts +93 -0
  169. package/src/features/billing/stripe-coupon/data/stripe-coupon.ts +165 -0
  170. package/src/features/billing/stripe-coupon/hooks/useStripeCouponCodeTableStructure.tsx +154 -0
  171. package/src/features/billing/stripe-coupon/hooks/useStripeCouponTableStructure.tsx +113 -0
  172. package/src/features/billing/stripe-coupon/index.ts +5 -0
  173. package/src/features/billing/stripe-coupon/stripe-coupon-code.module.ts +14 -0
  174. package/src/features/billing/stripe-coupon/stripe-coupon-offer.module.ts +10 -0
  175. package/src/features/billing/stripe-coupon/stripe-coupon.module.ts +18 -0
  176. package/src/features/billing/stripe-coupon/utils/__tests__/coupon-places.spec.ts +19 -0
  177. package/src/features/billing/stripe-coupon/utils/__tests__/to-promotion-code-result.spec.ts +41 -0
  178. package/src/features/billing/stripe-coupon/utils/coupon-places.ts +23 -0
  179. package/src/features/billing/stripe-coupon/utils/format-coupon-discount.ts +36 -0
  180. package/src/features/billing/stripe-coupon/utils/index.ts +3 -0
  181. package/src/features/billing/stripe-coupon/utils/to-promotion-code-result.ts +23 -0
  182. package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +24 -8
  183. package/src/features/billing/stripe-price/stripe-price.module.ts +2 -0
  184. package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +21 -6
  185. package/src/features/billing/stripe-product/stripe-product.module.ts +2 -0
  186. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +60 -19
  187. package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.interface.ts +2 -0
  188. package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -5
  189. package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +21 -2
  190. package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +1 -1
  191. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +1 -1
  192. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +60 -49
  193. package/src/features/billing/stripe-subscription/components/wizards/__tests__/review-card.spec.ts +34 -0
  194. package/src/features/billing/stripe-subscription/components/wizards/review-card.ts +24 -0
  195. package/src/features/billing/stripe-subscription/data/__tests__/stripe-subscription.discount.spec.ts +67 -0
  196. package/src/features/billing/stripe-subscription/data/stripe-subscription.interface.ts +14 -0
  197. package/src/features/billing/stripe-subscription/data/stripe-subscription.service.ts +39 -0
  198. package/src/features/billing/stripe-subscription/data/stripe-subscription.ts +21 -0
  199. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-payment.spec.ts +48 -0
  200. package/src/features/billing/stripe-subscription/hooks/__tests__/wizard-promotion.spec.ts +132 -0
  201. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +138 -12
  202. package/src/features/billing/stripe-subscription/hooks/wizard-payment.ts +30 -0
  203. package/src/features/billing/stripe-subscription/hooks/wizard-promotion.ts +54 -0
  204. package/src/features/blog/components/admin/BlogAdminContainer.tsx +13 -6
  205. package/src/features/blog/components/admin/BlogCategoriesList.tsx +1 -1
  206. package/src/features/blog/components/admin/BlogCoverImageField.tsx +1 -1
  207. package/src/features/blog/components/admin/BlogPostContainer.tsx +0 -1
  208. package/src/features/blog/components/admin/BlogPostEditor.tsx +42 -33
  209. package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +12 -4
  210. package/src/features/blog/components/admin/BlogPostsList.tsx +1 -1
  211. package/src/features/blog/components/public/BlogPostCard.tsx +2 -9
  212. package/src/features/blog/components/public/BlogPostMeta.tsx +2 -2
  213. package/src/features/blog/components/public/BlogPostView.tsx +9 -3
  214. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +2 -0
  215. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +17 -11
  216. package/src/features/blog/server/fetchers.ts +57 -8
  217. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +12 -3
  218. package/src/features/rbac/contexts/RbacContext.tsx +4 -7
  219. package/src/features/rbac/contexts/__tests__/RbacContext.spec.tsx +42 -0
  220. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +4 -3
  221. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +3 -18
  222. package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +17 -0
  223. package/src/features/tokenusage/tokenusage-admin.module.ts +4 -3
  224. package/src/features/tokenusage/tokenusage.modules.ts +12 -2
  225. package/src/features/user/contexts/CurrentUserContext.tsx +25 -0
  226. package/src/features/user/contexts/UserContext.tsx +18 -3
  227. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +121 -2
  228. package/src/features/user/contexts/__tests__/UserContext.spec.tsx +95 -0
  229. package/dist/chunk-3NLAXVIN.js.map +0 -1
  230. package/dist/chunk-EUKTBOI2.mjs.map +0 -1
  231. package/dist/chunk-NFBOSPNJ.js.map +0 -1
  232. package/dist/chunk-WC63PJSC.mjs.map +0 -1
  233. /package/dist/{BlockNoteEditor-YQL2SGFR.mjs.map → BlockNoteEditor-NEYG5L6W.mjs.map} +0 -0
  234. /package/dist/{BlockNoteViewer-AEJKJ27Q.mjs.map → BlockNoteViewer-3OUT5UFY.mjs.map} +0 -0
  235. /package/dist/{chunk-XCHSX3VC.mjs.map → chunk-BTPUYUQ3.mjs.map} +0 -0
  236. /package/dist/{chunk-6DC2WVVB.mjs.map → chunk-KQHBONL4.mjs.map} +0 -0
  237. /package/dist/{chunk-QMYQRPQC.mjs.map → chunk-Y7RR57EW.mjs.map} +0 -0
@@ -12,7 +12,7 @@ import "../../hooks/useBlogCategoryTableStructure";
12
12
  import { BlogCategoryEditor } from "./BlogCategoryEditor";
13
13
 
14
14
  type BlogCategoriesListProps = {
15
- /** Pass when the list is the page's own content inside a `RoundPageContainer fullWidth`. */
15
+ /** Pass only when the list is the page's whole content inside a `RoundPageContainer fullWidth`; omit it in a tab so the list keeps its bordered card. */
16
16
  fullWidth?: boolean;
17
17
  };
18
18
 
@@ -105,7 +105,7 @@ export function BlogCoverImageField({
105
105
  alt={alt ?? label}
106
106
  width={320}
107
107
  height={180}
108
- className="h-auto max-h-48 w-auto rounded-md object-cover"
108
+ className="h-auto max-h-48 w-auto max-w-full rounded-md object-cover"
109
109
  unoptimized={isRemoteImageSrc(previewUrl ? undefined : currentUrl)}
110
110
  />
111
111
  ) : (
@@ -89,7 +89,6 @@ function BlogPostShell() {
89
89
  <RoundPageContainer
90
90
  module={Modules.BlogPost}
91
91
  id={post.id}
92
- fullWidth
93
92
  forceHeader
94
93
  tabs={translations.map((translation) => ({
95
94
  sectionKey: translation.language,
@@ -149,7 +149,7 @@ export function BlogPostEditor({ post, trigger, onSuccess }: BlogPostEditorProps
149
149
  onNavigate={(url) => router.push(url)}
150
150
  trigger={trigger}
151
151
  >
152
- <div className="flex w-full flex-col gap-y-4">
152
+ <div className="flex h-full min-h-0 w-full flex-col gap-y-4">
153
153
  {!post && (
154
154
  <FormSelect
155
155
  form={form}
@@ -160,38 +160,45 @@ export function BlogPostEditor({ post, trigger, onSuccess }: BlogPostEditorProps
160
160
  />
161
161
  )}
162
162
  <FormInput form={form} id="title" name={t("blog.fields.title")} isRequired />
163
- {post && (
164
- <FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
165
- )}
166
- <FormTextarea
167
- form={form}
168
- id="summary"
169
- name={t("blog.fields.summary")}
170
- description={t("blog.fields.summaryHelp")}
171
- />
172
- <BlogCoverImageField
173
- form={form}
174
- id="coverImage"
175
- label={t("blog.fields.coverImage")}
176
- keyPrefix={BLOG_POST_UPLOAD_PREFIX}
177
- entityId={form.getValues("id")}
178
- currentUrl={post?.coverImage}
179
- alt={original?.coverImageAlt}
180
- selection={cover}
181
- onSelectionChange={setCover}
182
- />
183
- <FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
184
- <EntityMultiSelector<BlogCategoryInterface>
185
- id="categories"
186
- form={form}
187
- label={t("blog.fields.categories")}
188
- retriever={(params) => BlogCategoryService.findMany(params)}
189
- module={Modules.BlogCategory}
190
- getLabel={blogCategoryAdminName}
191
- // A post's included categories carry no translations, so the stored chip
192
- // names come from the category list.
193
- getFormValueLabel={(value: { id: string; name?: string }) => value.name || categoryName(value.id)}
194
- />
163
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
164
+ <div className="flex flex-col gap-y-4">
165
+ {post && (
166
+ <FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
167
+ )}
168
+ <EntityMultiSelector<BlogCategoryInterface>
169
+ id="categories"
170
+ form={form}
171
+ label={t("blog.fields.categories")}
172
+ retriever={(params) => BlogCategoryService.findMany(params)}
173
+ module={Modules.BlogCategory}
174
+ getLabel={blogCategoryAdminName}
175
+ // A post's included categories carry no translations, so the stored chip
176
+ // names come from the category list.
177
+ getFormValueLabel={(value: { id: string; name?: string }) => value.name || categoryName(value.id)}
178
+ />
179
+ <FormTextarea
180
+ form={form}
181
+ id="summary"
182
+ name={t("blog.fields.summary")}
183
+ description={t("blog.fields.summaryHelp")}
184
+ className="min-h-24"
185
+ />
186
+ </div>
187
+ <div className="flex flex-col gap-y-4">
188
+ <BlogCoverImageField
189
+ form={form}
190
+ id="coverImage"
191
+ label={t("blog.fields.coverImage")}
192
+ keyPrefix={BLOG_POST_UPLOAD_PREFIX}
193
+ entityId={form.getValues("id")}
194
+ currentUrl={post?.coverImage}
195
+ alt={original?.coverImageAlt}
196
+ selection={cover}
197
+ onSelectionChange={setCover}
198
+ />
199
+ <FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
200
+ </div>
201
+ </div>
195
202
  <FormBlockNote
196
203
  uploadKeyPrefix={BLOG_POST_UPLOAD_PREFIX}
197
204
  form={form}
@@ -199,6 +206,8 @@ export function BlogPostEditor({ post, trigger, onSuccess }: BlogPostEditorProps
199
206
  name={t("blog.fields.body")}
200
207
  type="blog-post"
201
208
  isRequired
209
+ stretch
210
+ className="min-h-96"
202
211
  onEmptyChange={setIsBodyEmptyState}
203
212
  />
204
213
  </div>
@@ -90,11 +90,17 @@ export function BlogPostTranslationEditor({ translation, trigger, onSuccess }: B
90
90
  onSuccess={onSuccess ?? (() => {})}
91
91
  trigger={trigger}
92
92
  >
93
- <div className="flex w-full flex-col gap-y-4">
93
+ <div className="flex h-full min-h-0 w-full flex-col gap-y-4">
94
94
  <FormInput form={form} id="title" name={t("blog.fields.title")} isRequired />
95
- <FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
96
- <FormTextarea form={form} id="summary" name={t("blog.fields.summary")} />
97
- <FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
95
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
96
+ <div className="flex flex-col gap-y-4">
97
+ <FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
98
+ <FormTextarea form={form} id="summary" name={t("blog.fields.summary")} className="min-h-24" />
99
+ </div>
100
+ <div className="flex flex-col gap-y-4">
101
+ <FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
102
+ </div>
103
+ </div>
98
104
  <FormBlockNote
99
105
  uploadKeyPrefix={BLOG_POST_UPLOAD_PREFIX}
100
106
  form={form}
@@ -102,6 +108,8 @@ export function BlogPostTranslationEditor({ translation, trigger, onSuccess }: B
102
108
  name={t("blog.fields.body")}
103
109
  type="blog-post"
104
110
  isRequired
111
+ stretch
112
+ className="min-h-96"
105
113
  onEmptyChange={setIsBodyEmptyState}
106
114
  />
107
115
  </div>
@@ -12,7 +12,7 @@ import "../../hooks/useBlogPostTableStructure";
12
12
  import { BlogPostEditor } from "./BlogPostEditor";
13
13
 
14
14
  type BlogPostsListProps = {
15
- /** Pass when the list is the page's own content inside a `RoundPageContainer fullWidth`. */
15
+ /** Pass only when the list is the page's whole content inside a `RoundPageContainer fullWidth`; omit it in a tab so the list keeps its bordered card. */
16
16
  fullWidth?: boolean;
17
17
  };
18
18
 
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
 
3
- import { useFormatter } from "next-intl";
4
3
  import Image from "next/image";
5
4
  import { MicroLabel, SectionHeader } from "../../../../components/typography";
6
5
  import { Link } from "../../../../shadcnui";
@@ -59,16 +58,10 @@ export function BlogPostCard(props: {
59
58
  headingLevel?: "h2" | "h3";
60
59
  }) {
61
60
  const { post, categories, basePath, variant = "card", priority, headingLevel = "h2" } = props;
62
- const format = useFormatter();
63
61
  const category = resolvePostCategory(post, categories);
64
62
  const isProminent = variant === "hero" || variant === "large";
65
63
 
66
- const eyebrow =
67
- variant === "row" && post.publishedAt
68
- ? [category?.name, format.dateTime(new Date(post.publishedAt), { day: "numeric", month: "short" })]
69
- .filter(Boolean)
70
- .join(" · ")
71
- : category?.name;
64
+ const eyebrow = category?.name;
72
65
 
73
66
  const text = (
74
67
  <div className="flex min-w-0 flex-col">
@@ -79,7 +72,7 @@ export function BlogPostCard(props: {
79
72
  </Link>
80
73
  </SectionHeader>
81
74
  {post.summary ? <p className="text-muted-foreground mt-2 text-sm leading-relaxed">{post.summary}</p> : null}
82
- <BlogPostMeta post={post} showAuthor={isProminent} showDate={isProminent} className="mt-3" />
75
+ <BlogPostMeta post={post} showAuthor={isProminent} className="mt-3" />
83
76
  </div>
84
77
  );
85
78
 
@@ -7,8 +7,8 @@ import type { PublicBlogPost } from "../../types/blog-public.types";
7
7
  const ONE_DAY_MS = 24 * 60 * 60 * 1000;
8
8
 
9
9
  /**
10
- * "author · date · updated · reading time". Each part is opt-in so cards show
11
- * only the reading time while the hero and the post page show more.
10
+ * "author · date · updated · reading time". Each part except reading time is
11
+ * opt-in. The public blog shows no dates, so its callers pass only showAuthor.
12
12
  */
13
13
  export function BlogPostMeta(props: {
14
14
  post: PublicBlogPost;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { ArrowLeftIcon } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
+ import type { ReactNode } from "react";
5
6
  import { BlockNoteEditorContainer } from "../../../../components/editors/BlockNoteEditorContainer";
6
7
  import { ContentTitle } from "../../../../components/navigations/ContentTitle";
7
8
  import { SectionHeader } from "../../../../components/typography";
@@ -14,14 +15,19 @@ import { BlogPostMeta } from "./BlogPostMeta";
14
15
  /**
15
16
  * Public post page: back link, category, title, summary, meta, cover, body,
16
17
  * then up to three posts from the same category.
18
+ *
19
+ * `body` replaces the client-only editor with server-rendered content (the app
20
+ * passes BlockNoteServerContent from `@carlonicora/nextjs-jsonapi/blocknote/server`),
21
+ * so the post text is in the HTML crawlers read.
17
22
  */
18
23
  export function BlogPostView(props: {
19
24
  post: PublicBlogPost;
20
25
  categories: PublicBlogCategory[];
21
26
  related: PublicBlogPost[];
22
27
  basePath: string;
28
+ body?: ReactNode;
23
29
  }) {
24
- const { post, categories, related, basePath } = props;
30
+ const { post, categories, related, basePath, body } = props;
25
31
  const t = useTranslations();
26
32
  const category = resolvePostCategory(post, categories);
27
33
  const more = related.slice(0, 3);
@@ -36,7 +42,7 @@ export function BlogPostView(props: {
36
42
  <header className="mt-8">
37
43
  <ContentTitle type={category?.name} element={post.title} headingLevel={1} className="mb-0" />
38
44
  {post.summary ? <p className="text-muted-foreground mt-3 text-sm leading-relaxed">{post.summary}</p> : null}
39
- <BlogPostMeta post={post} showAuthor showDate showUpdated className="mt-4" />
45
+ <BlogPostMeta post={post} showAuthor className="mt-4" />
40
46
  </header>
41
47
  {post.coverImage ? (
42
48
  <BlogPostCover
@@ -48,7 +54,7 @@ export function BlogPostView(props: {
48
54
  />
49
55
  ) : null}
50
56
  <div className="mt-8">
51
- <BlockNoteEditorContainer id={post.id} type="blog-post" initialContent={post.body} />
57
+ {body ?? <BlockNoteEditorContainer id={post.id} type="blog-post" initialContent={post.body} />}
52
58
  </div>
53
59
  </div>
54
60
  {more.length > 0 ? (
@@ -100,6 +100,8 @@ describe("BlogList", () => {
100
100
  expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
101
101
  expect(screen.getByRole("heading", { level: 1, name: "blog.public.title" })).toBeInTheDocument();
102
102
  expect(screen.getByText("blog.public.eyebrow")).toBeInTheDocument();
103
+ // The public blog shows no dates in any layout.
104
+ expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
103
105
  });
104
106
 
105
107
  it("active category chip is marked and links back to all", () => {
@@ -100,24 +100,30 @@ describe("BlogPostView", () => {
100
100
  expect(screen.getByRole("link", { name: "Three-way scenes" })).toHaveAttribute("href", "/blog/three-way");
101
101
  });
102
102
 
103
- it("shows updated only when the post changed more than a day after publishing", () => {
104
- const { rerender } = render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
105
- expect(screen.getByText(/blog\.public\.updated 2026-10-15/)).toBeInTheDocument();
103
+ it("shows neither the publish date nor the updated date", () => {
104
+ render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
105
+ expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
106
+ expect(screen.queryByText(/2026-10-15/)).not.toBeInTheDocument();
107
+ expect(screen.queryByText(/blog\.public\.updated/)).not.toBeInTheDocument();
108
+ });
109
+
110
+ it("omits the more-in section when there are no related posts", () => {
111
+ render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
112
+ expect(screen.queryByText(/blog\.public\.moreIn/)).not.toBeInTheDocument();
113
+ });
106
114
 
107
- rerender(
115
+ it("renders the body prop in place of the editor when given", () => {
116
+ render(
108
117
  <BlogPostView
109
- post={{ ...main, updatedAt: "2026-10-12T15:00:00.000Z" }}
118
+ post={main}
110
119
  categories={cats}
111
120
  related={[]}
112
121
  basePath="/blog"
122
+ body={<div data-testid="server-body">Server-rendered text</div>}
113
123
  />,
114
124
  );
115
- expect(screen.queryByText(/blog\.public\.updated/)).not.toBeInTheDocument();
116
- });
117
-
118
- it("omits the more-in section when there are no related posts", () => {
119
- render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
120
- expect(screen.queryByText(/blog\.public\.moreIn/)).not.toBeInTheDocument();
125
+ expect(screen.getByTestId("server-body")).toHaveTextContent("Server-rendered text");
126
+ expect(screen.queryByTestId("post-body")).not.toBeInTheDocument();
121
127
  });
122
128
 
123
129
  it("caps related posts at three", () => {
@@ -28,21 +28,20 @@ export async function fetchPublicBlogPosts(params: {
28
28
  language: string;
29
29
  categorySlug?: string;
30
30
  search?: string;
31
- page: number;
31
+ /** 1-based list page; ignored with `all`. */
32
+ page?: number;
33
+ /** Every matching post, read in API-sized pages (a sitemap). `hasMore` is then false. */
34
+ all?: boolean;
32
35
  }): Promise<PublicBlogList> {
36
+ if (params.all) return { posts: await fetchEveryPublicBlogPost(params), hasMore: false };
37
+
33
38
  // Matches the API cap (120 = 10 pages of 12); a larger ?page= is served as the last allowed one.
34
39
  const page = Math.min(10, Math.max(1, Math.floor(params.page || 1)));
35
40
  const size = BLOG_PAGE_SIZE * page;
36
41
 
37
- const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint() });
38
- endpoint.addAdditionalParam("language", params.language);
39
- if (params.categorySlug) endpoint.addAdditionalParam("category", encodeURIComponent(params.categorySlug));
40
- if (params.search) endpoint.addAdditionalParam("search", encodeURIComponent(params.search));
41
- endpoint.addAdditionalParam("page[size]", String(size));
42
-
43
42
  const res = await ServerJsonApiGet({
44
43
  classKey: { ...Modules.BlogPost, cache: "minutes" },
45
- endpoint: endpoint.generate(),
44
+ endpoint: publicPostsListEndpoint({ ...params, size }),
46
45
  language: params.language,
47
46
  });
48
47
  if (!res.ok) return { posts: [], hasMore: false };
@@ -54,6 +53,56 @@ export async function fetchPublicBlogPosts(params: {
54
53
  return { posts, hasMore };
55
54
  }
56
55
 
56
+ function publicPostsListEndpoint(params: {
57
+ language: string;
58
+ categorySlug?: string;
59
+ search?: string;
60
+ size: number;
61
+ offset?: number;
62
+ }): string {
63
+ const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint() });
64
+ endpoint.addAdditionalParam("language", params.language);
65
+ if (params.categorySlug) endpoint.addAdditionalParam("category", encodeURIComponent(params.categorySlug));
66
+ if (params.search) endpoint.addAdditionalParam("search", encodeURIComponent(params.search));
67
+ endpoint.addAdditionalParam("page[size]", String(params.size));
68
+ if (params.offset) endpoint.addAdditionalParam("page[offset]", String(params.offset));
69
+ return endpoint.generate();
70
+ }
71
+
72
+ // The API serves at most PUBLIC_MAX_PAGE_SIZE posts per request, so "all" walks
73
+ // page[offset] until a short page comes back. MAX_ALL_PAGES only stops a runaway loop.
74
+ const PUBLIC_MAX_PAGE_SIZE = 120;
75
+ const MAX_ALL_PAGES = 100;
76
+
77
+ async function fetchEveryPublicBlogPost(params: {
78
+ language: string;
79
+ categorySlug?: string;
80
+ search?: string;
81
+ }): Promise<PublicBlogPost[]> {
82
+ const posts: PublicBlogPost[] = [];
83
+
84
+ // In sequence: a failing cached call throws, and parallel failing cached calls
85
+ // never close the response.
86
+ for (let index = 0; index < MAX_ALL_PAGES; index++) {
87
+ const res = await ServerJsonApiGet({
88
+ classKey: { ...Modules.BlogPost, cache: "minutes" },
89
+ endpoint: publicPostsListEndpoint({
90
+ ...params,
91
+ size: PUBLIC_MAX_PAGE_SIZE,
92
+ offset: index * PUBLIC_MAX_PAGE_SIZE,
93
+ }),
94
+ language: params.language,
95
+ });
96
+ if (!res.ok) break;
97
+
98
+ const batch = asArray<BlogPostInterface>(res.data);
99
+ posts.push(...batch.map(toPublicBlogPost));
100
+ if (batch.length < PUBLIC_MAX_PAGE_SIZE) break;
101
+ }
102
+
103
+ return posts;
104
+ }
105
+
57
106
  export async function fetchPublicBlogPost(params: { language: string; slug: string }): Promise<PublicBlogPost | null> {
58
107
  const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint(), id: encodeURIComponent(params.slug) });
59
108
  endpoint.addAdditionalParam("language", params.language);
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { RoundPageContainer } from "../../../../components";
5
+ import { SharedProvider } from "../../../../contexts/SharedContext";
4
6
  import { Modules } from "../../../../core";
5
- import { AdministrationProvider } from "../../../administration/contexts/AdministrationContext";
7
+ import { BreadcrumbItemData } from "../../../../interfaces";
6
8
  import { EmailAutomationsList } from "../lists/EmailAutomationsList";
7
9
 
8
10
  /**
@@ -15,13 +17,20 @@ import { EmailAutomationsList } from "../lists/EmailAutomationsList";
15
17
  *
16
18
  * No `forceHeader`: ContentListTable draws its own header row with the title
17
19
  * and the create action, exactly as the company and user list pages do.
20
+ *
21
+ * Publishes its own breadcrumb, as CompanyProvider does for the companies list:
22
+ * the list root comes first and has no href because it is the current page.
18
23
  */
19
24
  export function EmailAutomationsListContainer() {
25
+ const t = useTranslations();
26
+
27
+ const breadcrumbs: BreadcrumbItemData[] = [{ name: t("emailAutomations.admin.title") }];
28
+
20
29
  return (
21
- <AdministrationProvider>
30
+ <SharedProvider value={{ breadcrumbs, title: { type: t("emailAutomations.admin.title") } }}>
22
31
  <RoundPageContainer module={Modules.EmailAutomation} fullWidth>
23
32
  <EmailAutomationsList fullWidth />
24
33
  </RoundPageContainer>
25
- </AdministrationProvider>
34
+ </SharedProvider>
26
35
  );
27
36
  }
@@ -4,8 +4,6 @@ import { DownloadIcon, Loader2Icon } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
6
6
  import { SharedProvider } from "../../../contexts";
7
- import { Modules } from "../../../core";
8
- import { usePageUrlGenerator } from "../../../hooks";
9
7
  import { BreadcrumbItemData } from "../../../interfaces";
10
8
  import { Button } from "../../../shadcnui";
11
9
  import { showError, showToast } from "../../../utils/toast";
@@ -70,7 +68,6 @@ export const RbacProvider = ({
70
68
  moduleNames,
71
69
  outputPath = DEFAULT_OUTPUT_PATH,
72
70
  }: RbacProviderProps) => {
73
- const generateUrl = usePageUrlGenerator();
74
71
  const t = useTranslations();
75
72
 
76
73
  const [matrix, setMatrix] = useState<RbacMatrix | null>(null);
@@ -149,10 +146,10 @@ export const RbacProvider = ({
149
146
 
150
147
  const breadcrumb = (): BreadcrumbItemData[] => {
151
148
  const response: BreadcrumbItemData[] = [];
152
- response.push({
153
- name: t(`entities.rbac`, { count: 2 }),
154
- href: generateUrl({ page: Modules.RbacMatrix }),
155
- });
149
+ // Unlinked: the provider only serves the RBAC page, so the root is always
150
+ // the current page. The dev matrix module has no pageUrl either, so a link
151
+ // built from it pointed at "undefined".
152
+ response.push({ name: t(`entities.rbac`, { count: 2 }) });
156
153
  return response;
157
154
  };
158
155
 
@@ -0,0 +1,42 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { useSharedContext } from "../../../../contexts/SharedContext";
4
+ import { RbacService } from "../../data/RbacService";
5
+ import { RbacProvider } from "../RbacContext";
6
+
7
+ vi.mock("../../data/RbacService", () => ({
8
+ RbacService: {
9
+ fetchMatrix: vi.fn(async () => ({ matrix: {}, modulePaths: {} })),
10
+ saveMatrix: vi.fn(async () => undefined),
11
+ },
12
+ }));
13
+
14
+ // The provider refetches whenever `t` changes identity, and the global next-intl
15
+ // mock in vitest.setup.ts builds a new `t` per call. A stable one keeps the fetch
16
+ // to a single run. Labels still render as their own key.
17
+ const { stableT } = vi.hoisted(() => ({ stableT: (key: string) => key }));
18
+ vi.mock("next-intl", () => ({
19
+ useTranslations: () => stableT,
20
+ useLocale: () => "en",
21
+ useNow: () => new Date(),
22
+ useTimeZone: () => "UTC",
23
+ useFormatter: () => ({ dateTime: vi.fn(), number: vi.fn(), relativeTime: vi.fn() }),
24
+ }));
25
+
26
+ function BreadcrumbProbe() {
27
+ const { breadcrumbs } = useSharedContext();
28
+ return <span data-testid="breadcrumbs">{JSON.stringify(breadcrumbs)}</span>;
29
+ }
30
+
31
+ describe("RbacContext", () => {
32
+ it("publishes the page as an unlinked breadcrumb, since it is the current page", async () => {
33
+ render(
34
+ <RbacProvider>
35
+ <BreadcrumbProbe />
36
+ </RbacProvider>,
37
+ );
38
+
39
+ await waitFor(() => expect(RbacService.fetchMatrix).toHaveBeenCalled());
40
+ expect(screen.getByTestId("breadcrumbs")).toHaveTextContent(JSON.stringify([{ name: "entities.rbac" }]));
41
+ });
42
+ });
@@ -4,6 +4,7 @@ import { cn } from "../../../lib/utils";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { useMemo } from "react";
6
6
  import { RoundPageContainer } from "../../../components";
7
+ import { Modules } from "../../../core";
7
8
  import {
8
9
  Card,
9
10
  CardAction,
@@ -75,7 +76,7 @@ export function TokenUsageAdminContainer() {
75
76
 
76
77
  if (error) {
77
78
  return (
78
- <RoundPageContainer fullWidth forceHeader>
79
+ <RoundPageContainer module={Modules.TokenUsageAdminSummary} fullWidth forceHeader>
79
80
  <div className="p-4">
80
81
  <Card>
81
82
  <CardContent>
@@ -89,12 +90,12 @@ export function TokenUsageAdminContainer() {
89
90
 
90
91
  // Loading renders nothing in the body: the title bar (with the filter bar) is
91
92
  // already mounted, so a spinner would only make the controls jump on arrival.
92
- if (isLoading) return <RoundPageContainer fullWidth forceHeader />;
93
+ if (isLoading) return <RoundPageContainer module={Modules.TokenUsageAdminSummary} fullWidth forceHeader />;
93
94
 
94
95
  const emptyLabel = t("token_usage.admin.no_data");
95
96
 
96
97
  return (
97
- <RoundPageContainer fullWidth forceHeader>
98
+ <RoundPageContainer module={Modules.TokenUsageAdminSummary} fullWidth forceHeader>
98
99
  <div className="flex w-full flex-col gap-4 p-4">
99
100
  <TokenUsageAdminTiles summary={summary} metric={filters.metric} singleCustomerMode={hidePlatform} />
100
101
 
@@ -3,7 +3,6 @@
3
3
  import { useTranslations } from "next-intl";
4
4
  import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
5
5
  import { SharedProvider } from "../../../contexts";
6
- import { usePageUrlGenerator } from "../../../hooks";
7
6
  import { BreadcrumbItemData } from "../../../interfaces";
8
7
  import { TokenUsageAdminFilterBar } from "../components/TokenUsageAdminFilterBar";
9
8
  import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
@@ -12,13 +11,6 @@ import type { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-
12
11
  import type { Granularity, Metric, StackBy } from "../data/tokenusage-admin.types";
13
12
  import { TokenUsageAdminService } from "../data/TokenUsageAdminService";
14
13
 
15
- /**
16
- * Default route the breadcrumb links back to. A host app that mounts the page
17
- * elsewhere overrides it with the `pageUrl` prop — the constant stays here
18
- * because the breadcrumb is built by this provider, not by the app.
19
- */
20
- const TOKEN_USAGE_ADMIN_PAGE_URL = "/administration/token-usage";
21
-
22
14
  /** Rows kept per ranked panel before the repository folds the tail into "other". */
23
15
  const DEFAULT_TOP_N = 10;
24
16
 
@@ -72,8 +64,6 @@ type TokenUsageAdminProviderProps = {
72
64
  initialTo?: string;
73
65
  /** Rows per ranked panel. Defaults to 10. */
74
66
  topN?: number;
75
- /** Route the breadcrumb links back to. */
76
- pageUrl?: string;
77
67
  };
78
68
 
79
69
  /**
@@ -91,10 +81,8 @@ export const TokenUsageAdminProvider = ({
91
81
  initialFrom,
92
82
  initialTo,
93
83
  topN = DEFAULT_TOP_N,
94
- pageUrl = TOKEN_USAGE_ADMIN_PAGE_URL,
95
84
  }: TokenUsageAdminProviderProps) => {
96
85
  const t = useTranslations();
97
- const generateUrl = usePageUrlGenerator();
98
86
 
99
87
  const [filters, setFilterState] = useState<TokenUsageAdminFilterState>(() => {
100
88
  const range = defaultRange();
@@ -185,12 +173,9 @@ export const TokenUsageAdminProvider = ({
185
173
  [byCompany],
186
174
  );
187
175
 
188
- const breadcrumb = (): BreadcrumbItemData[] => [
189
- {
190
- name: t("token_usage.admin.title"),
191
- href: generateUrl({ page: pageUrl }),
192
- },
193
- ];
176
+ // Unlinked: this provider only serves the token-usage page, so the root is
177
+ // always the current page.
178
+ const breadcrumb = (): BreadcrumbItemData[] => [{ name: t("token_usage.admin.title") }];
194
179
 
195
180
  const title = () => ({
196
181
  type: t("token_usage.admin.title"),
@@ -1,5 +1,6 @@
1
1
  import { render, screen, waitFor } from "@testing-library/react";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useSharedContext } from "../../../../contexts/SharedContext";
3
4
  import { TokenUsageAdminService } from "../../data/TokenUsageAdminService";
4
5
  import { TokenUsageAdminProvider, useTokenUsageAdmin } from "../TokenUsageAdminContext";
5
6
 
@@ -16,6 +17,11 @@ function Probe() {
16
17
  return <span data-testid="mode">{String(ctx.singleCustomerMode)}</span>;
17
18
  }
18
19
 
20
+ function BreadcrumbProbe() {
21
+ const { breadcrumbs } = useSharedContext();
22
+ return <span data-testid="breadcrumbs">{JSON.stringify(breadcrumbs)}</span>;
23
+ }
24
+
19
25
  describe("TokenUsageAdminContext", () => {
20
26
  // The mocked service is module-level, so its call counts survive across tests.
21
27
  // Without this reset the per-test `toHaveBeenCalledTimes` assertions would
@@ -77,4 +83,15 @@ describe("TokenUsageAdminContext", () => {
77
83
  expect(calls).toHaveLength(3);
78
84
  expect(screen.getByTestId("mode")).toHaveTextContent("true");
79
85
  });
86
+
87
+ it("publishes the page as an unlinked breadcrumb, since it is the current page", async () => {
88
+ render(
89
+ <TokenUsageAdminProvider>
90
+ <BreadcrumbProbe />
91
+ </TokenUsageAdminProvider>,
92
+ );
93
+
94
+ await waitFor(() => expect(TokenUsageAdminService.getSummary).toHaveBeenCalled());
95
+ expect(screen.getByTestId("breadcrumbs")).toHaveTextContent(JSON.stringify([{ name: "token_usage.admin.title" }]));
96
+ });
80
97
  });