@actuate-media/cms-admin 0.14.1 → 0.16.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 (304) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +148 -2
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/posts-badges.test.d.ts +2 -0
  5. package/dist/__tests__/components/posts-badges.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/posts-badges.test.js +68 -0
  7. package/dist/__tests__/components/posts-badges.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-posts.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-posts.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-posts.test.js +162 -0
  11. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -0
  12. package/dist/__tests__/lib/page-editor-service.test.d.ts +2 -0
  13. package/dist/__tests__/lib/page-editor-service.test.d.ts.map +1 -0
  14. package/dist/__tests__/lib/page-editor-service.test.js +137 -0
  15. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -0
  16. package/dist/__tests__/lib/pages-service.test.d.ts +2 -0
  17. package/dist/__tests__/lib/pages-service.test.d.ts.map +1 -0
  18. package/dist/__tests__/lib/pages-service.test.js +140 -0
  19. package/dist/__tests__/lib/pages-service.test.js.map +1 -0
  20. package/dist/__tests__/lib/post-editor-service.test.d.ts +2 -0
  21. package/dist/__tests__/lib/post-editor-service.test.d.ts.map +1 -0
  22. package/dist/__tests__/lib/post-editor-service.test.js +185 -0
  23. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -0
  24. package/dist/__tests__/lib/posts-service.test.d.ts +2 -0
  25. package/dist/__tests__/lib/posts-service.test.d.ts.map +1 -0
  26. package/dist/__tests__/lib/posts-service.test.js +244 -0
  27. package/dist/__tests__/lib/posts-service.test.js.map +1 -0
  28. package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
  29. package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
  30. package/dist/__tests__/views/posts-list-view.test.js +98 -0
  31. package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
  32. package/dist/actuate-admin.css +1 -1
  33. package/dist/components/pages/tag-badges.d.ts +21 -0
  34. package/dist/components/pages/tag-badges.d.ts.map +1 -0
  35. package/dist/components/pages/tag-badges.js +56 -0
  36. package/dist/components/pages/tag-badges.js.map +1 -0
  37. package/dist/components/posts/badges.d.ts +25 -0
  38. package/dist/components/posts/badges.d.ts.map +1 -0
  39. package/dist/components/posts/badges.js +123 -0
  40. package/dist/components/posts/badges.js.map +1 -0
  41. package/dist/components/posts/icons.d.ts +12 -0
  42. package/dist/components/posts/icons.d.ts.map +1 -0
  43. package/dist/components/posts/icons.js +26 -0
  44. package/dist/components/posts/icons.js.map +1 -0
  45. package/dist/layout/Header.d.ts +14 -1
  46. package/dist/layout/Header.d.ts.map +1 -1
  47. package/dist/layout/Header.js +10 -3
  48. package/dist/layout/Header.js.map +1 -1
  49. package/dist/layout/Layout.d.ts.map +1 -1
  50. package/dist/layout/Layout.js +78 -2
  51. package/dist/layout/Layout.js.map +1 -1
  52. package/dist/layout/Sidebar.d.ts +16 -0
  53. package/dist/layout/Sidebar.d.ts.map +1 -1
  54. package/dist/layout/Sidebar.js +176 -49
  55. package/dist/layout/Sidebar.js.map +1 -1
  56. package/dist/lib/page-editor-service.d.ts +45 -0
  57. package/dist/lib/page-editor-service.d.ts.map +1 -0
  58. package/dist/lib/page-editor-service.js +153 -0
  59. package/dist/lib/page-editor-service.js.map +1 -0
  60. package/dist/lib/pages-service.d.ts +143 -0
  61. package/dist/lib/pages-service.d.ts.map +1 -0
  62. package/dist/lib/pages-service.js +444 -0
  63. package/dist/lib/pages-service.js.map +1 -0
  64. package/dist/lib/post-editor-service.d.ts +57 -0
  65. package/dist/lib/post-editor-service.d.ts.map +1 -0
  66. package/dist/lib/post-editor-service.js +234 -0
  67. package/dist/lib/post-editor-service.js.map +1 -0
  68. package/dist/lib/posts-service.d.ts +73 -0
  69. package/dist/lib/posts-service.d.ts.map +1 -0
  70. package/dist/lib/posts-service.js +245 -0
  71. package/dist/lib/posts-service.js.map +1 -0
  72. package/dist/lib/preview-link.d.ts +32 -0
  73. package/dist/lib/preview-link.d.ts.map +1 -0
  74. package/dist/lib/preview-link.js +40 -0
  75. package/dist/lib/preview-link.js.map +1 -0
  76. package/dist/router/index.d.ts +1 -0
  77. package/dist/router/index.d.ts.map +1 -1
  78. package/dist/router/index.js +9 -1
  79. package/dist/router/index.js.map +1 -1
  80. package/dist/views/Dashboard.d.ts.map +1 -1
  81. package/dist/views/Dashboard.js +147 -62
  82. package/dist/views/Dashboard.js.map +1 -1
  83. package/dist/views/Pages/PageTagManager.d.ts +14 -0
  84. package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
  85. package/dist/views/Pages/PageTagManager.js +145 -0
  86. package/dist/views/Pages/PageTagManager.js.map +1 -0
  87. package/dist/views/Pages/PagesListView.d.ts +18 -0
  88. package/dist/views/Pages/PagesListView.d.ts.map +1 -0
  89. package/dist/views/Pages/PagesListView.js +287 -0
  90. package/dist/views/Pages/PagesListView.js.map +1 -0
  91. package/dist/views/Posts/NewPostPicker.d.ts +16 -0
  92. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
  93. package/dist/views/Posts/NewPostPicker.js +59 -0
  94. package/dist/views/Posts/NewPostPicker.js.map +1 -0
  95. package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
  96. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
  97. package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
  98. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
  99. package/dist/views/Posts/PostTypesView.d.ts +26 -0
  100. package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
  101. package/dist/views/Posts/PostTypesView.js +52 -0
  102. package/dist/views/Posts/PostTypesView.js.map +1 -0
  103. package/dist/views/Posts/PostsListView.d.ts +25 -0
  104. package/dist/views/Posts/PostsListView.d.ts.map +1 -0
  105. package/dist/views/Posts/PostsListView.js +239 -0
  106. package/dist/views/Posts/PostsListView.js.map +1 -0
  107. package/dist/views/Posts/post-type-presets.d.ts +32 -0
  108. package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
  109. package/dist/views/Posts/post-type-presets.js +72 -0
  110. package/dist/views/Posts/post-type-presets.js.map +1 -0
  111. package/dist/views/page-builder/Widgets.d.ts +13 -0
  112. package/dist/views/page-builder/Widgets.d.ts.map +1 -0
  113. package/dist/views/page-builder/Widgets.js +15 -0
  114. package/dist/views/page-builder/Widgets.js.map +1 -0
  115. package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
  116. package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
  117. package/dist/views/page-editor/AddSectionModal.js +22 -0
  118. package/dist/views/page-editor/AddSectionModal.js.map +1 -0
  119. package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
  120. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
  121. package/dist/views/page-editor/EditorCanvas.js +64 -0
  122. package/dist/views/page-editor/EditorCanvas.js.map +1 -0
  123. package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
  124. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
  125. package/dist/views/page-editor/EditorTopBar.js +42 -0
  126. package/dist/views/page-editor/EditorTopBar.js.map +1 -0
  127. package/dist/views/page-editor/PagePreview.d.ts +13 -0
  128. package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
  129. package/dist/views/page-editor/PagePreview.js +37 -0
  130. package/dist/views/page-editor/PagePreview.js.map +1 -0
  131. package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
  132. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
  133. package/dist/views/page-editor/PageSectionEditor.js +272 -0
  134. package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
  135. package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
  136. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
  137. package/dist/views/page-editor/PageSettingsModal.js +52 -0
  138. package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
  139. package/dist/views/page-editor/SectionInspector.d.ts +17 -0
  140. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
  141. package/dist/views/page-editor/SectionInspector.js +98 -0
  142. package/dist/views/page-editor/SectionInspector.js.map +1 -0
  143. package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
  144. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
  145. package/dist/views/page-editor/SectionsPanel.js +43 -0
  146. package/dist/views/page-editor/SectionsPanel.js.map +1 -0
  147. package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
  148. package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
  149. package/dist/views/page-editor/ValidationSummary.js +10 -0
  150. package/dist/views/page-editor/ValidationSummary.js.map +1 -0
  151. package/dist/views/page-editor/section-icons.d.ts +4 -0
  152. package/dist/views/page-editor/section-icons.d.ts.map +1 -0
  153. package/dist/views/page-editor/section-icons.js +20 -0
  154. package/dist/views/page-editor/section-icons.js.map +1 -0
  155. package/dist/views/page-editor/section-types.d.ts +11 -0
  156. package/dist/views/page-editor/section-types.d.ts.map +1 -0
  157. package/dist/views/page-editor/section-types.js +10 -0
  158. package/dist/views/page-editor/section-types.js.map +1 -0
  159. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
  160. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
  161. package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
  162. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
  163. package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
  164. package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
  165. package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
  166. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
  167. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
  168. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
  169. package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
  170. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
  171. package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
  172. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
  173. package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
  174. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
  175. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
  176. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
  177. package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
  178. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
  179. package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
  180. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
  181. package/dist/views/page-editor/sections/GallerySection.js +17 -0
  182. package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
  183. package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
  184. package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
  185. package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
  186. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
  187. package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
  188. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
  189. package/dist/views/page-editor/sections/MissionSection.js +12 -0
  190. package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
  191. package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
  192. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
  193. package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
  194. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
  195. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
  196. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
  197. package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
  198. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
  199. package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
  200. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
  201. package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
  202. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
  203. package/dist/views/page-editor/sections/index.d.ts +14 -0
  204. package/dist/views/page-editor/sections/index.d.ts.map +1 -0
  205. package/dist/views/page-editor/sections/index.js +12 -0
  206. package/dist/views/page-editor/sections/index.js.map +1 -0
  207. package/dist/views/page-editor/sections/parts.d.ts +55 -0
  208. package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
  209. package/dist/views/page-editor/sections/parts.js +60 -0
  210. package/dist/views/page-editor/sections/parts.js.map +1 -0
  211. package/dist/views/page-editor/viewports.d.ts +14 -0
  212. package/dist/views/page-editor/viewports.d.ts.map +1 -0
  213. package/dist/views/page-editor/viewports.js +12 -0
  214. package/dist/views/page-editor/viewports.js.map +1 -0
  215. package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
  216. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
  217. package/dist/views/post-editor/PostEditorCanvas.js +58 -0
  218. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
  219. package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
  220. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
  221. package/dist/views/post-editor/PostFieldsModal.js +53 -0
  222. package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
  223. package/dist/views/post-editor/PostHeader.d.ts +15 -0
  224. package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
  225. package/dist/views/post-editor/PostHeader.js +27 -0
  226. package/dist/views/post-editor/PostHeader.js.map +1 -0
  227. package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
  228. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
  229. package/dist/views/post-editor/PostHeaderPanel.js +21 -0
  230. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
  231. package/dist/views/post-editor/PostPreview.d.ts +14 -0
  232. package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
  233. package/dist/views/post-editor/PostPreview.js +53 -0
  234. package/dist/views/post-editor/PostPreview.js.map +1 -0
  235. package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
  236. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
  237. package/dist/views/post-editor/PostSectionEditor.js +290 -0
  238. package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
  239. package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
  240. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
  241. package/dist/views/post-editor/PostTemplateEditor.js +144 -0
  242. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
  243. package/package.json +2 -2
  244. package/src/AdminRoot.tsx +213 -2
  245. package/src/__tests__/components/posts-badges.test.tsx +77 -0
  246. package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
  247. package/src/__tests__/lib/page-editor-service.test.ts +173 -0
  248. package/src/__tests__/lib/pages-service.test.ts +157 -0
  249. package/src/__tests__/lib/post-editor-service.test.ts +215 -0
  250. package/src/__tests__/lib/posts-service.test.ts +279 -0
  251. package/src/__tests__/views/posts-list-view.test.ts +110 -0
  252. package/src/components/pages/tag-badges.tsx +104 -0
  253. package/src/components/posts/badges.tsx +170 -0
  254. package/src/components/posts/icons.ts +40 -0
  255. package/src/layout/Header.tsx +90 -43
  256. package/src/layout/Layout.tsx +166 -1
  257. package/src/layout/Sidebar.tsx +287 -101
  258. package/src/lib/page-editor-service.ts +233 -0
  259. package/src/lib/pages-service.ts +599 -0
  260. package/src/lib/post-editor-service.ts +342 -0
  261. package/src/lib/posts-service.ts +366 -0
  262. package/src/lib/preview-link.ts +63 -0
  263. package/src/router/index.ts +10 -1
  264. package/src/views/Dashboard.tsx +270 -131
  265. package/src/views/Pages/PageTagManager.tsx +436 -0
  266. package/src/views/Pages/PagesListView.tsx +944 -0
  267. package/src/views/Posts/NewPostPicker.tsx +147 -0
  268. package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
  269. package/src/views/Posts/PostTypesView.tsx +194 -0
  270. package/src/views/Posts/PostsListView.tsx +869 -0
  271. package/src/views/Posts/post-type-presets.ts +105 -0
  272. package/src/views/page-builder/Widgets.tsx +44 -0
  273. package/src/views/page-editor/AddSectionModal.tsx +56 -0
  274. package/src/views/page-editor/EditorCanvas.tsx +127 -0
  275. package/src/views/page-editor/EditorTopBar.tsx +233 -0
  276. package/src/views/page-editor/PagePreview.tsx +96 -0
  277. package/src/views/page-editor/PageSectionEditor.tsx +476 -0
  278. package/src/views/page-editor/PageSettingsModal.tsx +154 -0
  279. package/src/views/page-editor/SectionInspector.tsx +599 -0
  280. package/src/views/page-editor/SectionsPanel.tsx +270 -0
  281. package/src/views/page-editor/ValidationSummary.tsx +53 -0
  282. package/src/views/page-editor/section-icons.ts +33 -0
  283. package/src/views/page-editor/section-types.ts +27 -0
  284. package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
  285. package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
  286. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
  287. package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
  288. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
  289. package/src/views/page-editor/sections/GallerySection.tsx +54 -0
  290. package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
  291. package/src/views/page-editor/sections/MissionSection.tsx +22 -0
  292. package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
  293. package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
  294. package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
  295. package/src/views/page-editor/sections/index.ts +13 -0
  296. package/src/views/page-editor/sections/parts.tsx +113 -0
  297. package/src/views/page-editor/viewports.ts +25 -0
  298. package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
  299. package/src/views/post-editor/PostFieldsModal.tsx +196 -0
  300. package/src/views/post-editor/PostHeader.tsx +87 -0
  301. package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
  302. package/src/views/post-editor/PostPreview.tsx +114 -0
  303. package/src/views/post-editor/PostSectionEditor.tsx +506 -0
  304. package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
@@ -0,0 +1,63 @@
1
+ import { cmsApi } from './api.js'
2
+
3
+ /**
4
+ * Mint a signed, time-boxed preview token for a single document. The token is
5
+ * bound to the (collection, documentId) pair server-side, so it can only
6
+ * unlock the exact draft it was issued for. Used to build public preview URLs
7
+ * (`/<prefix>/<slug>?preview=<token>`) that render a draft through the
8
+ * consumer's real front-end — see {@link buildPublicPreviewUrl}.
9
+ */
10
+ export async function createPreviewToken(
11
+ collection: string,
12
+ documentId: string,
13
+ ttlSeconds?: number,
14
+ ): Promise<{ token: string; expiresAt: string; slug?: string | null }> {
15
+ const res = await cmsApi<{ token: string; expiresAt: string; slug?: string | null }>(
16
+ '/preview/token',
17
+ {
18
+ method: 'POST',
19
+ body: JSON.stringify({ collection, documentId, ttlSeconds }),
20
+ },
21
+ )
22
+ if (res.error || !res.data) {
23
+ throw new Error(res.error ?? 'Failed to create preview token')
24
+ }
25
+ return res.data
26
+ }
27
+
28
+ export interface PublicPreviewUrlParts {
29
+ /** Absolute site origin, e.g. `https://example.com`. Trailing slash optional. */
30
+ siteUrl: string
31
+ /** Collection URL prefix (e.g. `blog`). Empty for top-level page collections. */
32
+ urlPrefix?: string | null
33
+ /** Document slug used in the public path. */
34
+ slug: string
35
+ /** Signed preview token from {@link createPreviewToken}. */
36
+ token: string
37
+ }
38
+
39
+ /**
40
+ * Build the public URL a draft preview opens at — the SAME path the
41
+ * `/resolve` endpoint matches: `<urlPrefix>/<slug>` (or `/<slug>` for
42
+ * top-level pages), with the preview token on the query string so the
43
+ * public route can drop the `PUBLISHED` filter for this one document.
44
+ */
45
+ export function buildPublicPreviewUrl({
46
+ siteUrl,
47
+ urlPrefix,
48
+ slug,
49
+ token,
50
+ }: PublicPreviewUrlParts): string {
51
+ const base = (siteUrl ?? '').replace(/\/+$/, '')
52
+ const prefix = (urlPrefix ?? '').replace(/^\/+|\/+$/g, '')
53
+ const cleanSlug = slug.replace(/^\/+|\/+$/g, '')
54
+ const path = prefix ? `/${prefix}/${cleanSlug}` : `/${cleanSlug}`
55
+ return `${base}${path}?preview=${encodeURIComponent(token)}`
56
+ }
57
+
58
+ /** Resolve the public site origin: explicit config first, else the current tab's origin. */
59
+ export function resolveSiteUrl(configSiteUrl?: string | null): string {
60
+ if (configSiteUrl && configSiteUrl.trim()) return configSiteUrl.trim()
61
+ if (typeof window !== 'undefined') return window.location.origin
62
+ return ''
63
+ }
@@ -44,6 +44,15 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
44
44
  window.history.pushState({ adminPath: normalizedPath }, '', fullUrl)
45
45
  }, [])
46
46
 
47
+ // Build an absolute (basePath-prefixed) admin URL without navigating. Used
48
+ // for links that must load a real document — e.g. opening a preview in a
49
+ // new browser tab, where `pushState` (which only mutates the current tab)
50
+ // is not an option.
51
+ const buildHref = useCallback((path: string) => {
52
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
53
+ return normalizedPath === '/' ? baseRef.current : `${baseRef.current}${normalizedPath}`
54
+ }, [])
55
+
47
56
  const matchRoute = useCallback(
48
57
  (pattern: string): RouteParams | null => {
49
58
  const patternParts = pattern.split('/').filter(Boolean)
@@ -75,5 +84,5 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
75
84
  [currentPath],
76
85
  )
77
86
 
78
- return { currentPath, navigate, matchRoute }
87
+ return { currentPath, navigate, matchRoute, buildHref }
79
88
  }
@@ -33,6 +33,10 @@ import {
33
33
  Database,
34
34
  ChevronRight,
35
35
  Loader2,
36
+ TrendingUp,
37
+ TrendingDown,
38
+ Gauge,
39
+ AlertCircle,
36
40
  type LucideIcon,
37
41
  } from 'lucide-react'
38
42
  import { useApiData } from '../lib/useApiData.js'
@@ -64,6 +68,28 @@ interface HealthData {
64
68
  models: Record<string, boolean>
65
69
  }
66
70
 
71
+ interface DeliveryStats {
72
+ hasData: boolean
73
+ requests24h: number
74
+ requestsPrev24h: number
75
+ deltaPercent: number | null
76
+ errorRate: number
77
+ errorCount: number
78
+ avgLatencyMs: number
79
+ p95LatencyMs: number
80
+ maxLatencyMs: number
81
+ webhookCount: number
82
+ webhookActiveCount: number
83
+ }
84
+
85
+ interface ContentHealthData {
86
+ score: number
87
+ label: 'Good' | 'Fair' | 'Poor'
88
+ counts: Record<string, number>
89
+ totalIssues: number
90
+ lastScanAt: string | null
91
+ }
92
+
67
93
  interface CollectionMeta {
68
94
  slug: string
69
95
  type?: string
@@ -114,6 +140,27 @@ function timeOfDayGreeting(): string {
114
140
  return 'Good evening'
115
141
  }
116
142
 
143
+ /**
144
+ * Return the user's first name for the greeting. Falls back to the
145
+ * email local-part, then a generic "there". Strips role-style names
146
+ * like "Admin" / "Administrator" / "Editor" — a literal
147
+ * `Good morning, Admin` reads as bot-output, not a person.
148
+ */
149
+ function firstNameForGreeting(session: any): string {
150
+ const raw = (session?.user?.name ?? session?.name ?? '').trim()
151
+ const email = session?.user?.email ?? session?.email
152
+ const generic = new Set(['admin', 'administrator', 'editor', 'user', 'root'])
153
+ if (raw) {
154
+ const first = raw.split(/\s+/)[0] ?? raw
155
+ if (!generic.has(first.toLowerCase())) return first
156
+ }
157
+ if (typeof email === 'string' && email.length > 0) {
158
+ const local = email.split('@')[0] ?? email
159
+ return local.charAt(0).toUpperCase() + local.slice(1)
160
+ }
161
+ return 'there'
162
+ }
163
+
117
164
  function todayDateString(): string {
118
165
  return new Date().toLocaleDateString('en-US', {
119
166
  weekday: 'long',
@@ -200,10 +247,14 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
200
247
  const nav = (path: string) => onNavigate?.(path)
201
248
  const { data: stats, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats')
202
249
  const { data: health } = useApiData<HealthData>('/health')
250
+ const { data: delivery, loading: deliveryLoading } =
251
+ useApiData<DeliveryStats>('/stats/api-delivery')
252
+ const { data: contentHealthData, loading: contentHealthLoading } =
253
+ useApiData<ContentHealthData>('/stats/content-health')
203
254
 
204
255
  const greeting = useMemo(() => timeOfDayGreeting(), [])
205
256
  const dateStr = useMemo(() => todayDateString(), [])
206
- const userName = session?.name ?? session?.email?.split('@')[0] ?? 'there'
257
+ const userName = useMemo(() => firstNameForGreeting(session), [session])
207
258
 
208
259
  const collections = useMemo(() => resolveCollections(config), [config])
209
260
 
@@ -301,18 +352,35 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
301
352
  }, [stats, collections])
302
353
 
303
354
  // ── Quick actions ───────────────────────────────────────────────────────
304
- // "New Post" lives only in the hero CTA above; this row is the secondary
305
- // surface and intentionally omits the primary action to avoid the obvious
306
- // duplication. Order mirrors the natural authoring flow.
355
+ // 5-button strip matching the reference dashboard. The "+ New Post" entry
356
+ // intentionally duplicates the top-bar primary action: the top bar
357
+ // surface is for chrome-level navigation, the quick-action row is the
358
+ // dashboard's content-creation toolbar. Order mirrors the natural
359
+ // authoring flow (post → page → media → form → integration).
307
360
  const quickActions = useMemo(() => {
361
+ const postTypes = collections.filter((c) => c.type === 'post')
308
362
  const pages = collections.find((c) => c.slug === 'pages' || c.type === 'page')
309
- const items: { label: string; icon: LucideIcon; onClick: () => void }[] = []
310
- if (pages)
311
- items.push({ label: 'New Page', icon: Plus, onClick: () => nav(`/${pages.slug}/new`) })
312
- items.push({ label: 'Upload Media', icon: Upload, onClick: () => nav('/media') })
313
- items.push({ label: 'New Form', icon: Plus, onClick: () => nav('/forms') })
314
- items.push({ label: 'Manage SEO', icon: Search, onClick: () => nav('/seo') })
315
- items.push({ label: 'View API', icon: Globe, onClick: () => nav('/api-keys') })
363
+ const pagesSlug = pages?.slug ?? 'pages'
364
+ // "New Post": one Post Type → straight to its editor; several →
365
+ // the picker; none the Post Type setup instructions (avoids a
366
+ // broken slug-based new URL).
367
+ const newPostPath =
368
+ postTypes.length === 1
369
+ ? `/${postTypes[0]!.slug}/new`
370
+ : postTypes.length > 1
371
+ ? '/posts/new'
372
+ : '/posts/types/new'
373
+ const items: { label: string; icon: LucideIcon; onClick: () => void }[] = [
374
+ {
375
+ label: 'New Post',
376
+ icon: Plus,
377
+ onClick: () => nav(newPostPath),
378
+ },
379
+ { label: 'New Page', icon: Plus, onClick: () => nav(`/${pagesSlug}/new`) },
380
+ { label: 'Upload Media', icon: Upload, onClick: () => nav('/media') },
381
+ { label: 'New Form', icon: Plus, onClick: () => nav('/forms') },
382
+ { label: 'View API', icon: Globe, onClick: () => nav('/api-keys') },
383
+ ]
316
384
  return items
317
385
  }, [collections, onNavigate]) // eslint-disable-line react-hooks/exhaustive-deps
318
386
 
@@ -351,51 +419,63 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
351
419
  })
352
420
  }, [stats, collections])
353
421
 
354
- // ── Content health summary (derived from /stats) ────────────────────────
422
+ // ── Content health (sourced from /stats/content-health) ─────────────────
423
+ // Persisted by the nightly content-health cron. The four issue rows
424
+ // map 1:1 to ContentIssueType in the schema; the labels are
425
+ // user-facing and intentionally read like the reference dashboard.
355
426
  const contentHealth = useMemo(() => {
356
- const score = stats?.avgSeoScore ?? 0
357
- const counts = stats?.statusCounts ?? {}
358
- const totalDrafts = counts['DRAFT'] ?? 0
359
- const totalScheduled = counts['SCHEDULED'] ?? 0
360
- const totalInReview = counts['IN_REVIEW'] ?? 0
361
- const issues: { label: string; count: number; tone: 'warn' | 'err' | 'muted' }[] = []
362
- if (totalDrafts > 0) {
363
- issues.push({ label: 'Drafts pending publish', count: totalDrafts, tone: 'warn' })
364
- }
365
- if (totalInReview > 0) {
366
- issues.push({ label: 'Awaiting review', count: totalInReview, tone: 'warn' })
367
- }
368
- if (totalScheduled > 0) {
369
- issues.push({ label: 'Scheduled to publish', count: totalScheduled, tone: 'muted' })
370
- }
371
- if (score > 0 && score < 70) {
427
+ const counts = contentHealthData?.counts ?? {}
428
+ const score = contentHealthData?.score ?? 100
429
+ const label = contentHealthData?.label ?? 'Good'
430
+ const tone: 'ok' | 'warn' | 'err' | 'muted' =
431
+ score >= 70 ? 'ok' : score >= 40 ? 'warn' : score > 0 ? 'err' : 'muted'
432
+
433
+ // Issue list only render rows that have non-zero counts so the
434
+ // card stays compact when the corpus is healthy. The empty state
435
+ // below picks up when all four are zero.
436
+ const issues: { kind: string; label: string; count: number; tone: 'warn' | 'err' }[] = []
437
+ const missingMeta = counts['MISSING_META_DESCRIPTION'] ?? 0
438
+ const brokenLinks = counts['BROKEN_INTERNAL_LINK'] ?? 0
439
+ const missingAlt = counts['MISSING_ALT_TEXT'] ?? 0
440
+ const outdated = counts['OUTDATED_CONTENT'] ?? 0
441
+ if (missingMeta > 0)
372
442
  issues.push({
373
- label: 'Pages with weak SEO',
374
- count: Math.max(1, Math.round((70 - score) / 10)),
375
- tone: score < 40 ? 'err' : 'warn',
443
+ kind: 'MISSING_META_DESCRIPTION',
444
+ label: 'Missing meta descriptions',
445
+ count: missingMeta,
446
+ tone: 'warn',
376
447
  })
377
- }
448
+ if (brokenLinks > 0)
449
+ issues.push({
450
+ kind: 'BROKEN_INTERNAL_LINK',
451
+ label: 'Broken internal links',
452
+ count: brokenLinks,
453
+ tone: 'err',
454
+ })
455
+ if (missingAlt > 0)
456
+ issues.push({
457
+ kind: 'MISSING_ALT_TEXT',
458
+ label: 'Missing alt text',
459
+ count: missingAlt,
460
+ tone: 'warn',
461
+ })
462
+ if (outdated > 0)
463
+ issues.push({
464
+ kind: 'OUTDATED_CONTENT',
465
+ label: 'Outdated content (90+ days)',
466
+ count: outdated,
467
+ tone: 'warn',
468
+ })
469
+
378
470
  return {
379
471
  score,
380
- label: score >= 70 ? 'Good' : score >= 40 ? 'Fair' : score > 0 ? 'Poor' : 'No data',
381
- tone: score >= 70 ? 'ok' : score >= 40 ? 'warn' : score > 0 ? 'err' : 'muted',
472
+ label,
473
+ tone,
382
474
  issues,
475
+ hasData: contentHealthData !== null,
476
+ loading: contentHealthLoading,
383
477
  } as const
384
- }, [stats])
385
-
386
- // ── Content delivery tiles ──────────────────────────────────────────────
387
- const delivery = useMemo(() => {
388
- const sched = stats?.statusCounts?.['SCHEDULED'] ?? 0
389
- return {
390
- totalDocs: stats?.totalDocuments ?? 0,
391
- forms: stats?.formCount ?? 0,
392
- scheduled: sched,
393
- webhooks: {
394
- total: stats?.webhookCount ?? 0,
395
- active: stats?.webhookActiveCount ?? 0,
396
- },
397
- }
398
- }, [stats])
478
+ }, [contentHealthData, contentHealthLoading])
399
479
 
400
480
  const totalIssues = contentHealth.issues.reduce((s, i) => s + i.count, 0)
401
481
 
@@ -409,10 +489,6 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
409
489
  )
410
490
  }
411
491
 
412
- const heroPostSlug =
413
- collections.find((c) => c.slug === 'posts' || c.type === 'post')?.slug ?? 'posts'
414
- const siteUrl = config?.site?.url ?? config?.seo?.siteUrl ?? null
415
-
416
492
  return (
417
493
  <div className="w-full space-y-5 p-4 sm:p-6 lg:px-8">
418
494
  {/* Health banners ────────────────────────────────────────────── */}
@@ -452,40 +528,25 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
452
528
  </div>
453
529
  )}
454
530
 
455
- {/* Header ─────────────────────────────────────────────────────── */}
456
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
457
- <div className="min-w-0">
458
- <h1 className="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">
459
- {greeting}, {userName} <span aria-hidden>👋</span>
460
- </h1>
461
- <p className="text-muted-foreground mt-0.5 text-sm">
462
- {dateStr}
463
- {totalIssues > 0 && (
464
- <span className="hidden sm:inline">
465
- {' · '}
466
- {totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
467
- </span>
468
- )}
469
- </p>
470
- </div>
471
- <div className="flex shrink-0 items-center gap-2">
472
- <button
473
- onClick={() => nav(`/${heroPostSlug}/new`)}
474
- className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3.5 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
475
- >
476
- <Plus className="h-3.5 w-3.5" /> New Post
477
- </button>
478
- {siteUrl && (
479
- <a
480
- href={siteUrl}
481
- target="_blank"
482
- rel="noopener noreferrer"
483
- className="border-border bg-card hover:bg-accent hover:text-accent-foreground hidden items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors sm:inline-flex"
484
- >
485
- <ExternalLink className="h-3.5 w-3.5" /> View Site
486
- </a>
531
+ {/* Hero greeting ─────────────────────────────────────────────────
532
+ Primary "+ New Post" and "View Site" actions live in the top bar
533
+ (Header.tsx page-action slot), not here, so the hero is purely
534
+ informational and the dashboard's chrome-level affordances stay
535
+ consistent across navigation. The on-page secondary action row
536
+ below covers the same creation paths plus authoring shortcuts. */}
537
+ <div className="flex flex-col gap-1 sm:gap-0.5">
538
+ <h1 className="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">
539
+ {greeting}, {userName} <span aria-hidden>👋</span>
540
+ </h1>
541
+ <p className="text-muted-foreground text-sm">
542
+ {dateStr}
543
+ {totalIssues > 0 && (
544
+ <span className="hidden sm:inline">
545
+ {' · '}
546
+ {totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
547
+ </span>
487
548
  )}
488
- </div>
549
+ </p>
489
550
  </div>
490
551
 
491
552
  {/* Quick actions ──────────────────────────────────────────────── */}
@@ -763,12 +824,21 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
763
824
  </aside>
764
825
  </div>
765
826
 
766
- {/* Content Delivery ──────────────────────────────────────────── */}
827
+ {/* Delivery API ──────────────────────────────────────────────────
828
+ Headless content delivery metrics aggregated over the last
829
+ 24h from `actuate_api_request_metrics`. Populated in real-time
830
+ by the request logger in handler-factory.ts — no cron
831
+ required. Empty-state messaging differentiates "we haven't
832
+ collected enough data yet" from "we have data and it shows
833
+ 0" so editors don't think the dashboard is broken on a
834
+ quiet weekend. */}
767
835
  <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
768
836
  <header className="border-border flex items-center justify-between border-b px-4 py-3">
769
837
  <div className="min-w-0">
770
- <h2 className="text-foreground text-sm font-semibold">Content Delivery</h2>
771
- <p className="text-muted-foreground mt-0.5 text-xs">Real-time platform activity</p>
838
+ <h2 className="text-foreground text-sm font-semibold">Delivery API</h2>
839
+ <p className="text-muted-foreground mt-0.5 text-xs">
840
+ Headless content delivery · last 24 hours
841
+ </p>
772
842
  </div>
773
843
  <button
774
844
  className="inline-flex items-center gap-1 text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
@@ -777,52 +847,114 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
777
847
  API docs <ExternalLink className="h-3 w-3" />
778
848
  </button>
779
849
  </header>
780
- <div className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0">
781
- <DeliveryTile
782
- icon={Activity}
783
- label="Total Documents"
784
- value={delivery.totalDocs.toLocaleString()}
785
- sub="across all collections"
786
- />
787
- <DeliveryTile
788
- icon={Clock}
789
- label="Scheduled Posts"
790
- value={delivery.scheduled.toLocaleString()}
791
- sub={delivery.scheduled > 0 ? 'in publishing queue' : 'none queued'}
792
- tone={delivery.scheduled > 0 ? 'ok' : 'muted'}
793
- />
794
- <DeliveryTile
795
- icon={ClipboardList}
796
- label="Form Responses"
797
- value={delivery.forms.toLocaleString()}
798
- sub={delivery.forms > 0 ? 'total received' : 'none yet'}
799
- tone={delivery.forms > 0 ? 'ok' : 'muted'}
800
- />
801
- <DeliveryTile
802
- icon={Zap}
803
- label="Active Webhooks"
804
- value={`${delivery.webhooks.active} / ${delivery.webhooks.total || 0}`}
805
- sub={
806
- delivery.webhooks.total === 0
807
- ? 'none configured'
808
- : delivery.webhooks.active === delivery.webhooks.total
809
- ? 'all active'
810
- : `${delivery.webhooks.total - delivery.webhooks.active} paused`
811
- }
812
- tone={
813
- delivery.webhooks.total === 0
814
- ? 'muted'
815
- : delivery.webhooks.active === delivery.webhooks.total
816
- ? 'ok'
817
- : 'warn'
818
- }
819
- />
820
- </div>
850
+ <DeliveryGrid delivery={delivery} loading={deliveryLoading} />
821
851
  </section>
822
852
  </div>
823
853
  )
824
854
  }
825
855
 
856
+ // ─── Delivery API grid ──────────────────────────────────────────────────────
857
+
858
+ function DeliveryGrid({ delivery, loading }: { delivery: DeliveryStats | null; loading: boolean }) {
859
+ if (loading && !delivery) {
860
+ return (
861
+ <div
862
+ className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0"
863
+ aria-busy
864
+ >
865
+ {[0, 1, 2, 3].map((i) => (
866
+ <div key={i} className="px-4 py-3.5">
867
+ <div className="bg-muted mb-2 h-3 w-24 rounded" />
868
+ <div className="bg-muted mb-1.5 h-7 w-20 rounded" />
869
+ <div className="bg-muted h-3 w-28 rounded" />
870
+ </div>
871
+ ))}
872
+ </div>
873
+ )
874
+ }
875
+
876
+ const hasData = delivery?.hasData ?? false
877
+ const requests = delivery?.requests24h ?? 0
878
+ const delta: number | null = delivery?.deltaPercent ?? null
879
+ const errorRate = delivery?.errorRate ?? 0
880
+ const avgLatency = delivery?.avgLatencyMs ?? 0
881
+ const p95Latency = delivery?.p95LatencyMs ?? 0
882
+ const webhookTotal = delivery?.webhookCount ?? 0
883
+ const webhookActive = delivery?.webhookActiveCount ?? 0
884
+
885
+ // Pretty-print large numbers (14_823 → "14.8K") so the tile stays
886
+ // readable at the dashboard's compact density.
887
+ const requestsLabel = formatLargeNumber(requests)
888
+ const errorRatePct = (errorRate * 100).toFixed(2)
889
+
890
+ return (
891
+ <div className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0">
892
+ <DeliveryTile
893
+ icon={Activity}
894
+ label="API Requests (24h)"
895
+ value={hasData ? requestsLabel : '—'}
896
+ sub={
897
+ !hasData
898
+ ? 'no traffic recorded yet'
899
+ : delta === null
900
+ ? 'no prior baseline'
901
+ : `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}% vs yesterday`
902
+ }
903
+ subIcon={
904
+ hasData && typeof delta === 'number'
905
+ ? delta >= 0
906
+ ? TrendingUp
907
+ : TrendingDown
908
+ : undefined
909
+ }
910
+ tone={hasData ? (delta && delta >= 0 ? 'ok' : 'muted') : 'muted'}
911
+ />
912
+ <DeliveryTile
913
+ icon={AlertCircle}
914
+ label="Error Rate"
915
+ value={hasData ? `${errorRatePct}%` : '—'}
916
+ sub={
917
+ !hasData
918
+ ? 'no traffic recorded yet'
919
+ : errorRate < 0.005
920
+ ? 'healthy'
921
+ : errorRate < 0.05
922
+ ? 'elevated'
923
+ : 'investigate'
924
+ }
925
+ tone={!hasData ? 'muted' : errorRate < 0.005 ? 'ok' : errorRate < 0.05 ? 'warn' : 'err'}
926
+ />
927
+ <DeliveryTile
928
+ icon={Gauge}
929
+ label="Avg Response Time"
930
+ value={hasData ? `${Math.round(avgLatency)}ms` : '—'}
931
+ sub={hasData ? `p95: ${p95Latency}ms` : 'no traffic recorded yet'}
932
+ tone={!hasData ? 'muted' : avgLatency < 200 ? 'ok' : avgLatency < 600 ? 'warn' : 'err'}
933
+ />
934
+ <DeliveryTile
935
+ icon={Zap}
936
+ label="Active Webhooks"
937
+ value={`${webhookActive} / ${webhookTotal}`}
938
+ sub={
939
+ webhookTotal === 0
940
+ ? 'none configured'
941
+ : webhookActive === webhookTotal
942
+ ? 'All active'
943
+ : `${webhookTotal - webhookActive} paused`
944
+ }
945
+ tone={webhookTotal === 0 ? 'muted' : webhookActive === webhookTotal ? 'ok' : 'warn'}
946
+ />
947
+ </div>
948
+ )
949
+ }
950
+
951
+ function formatLargeNumber(n: number): string {
952
+ if (n < 1000) return n.toLocaleString()
953
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}K`
954
+ if (n < 1_000_000) return `${(n / 1000).toFixed(0)}K`
955
+ return `${(n / 1_000_000).toFixed(1)}M`
956
+ }
957
+
826
958
  // ─── Sub-components (kept in-file: smaller bundle, no extra module hops) ────
827
959
 
828
960
  function EmptyState({
@@ -852,12 +984,14 @@ function DeliveryTile({
852
984
  label,
853
985
  value,
854
986
  sub,
987
+ subIcon: SubIcon,
855
988
  tone = 'muted',
856
989
  }: {
857
990
  icon: LucideIcon
858
991
  label: string
859
992
  value: string
860
993
  sub?: string
994
+ subIcon?: LucideIcon
861
995
  tone?: 'ok' | 'warn' | 'err' | 'muted'
862
996
  }) {
863
997
  const subTone =
@@ -875,7 +1009,12 @@ function DeliveryTile({
875
1009
  <span className="text-[11px] font-medium">{label}</span>
876
1010
  </div>
877
1011
  <p className="text-foreground text-xl leading-tight font-semibold tracking-tight">{value}</p>
878
- {sub && <p className={`mt-0.5 text-[11px] ${subTone}`}>{sub}</p>}
1012
+ {sub && (
1013
+ <p className={`mt-0.5 inline-flex items-center gap-1 text-[11px] ${subTone}`}>
1014
+ {SubIcon && <SubIcon className="h-3 w-3 shrink-0" aria-hidden />}
1015
+ {sub}
1016
+ </p>
1017
+ )}
879
1018
  </div>
880
1019
  )
881
1020
  }