@carlonicora/nextjs-jsonapi 5.2.0 → 5.4.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-EGY4ZAAM.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
  2. package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
  3. package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
  4. package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
  5. package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
  6. package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
  7. package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-YL3J7J2Y.js.map} +1 -1
  8. package/dist/BlogCategoryFields-DO48vOTE.d.mts +14 -0
  9. package/dist/BlogCategoryFields-DO48vOTE.d.ts +14 -0
  10. package/dist/BlogPostInterface-BB_4wcDR.d.mts +124 -0
  11. package/dist/BlogPostInterface-bWAT1rfR.d.ts +124 -0
  12. package/dist/billing/index.js +441 -441
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
  15. package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
  16. package/dist/{chunk-3VEQ5JZJ.js → chunk-3NLAXVIN.js} +860 -849
  17. package/dist/{chunk-3VEQ5JZJ.js.map → chunk-3NLAXVIN.js.map} +1 -1
  18. package/dist/{chunk-YMUPTXOT.mjs → chunk-3YW35YHQ.mjs} +1 -101
  19. package/dist/chunk-3YW35YHQ.mjs.map +1 -0
  20. package/dist/{chunk-EXICQC4A.js → chunk-5PPG6PL7.js} +3 -3
  21. package/dist/{chunk-EXICQC4A.js.map → chunk-5PPG6PL7.js.map} +1 -1
  22. package/dist/{chunk-NPKFMRH5.mjs → chunk-6DC2WVVB.mjs} +2 -2
  23. package/dist/{chunk-2Z3ZCYXX.mjs → chunk-EUKTBOI2.mjs} +19 -8
  24. package/dist/chunk-EUKTBOI2.mjs.map +1 -0
  25. package/dist/{chunk-THKYH65N.js → chunk-KSYQEKJX.js} +11 -111
  26. package/dist/chunk-KSYQEKJX.js.map +1 -0
  27. package/dist/{chunk-S5MQDWWC.js → chunk-NFBOSPNJ.js} +1451 -176
  28. package/dist/chunk-NFBOSPNJ.js.map +1 -0
  29. package/dist/{chunk-JEZQTZQ4.mjs → chunk-QMYQRPQC.mjs} +2 -2
  30. package/dist/{chunk-HNOWSFM5.mjs → chunk-WC63PJSC.mjs} +1278 -3
  31. package/dist/chunk-WC63PJSC.mjs.map +1 -0
  32. package/dist/chunk-XCHSX3VC.mjs +107 -0
  33. package/dist/chunk-XCHSX3VC.mjs.map +1 -0
  34. package/dist/{chunk-75MLT74B.js → chunk-YNCQSWQW.js} +8 -8
  35. package/dist/{chunk-75MLT74B.js.map → chunk-YNCQSWQW.js.map} +1 -1
  36. package/dist/chunk-YW4ZCEWB.js +107 -0
  37. package/dist/chunk-YW4ZCEWB.js.map +1 -0
  38. package/dist/client/index.d.mts +6 -39
  39. package/dist/client/index.d.ts +6 -39
  40. package/dist/client/index.js +4 -4
  41. package/dist/client/index.mjs +3 -3
  42. package/dist/components/index.d.mts +15 -4
  43. package/dist/components/index.d.ts +15 -4
  44. package/dist/components/index.js +4 -4
  45. package/dist/components/index.mjs +3 -3
  46. package/dist/contexts/index.js +4 -4
  47. package/dist/contexts/index.mjs +3 -3
  48. package/dist/core/index.d.mts +400 -3
  49. package/dist/core/index.d.ts +400 -3
  50. package/dist/core/index.js +64 -2
  51. package/dist/core/index.js.map +1 -1
  52. package/dist/core/index.mjs +63 -1
  53. package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
  54. package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
  55. package/dist/email-automations/index.css +30 -0
  56. package/dist/email-automations/index.css.map +1 -0
  57. package/dist/email-automations/index.d.mts +303 -0
  58. package/dist/email-automations/index.d.ts +303 -0
  59. package/dist/email-automations/index.js +1705 -0
  60. package/dist/email-automations/index.js.map +1 -0
  61. package/dist/email-automations/index.mjs +1705 -0
  62. package/dist/email-automations/index.mjs.map +1 -0
  63. package/dist/features/analytics/index.js +122 -122
  64. package/dist/features/analytics/index.mjs +4 -4
  65. package/dist/features/analytics/server.js +6 -5
  66. package/dist/features/analytics/server.js.map +1 -1
  67. package/dist/features/analytics/server.mjs +4 -3
  68. package/dist/features/analytics/server.mjs.map +1 -1
  69. package/dist/features/blog/index.css +30 -0
  70. package/dist/features/blog/index.css.map +1 -0
  71. package/dist/features/blog/index.d.mts +266 -0
  72. package/dist/features/blog/index.d.ts +266 -0
  73. package/dist/features/blog/index.js +1799 -0
  74. package/dist/features/blog/index.js.map +1 -0
  75. package/dist/features/blog/index.mjs +1799 -0
  76. package/dist/features/blog/index.mjs.map +1 -0
  77. package/dist/features/blog/server.d.mts +33 -0
  78. package/dist/features/blog/server.d.ts +33 -0
  79. package/dist/features/blog/server.js +139 -0
  80. package/dist/features/blog/server.js.map +1 -0
  81. package/dist/features/blog/server.mjs +139 -0
  82. package/dist/features/blog/server.mjs.map +1 -0
  83. package/dist/features/help/index.js +37 -37
  84. package/dist/features/help/index.mjs +3 -3
  85. package/dist/features/tokenusage/index.js +125 -125
  86. package/dist/features/tokenusage/index.mjs +4 -4
  87. package/dist/index.d.mts +5 -2
  88. package/dist/index.d.ts +5 -2
  89. package/dist/index.js +65 -3
  90. package/dist/index.js.map +1 -1
  91. package/dist/index.mjs +64 -2
  92. package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
  93. package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
  94. package/dist/server/index.d.mts +1 -1
  95. package/dist/server/index.d.ts +1 -1
  96. package/dist/server/index.js +8 -6
  97. package/dist/server/index.js.map +1 -1
  98. package/dist/server/index.mjs +12 -10
  99. package/dist/types-70acd-8C.d.mts +39 -0
  100. package/dist/types-l8SFleXi.d.ts +39 -0
  101. package/messages/en.json +210 -0
  102. package/messages/it.json +210 -0
  103. package/package.json +21 -1
  104. package/src/components/editors/BlockNoteEditor.tsx +13 -3
  105. package/src/components/forms/FormBlockNote.tsx +4 -0
  106. package/src/components/forms/FormFieldWrapper.tsx +1 -1
  107. package/src/components/forms/FormTextarea.tsx +2 -1
  108. package/src/components/navigations/ContentTitle.tsx +14 -3
  109. package/src/core/index.ts +12 -0
  110. package/src/core/registry/ModuleRegistry.ts +11 -0
  111. package/src/email-automations/index.ts +5 -0
  112. package/src/features/blog/BlogModules.ts +49 -0
  113. package/src/features/blog/components/admin/BlogAdminContainer.tsx +32 -0
  114. package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
  115. package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
  116. package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
  117. package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
  118. package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
  119. package/src/features/blog/components/admin/BlogPostContainer.tsx +102 -0
  120. package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
  121. package/src/features/blog/components/admin/BlogPostEditor.tsx +207 -0
  122. package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
  123. package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +110 -0
  124. package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
  125. package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
  126. package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
  127. package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
  128. package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
  129. package/src/features/blog/components/admin/index.ts +14 -0
  130. package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
  131. package/src/features/blog/components/public/BlogList.tsx +21 -0
  132. package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
  133. package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
  134. package/src/features/blog/components/public/BlogListParts.tsx +74 -0
  135. package/src/features/blog/components/public/BlogListRail.tsx +62 -0
  136. package/src/features/blog/components/public/BlogPostCard.tsx +132 -0
  137. package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
  138. package/src/features/blog/components/public/BlogPostView.tsx +74 -0
  139. package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
  140. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +173 -0
  141. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +128 -0
  142. package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
  143. package/src/features/blog/components/public/index.ts +3 -0
  144. package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
  145. package/src/features/blog/contexts/index.ts +1 -0
  146. package/src/features/blog/data/BlogCategory.ts +93 -0
  147. package/src/features/blog/data/BlogCategoryFields.ts +4 -0
  148. package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
  149. package/src/features/blog/data/BlogCategoryService.ts +54 -0
  150. package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
  151. package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
  152. package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
  153. package/src/features/blog/data/BlogPost.spec.ts +123 -0
  154. package/src/features/blog/data/BlogPost.ts +159 -0
  155. package/src/features/blog/data/BlogPostFields.ts +7 -0
  156. package/src/features/blog/data/BlogPostInterface.ts +39 -0
  157. package/src/features/blog/data/BlogPostService.ts +82 -0
  158. package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
  159. package/src/features/blog/data/BlogPostTranslation.ts +110 -0
  160. package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
  161. package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
  162. package/src/features/blog/data/BlogSettings.ts +37 -0
  163. package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
  164. package/src/features/blog/data/BlogSettingsService.ts +24 -0
  165. package/src/features/blog/data/index.ts +17 -0
  166. package/src/features/blog/hooks/index.ts +3 -0
  167. package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
  168. package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
  169. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
  170. package/src/features/blog/index.ts +15 -0
  171. package/src/features/blog/server/fetchers.ts +120 -0
  172. package/src/features/blog/server/toPublic.spec.ts +91 -0
  173. package/src/features/blog/server/toPublic.ts +36 -0
  174. package/src/features/blog/server-entry.ts +17 -0
  175. package/src/features/blog/types/blog-public.types.ts +34 -0
  176. package/src/features/blog/types/blog.constants.ts +40 -0
  177. package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
  178. package/src/features/email-automation/EmailAutomationModule.ts +30 -0
  179. package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
  180. package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
  181. package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
  182. package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
  183. package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
  184. package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
  185. package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
  186. package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
  187. package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
  188. package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
  189. package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
  190. package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
  191. package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
  192. package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
  193. package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
  194. package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
  195. package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
  196. package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
  197. package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
  198. package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
  199. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
  200. package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
  201. package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
  202. package/src/features/email-automation/components/index.ts +15 -0
  203. package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
  204. package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
  205. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
  206. package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
  207. package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
  208. package/src/features/email-automation/data/EmailAutomation.ts +87 -0
  209. package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
  210. package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
  211. package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
  212. package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
  213. package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
  214. package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
  215. package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
  216. package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
  217. package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
  218. package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
  219. package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
  220. package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
  221. package/src/features/email-automation/data/email-automation.fields.ts +9 -0
  222. package/src/features/email-automation/data/index.ts +13 -0
  223. package/src/features/email-automation/hooks/index.ts +2 -0
  224. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
  225. package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
  226. package/src/features/email-automation/index.ts +5 -0
  227. package/src/features/s3/data/s3.service.ts +17 -0
  228. package/dist/BlockNoteEditor-2QJVK2HH.mjs.map +0 -1
  229. package/dist/BlockNoteEditor-EGY4ZAAM.js.map +0 -1
  230. package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
  231. package/dist/chunk-HNOWSFM5.mjs.map +0 -1
  232. package/dist/chunk-S5MQDWWC.js.map +0 -1
  233. package/dist/chunk-THKYH65N.js.map +0 -1
  234. package/dist/chunk-YMUPTXOT.mjs.map +0 -1
  235. /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
  236. /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
  237. /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { useFormatter } from "next-intl";
4
+ import Image from "next/image";
5
+ import { MicroLabel, SectionHeader } from "../../../../components/typography";
6
+ import { Link } from "../../../../shadcnui";
7
+ import { cn } from "../../../../utils/cn";
8
+ import { isRemoteImageSrc } from "../../../../utils/isRemoteImageSrc";
9
+ import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
10
+ import { blogPostHref, resolvePostCategory } from "./blogPublicHelpers";
11
+ import { BlogPostMeta } from "./BlogPostMeta";
12
+
13
+ /**
14
+ * Cover image in a fixed-ratio frame. With no image it renders a
15
+ * token-coloured placeholder of the same footprint, so rows stay aligned.
16
+ */
17
+ export function BlogPostCover(props: {
18
+ src?: string;
19
+ alt?: string;
20
+ sizes: string;
21
+ priority?: boolean;
22
+ className?: string;
23
+ }) {
24
+ const { src, alt, sizes, priority, className } = props;
25
+ return (
26
+ <div className={cn("border-border bg-primary/10 relative overflow-hidden rounded-lg border", className)}>
27
+ {src ? (
28
+ <Image
29
+ src={src}
30
+ alt={alt ?? ""}
31
+ fill
32
+ sizes={sizes}
33
+ priority={priority}
34
+ className="object-cover"
35
+ unoptimized={isRemoteImageSrc(src)}
36
+ />
37
+ ) : null}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ type BlogPostCardVariant = "hero" | "large" | "card" | "row";
43
+
44
+ /**
45
+ * One post in a public list.
46
+ * - hero: image and text side by side, author and date (featured layout)
47
+ * - large: image above text, author and date (rail layout)
48
+ * - card: small grid card (featured grid, rail columns, related posts)
49
+ * - row: image at the start, text at the end (column layout)
50
+ * The title link is stretched over the whole card, so the card is one target
51
+ * whose accessible name is the title alone.
52
+ */
53
+ export function BlogPostCard(props: {
54
+ post: PublicBlogPost;
55
+ categories: PublicBlogCategory[];
56
+ basePath: string;
57
+ variant?: BlogPostCardVariant;
58
+ priority?: boolean;
59
+ headingLevel?: "h2" | "h3";
60
+ }) {
61
+ const { post, categories, basePath, variant = "card", priority, headingLevel = "h2" } = props;
62
+ const format = useFormatter();
63
+ const category = resolvePostCategory(post, categories);
64
+ const isProminent = variant === "hero" || variant === "large";
65
+
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;
72
+
73
+ const text = (
74
+ <div className="flex min-w-0 flex-col">
75
+ {eyebrow ? <MicroLabel as="span">{eyebrow}</MicroLabel> : null}
76
+ <SectionHeader level={headingLevel === "h3" ? 3 : 2} className="mt-1.5">
77
+ <Link href={blogPostHref(basePath, post.slug)} className="after:absolute after:inset-0">
78
+ {post.title}
79
+ </Link>
80
+ </SectionHeader>
81
+ {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" />
83
+ </div>
84
+ );
85
+
86
+ if (variant === "hero") {
87
+ return (
88
+ <article className="group relative grid items-center gap-6 md:grid-cols-[1.4fr_1fr] md:gap-10">
89
+ <BlogPostCover
90
+ src={post.coverImage}
91
+ alt={post.coverImageAlt}
92
+ sizes="(min-width: 1024px) 560px, (min-width: 768px) 58vw, 100vw"
93
+ priority={priority}
94
+ className="aspect-[16/10]"
95
+ />
96
+ {text}
97
+ </article>
98
+ );
99
+ }
100
+
101
+ if (variant === "row") {
102
+ return (
103
+ <article className="group relative grid items-center gap-4 sm:grid-cols-[10rem_1fr] sm:gap-6">
104
+ <BlogPostCover
105
+ src={post.coverImage}
106
+ alt={post.coverImageAlt}
107
+ sizes="(min-width: 640px) 160px, 100vw"
108
+ priority={priority}
109
+ className="aspect-[16/10]"
110
+ />
111
+ {text}
112
+ </article>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <article className="group relative flex flex-col gap-4">
118
+ <BlogPostCover
119
+ src={post.coverImage}
120
+ alt={post.coverImageAlt}
121
+ sizes={
122
+ variant === "large"
123
+ ? "(min-width: 1024px) 760px, 100vw"
124
+ : "(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
125
+ }
126
+ priority={priority}
127
+ className={variant === "large" ? "aspect-[16/8]" : "aspect-[16/10]"}
128
+ />
129
+ {text}
130
+ </article>
131
+ );
132
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { useFormatter, useTranslations } from "next-intl";
4
+ import { cn } from "../../../../utils/cn";
5
+ import type { PublicBlogPost } from "../../types/blog-public.types";
6
+
7
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
8
+
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.
12
+ */
13
+ export function BlogPostMeta(props: {
14
+ post: PublicBlogPost;
15
+ showAuthor?: boolean;
16
+ showDate?: boolean;
17
+ showUpdated?: boolean;
18
+ className?: string;
19
+ }) {
20
+ const { post, showAuthor, showDate, showUpdated, className } = props;
21
+ const t = useTranslations();
22
+ const format = useFormatter();
23
+
24
+ const parts: string[] = [];
25
+ if (showAuthor && post.authorName) parts.push(t("blog.public.by", { author: post.authorName }));
26
+ if (showDate && post.publishedAt) {
27
+ parts.push(format.dateTime(new Date(post.publishedAt), { dateStyle: "medium" }));
28
+ }
29
+ if (
30
+ showUpdated &&
31
+ post.publishedAt &&
32
+ post.updatedAt &&
33
+ new Date(post.updatedAt).getTime() - new Date(post.publishedAt).getTime() > ONE_DAY_MS
34
+ ) {
35
+ parts.push(t("blog.public.updated", { date: format.dateTime(new Date(post.updatedAt), { dateStyle: "medium" }) }));
36
+ }
37
+ parts.push(t("blog.public.readingTime", { minutes: post.readingMinutes }));
38
+
39
+ return (
40
+ <p className={cn("text-muted-foreground text-xs", className)}>
41
+ {parts.map((part, index) => (
42
+ <span key={index}>
43
+ {index > 0 ? " · " : null}
44
+ {part}
45
+ </span>
46
+ ))}
47
+ </p>
48
+ );
49
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { ArrowLeftIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { BlockNoteEditorContainer } from "../../../../components/editors/BlockNoteEditorContainer";
6
+ import { ContentTitle } from "../../../../components/navigations/ContentTitle";
7
+ import { SectionHeader } from "../../../../components/typography";
8
+ import { Link } from "../../../../shadcnui";
9
+ import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
10
+ import { resolvePostCategory } from "./blogPublicHelpers";
11
+ import { BlogPostCard, BlogPostCover } from "./BlogPostCard";
12
+ import { BlogPostMeta } from "./BlogPostMeta";
13
+
14
+ /**
15
+ * Public post page: back link, category, title, summary, meta, cover, body,
16
+ * then up to three posts from the same category.
17
+ */
18
+ export function BlogPostView(props: {
19
+ post: PublicBlogPost;
20
+ categories: PublicBlogCategory[];
21
+ related: PublicBlogPost[];
22
+ basePath: string;
23
+ }) {
24
+ const { post, categories, related, basePath } = props;
25
+ const t = useTranslations();
26
+ const category = resolvePostCategory(post, categories);
27
+ const more = related.slice(0, 3);
28
+
29
+ return (
30
+ <article className="px-4 pt-12 pb-20 md:pt-16">
31
+ <div className="mx-auto max-w-3xl">
32
+ <Link href={basePath} className="inline-flex items-center gap-1.5">
33
+ <ArrowLeftIcon className="size-4 rtl:rotate-180" />
34
+ {t("blog.public.back")}
35
+ </Link>
36
+ <header className="mt-8">
37
+ <ContentTitle type={category?.name} element={post.title} headingLevel={1} className="mb-0" />
38
+ {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" />
40
+ </header>
41
+ {post.coverImage ? (
42
+ <BlogPostCover
43
+ src={post.coverImage}
44
+ alt={post.coverImageAlt}
45
+ sizes="(min-width: 768px) 768px, 100vw"
46
+ priority
47
+ className="mt-8 aspect-[16/9]"
48
+ />
49
+ ) : null}
50
+ <div className="mt-8">
51
+ <BlockNoteEditorContainer id={post.id} type="blog-post" initialContent={post.body} />
52
+ </div>
53
+ </div>
54
+ {more.length > 0 ? (
55
+ <section className="mx-auto mt-16 max-w-5xl">
56
+ <div className="border-border border-t pt-10">
57
+ <SectionHeader level={2}>{t("blog.public.moreIn", { category: category?.name ?? "" })}</SectionHeader>
58
+ <div className="mt-6 grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
59
+ {more.map((relatedPost) => (
60
+ <BlogPostCard
61
+ key={relatedPost.id}
62
+ post={relatedPost}
63
+ categories={categories}
64
+ basePath={basePath}
65
+ headingLevel="h3"
66
+ />
67
+ ))}
68
+ </div>
69
+ </div>
70
+ </section>
71
+ ) : null}
72
+ </article>
73
+ );
74
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { SearchIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { FormEvent, useEffect, useState } from "react";
6
+ import { useI18nRouter } from "../../../../i18n";
7
+ import { InputGroup, InputGroupAddon, InputGroupInput } from "../../../../shadcnui";
8
+ import { blogListHref } from "./blogPublicHelpers";
9
+
10
+ /**
11
+ * Search form for the public list. The current query and category come in as
12
+ * props (the page already parsed the search params), so this component never
13
+ * reads `useSearchParams` and needs no Suspense boundary of its own.
14
+ * Submitting keeps the category and resets to page one.
15
+ */
16
+ export function BlogSearchBox(props: {
17
+ basePath: string;
18
+ query?: string;
19
+ activeCategorySlug?: string;
20
+ className?: string;
21
+ }) {
22
+ const { basePath, query, activeCategorySlug, className } = props;
23
+ const t = useTranslations();
24
+ const router = useI18nRouter();
25
+ const [value, setValue] = useState(query ?? "");
26
+
27
+ useEffect(() => setValue(query ?? ""), [query]);
28
+
29
+ const onSubmit = (event: FormEvent<HTMLFormElement>) => {
30
+ event.preventDefault();
31
+ const q = value.trim();
32
+ router.push(blogListHref(basePath, { category: activeCategorySlug, q: q || undefined }));
33
+ };
34
+
35
+ return (
36
+ <form role="search" onSubmit={onSubmit} className={className}>
37
+ <InputGroup>
38
+ <InputGroupAddon>
39
+ <SearchIcon />
40
+ </InputGroupAddon>
41
+ <InputGroupInput
42
+ type="search"
43
+ name="q"
44
+ value={value}
45
+ onChange={(event) => setValue(event.target.value)}
46
+ placeholder={t("blog.public.searchPlaceholder")}
47
+ aria-label={t("blog.public.search")}
48
+ />
49
+ </InputGroup>
50
+ </form>
51
+ );
52
+ }
@@ -0,0 +1,173 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { configureI18n } from "../../../../../i18n";
5
+ import type { PublicBlogCategory, PublicBlogPost } from "../../../types/blog-public.types";
6
+ import { BlogList } from "../BlogList";
7
+
8
+ // t interpolates its values after the key so assertions can see them.
9
+ vi.mock("next-intl", () => ({
10
+ useTranslations: () => (key: string, values?: Record<string, unknown>) =>
11
+ values ? `${key} ${Object.values(values).join(" ")}` : key,
12
+ useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
13
+ }));
14
+ vi.mock("next/image", () => ({
15
+ default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
16
+ }));
17
+
18
+ const cats: PublicBlogCategory[] = [
19
+ { id: "c1", name: "Game prep", slug: "game-prep" },
20
+ { id: "c2", name: "Product", slug: "product" },
21
+ ];
22
+
23
+ const post = (overrides: Partial<PublicBlogPost>): PublicBlogPost => ({
24
+ id: "p",
25
+ language: "en",
26
+ title: "Title",
27
+ slug: "title",
28
+ summary: "Summary",
29
+ readingMinutes: 4,
30
+ publishedAt: "2026-10-12T09:00:00.000Z",
31
+ updatedAt: "2026-10-12T09:00:00.000Z",
32
+ authorName: "Carlo Nicora",
33
+ categoryIds: ["c1"],
34
+ ...overrides,
35
+ });
36
+
37
+ const p1 = post({
38
+ id: "p1",
39
+ title: "Prep situations, not plots",
40
+ slug: "prep-situations",
41
+ summary: "Why a scene that can go three ways beats a scene that must go one way.",
42
+ coverImage: "https://cdn.test/p1.webp",
43
+ });
44
+ const p2 = post({
45
+ id: "p2",
46
+ title: "Voice attribution, explained",
47
+ slug: "voice",
48
+ summary: "How it knows who spoke.",
49
+ categoryIds: ["c2"],
50
+ });
51
+ const p3 = post({
52
+ id: "p3",
53
+ title: "Clues that point, not explain",
54
+ slug: "clues",
55
+ summary: "Make every clue a pointer.",
56
+ });
57
+
58
+ beforeEach(() => {
59
+ configureI18n({
60
+ useRouter: () => ({
61
+ push: () => {},
62
+ replace: () => {},
63
+ back: () => {},
64
+ forward: () => {},
65
+ refresh: () => {},
66
+ prefetch: () => {},
67
+ }),
68
+ useTranslations: () => (key: string) => key,
69
+ usePathname: () => "/blog",
70
+ getLocale: async () => "en",
71
+ Link: ({ href, children, prefetch: _prefetch, scroll: _scroll, ...props }: any) => (
72
+ <a href={href} {...props}>
73
+ {children}
74
+ </a>
75
+ ),
76
+ });
77
+ });
78
+
79
+ describe("BlogList", () => {
80
+ it.each(["featured", "column", "rail"] as const)("%s layout renders every post title and a summary", (layout) => {
81
+ render(
82
+ <BlogList
83
+ layout={layout}
84
+ list={{ posts: [p1, p2, p3], hasMore: false }}
85
+ categories={cats}
86
+ page={1}
87
+ basePath="/blog"
88
+ />,
89
+ );
90
+
91
+ expect(screen.getAllByRole("link", { name: /Prep situations/ })[0]).toHaveAttribute(
92
+ "href",
93
+ "/blog/prep-situations",
94
+ );
95
+ expect(screen.getAllByRole("link", { name: /Voice attribution/ })[0]).toHaveAttribute("href", "/blog/voice");
96
+ expect(screen.getAllByRole("link", { name: /Clues that point/ })[0]).toHaveAttribute("href", "/blog/clues");
97
+ expect(screen.getByText(p3.summary!)).toBeInTheDocument();
98
+ expect(screen.getAllByText(/blog\.public\.readingTime 4/).length).toBeGreaterThanOrEqual(3);
99
+ // One real h1 per page (public SEO page), carried by ContentTitle; the eyebrow is not part of it.
100
+ expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
101
+ expect(screen.getByRole("heading", { level: 1, name: "blog.public.title" })).toBeInTheDocument();
102
+ expect(screen.getByText("blog.public.eyebrow")).toBeInTheDocument();
103
+ });
104
+
105
+ it("active category chip is marked and links back to all", () => {
106
+ render(
107
+ <BlogList
108
+ layout="featured"
109
+ list={{ posts: [p1], hasMore: false }}
110
+ categories={cats}
111
+ activeCategorySlug="game-prep"
112
+ page={1}
113
+ basePath="/blog"
114
+ />,
115
+ );
116
+
117
+ const active = screen.getByRole("link", { name: "Game prep" });
118
+ expect(active).toHaveAttribute("aria-current", "page");
119
+ expect(active).toHaveAttribute("href", "/blog?category=game-prep");
120
+ expect(screen.getByRole("link", { name: "Product" })).not.toHaveAttribute("aria-current");
121
+ const all = screen.getByRole("link", { name: "blog.public.all" });
122
+ expect(all).toHaveAttribute("href", "/blog");
123
+ expect(all).not.toHaveAttribute("aria-current");
124
+ });
125
+
126
+ it("load more links to the next page keeping filters", () => {
127
+ render(
128
+ <BlogList
129
+ layout="column"
130
+ list={{ posts: [p1], hasMore: true }}
131
+ categories={cats}
132
+ activeCategorySlug="game-prep"
133
+ query="x"
134
+ page={1}
135
+ basePath="/blog"
136
+ />,
137
+ );
138
+
139
+ expect(screen.getByRole("link", { name: "blog.public.loadMore" })).toHaveAttribute(
140
+ "href",
141
+ "/blog?category=game-prep&q=x&page=2",
142
+ );
143
+ });
144
+
145
+ it("hides load more when there is nothing more", () => {
146
+ render(
147
+ <BlogList layout="rail" list={{ posts: [p1], hasMore: false }} categories={cats} page={1} basePath="/blog" />,
148
+ );
149
+ expect(screen.queryByRole("link", { name: "blog.public.loadMore" })).not.toBeInTheDocument();
150
+ });
151
+
152
+ it("empty list with a query shows noResults", () => {
153
+ render(
154
+ <BlogList
155
+ layout="featured"
156
+ list={{ posts: [], hasMore: false }}
157
+ categories={cats}
158
+ query="zzz"
159
+ page={1}
160
+ basePath="/blog"
161
+ />,
162
+ );
163
+ expect(screen.getByText("blog.public.noResults")).toBeInTheDocument();
164
+ expect(screen.queryByText("blog.public.empty")).not.toBeInTheDocument();
165
+ });
166
+
167
+ it("empty list without a query shows empty", () => {
168
+ render(
169
+ <BlogList layout="column" list={{ posts: [], hasMore: false }} categories={cats} page={1} basePath="/blog" />,
170
+ );
171
+ expect(screen.getByText("blog.public.empty")).toBeInTheDocument();
172
+ });
173
+ });
@@ -0,0 +1,128 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { configureI18n } from "../../../../../i18n";
5
+ import type { PublicBlogCategory, PublicBlogPost } from "../../../types/blog-public.types";
6
+ import { BlogPostView } from "../BlogPostView";
7
+
8
+ // t interpolates its values after the key so assertions can see them.
9
+ vi.mock("next-intl", () => ({
10
+ useTranslations: () => (key: string, values?: Record<string, unknown>) =>
11
+ values ? `${key} ${Object.values(values).join(" ")}` : key,
12
+ useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
13
+ }));
14
+ vi.mock("next/image", () => ({
15
+ default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
16
+ }));
17
+ // The real editor is a next/dynamic ssr:false import; assert on the props it receives.
18
+ vi.mock("../../../../../components/editors/BlockNoteEditorContainer", () => ({
19
+ BlockNoteEditorContainer: ({ id, type, initialContent }: { id: string; type: string; initialContent: unknown }) => (
20
+ <div data-testid="post-body" data-id={id} data-type={type}>
21
+ {JSON.stringify(initialContent)}
22
+ </div>
23
+ ),
24
+ }));
25
+
26
+ const cats: PublicBlogCategory[] = [{ id: "c1", name: "Game prep", slug: "game-prep" }];
27
+
28
+ const post = (overrides: Partial<PublicBlogPost>): PublicBlogPost => ({
29
+ id: "p",
30
+ language: "en",
31
+ title: "Title",
32
+ slug: "title",
33
+ readingMinutes: 4,
34
+ publishedAt: "2026-10-12T09:00:00.000Z",
35
+ updatedAt: "2026-10-12T09:00:00.000Z",
36
+ categoryIds: ["c1"],
37
+ ...overrides,
38
+ });
39
+
40
+ const main = post({
41
+ id: "p1",
42
+ title: "Prep situations, not plots",
43
+ slug: "prep-situations",
44
+ summary: "Why a scene that can go three ways beats a scene that must go one way.",
45
+ body: [{ type: "paragraph", content: [{ type: "text", text: "Every GM has lived that moment" }] }],
46
+ coverImage: "https://cdn.test/p1.webp",
47
+ coverImageAlt: "A table with dice",
48
+ readingMinutes: 6,
49
+ authorName: "Carlo Nicora",
50
+ updatedAt: "2026-10-15T09:00:00.000Z",
51
+ });
52
+ const r1 = post({
53
+ id: "r1",
54
+ title: "Clues that point, not explain",
55
+ slug: "clues",
56
+ summary: "Make every clue a pointer.",
57
+ });
58
+ const r2 = post({ id: "r2", title: "Three-way scenes", slug: "three-way" });
59
+
60
+ beforeEach(() => {
61
+ configureI18n({
62
+ useRouter: () => ({
63
+ push: () => {},
64
+ replace: () => {},
65
+ back: () => {},
66
+ forward: () => {},
67
+ refresh: () => {},
68
+ prefetch: () => {},
69
+ }),
70
+ useTranslations: () => (key: string) => key,
71
+ usePathname: () => "/blog/prep-situations",
72
+ getLocale: async () => "en",
73
+ Link: ({ href, children, prefetch: _prefetch, scroll: _scroll, ...props }: any) => (
74
+ <a href={href} {...props}>
75
+ {children}
76
+ </a>
77
+ ),
78
+ });
79
+ });
80
+
81
+ describe("BlogPostView", () => {
82
+ it("shows category, title, author, reading time and more-in links", () => {
83
+ render(<BlogPostView post={main} categories={cats} related={[r1, r2]} basePath="/blog" />);
84
+
85
+ expect(screen.getByRole("link", { name: /blog\.public\.back/ })).toHaveAttribute("href", "/blog");
86
+ expect(screen.getAllByText("Game prep")[0]).toBeInTheDocument();
87
+ expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
88
+ expect(screen.getByRole("heading", { level: 1, name: "Prep situations, not plots" })).toBeInTheDocument();
89
+ expect(screen.getByText(main.summary!)).toBeInTheDocument();
90
+ expect(screen.getByText("blog.public.by Carlo Nicora")).toBeInTheDocument();
91
+ expect(screen.getByText(/blog\.public\.readingTime 6/)).toBeInTheDocument();
92
+ expect(screen.getByAltText("A table with dice")).toHaveAttribute("src", "https://cdn.test/p1.webp");
93
+ const body = screen.getByTestId("post-body");
94
+ expect(body).toHaveTextContent("Every GM has lived that moment");
95
+ expect(body).toHaveAttribute("data-id", "p1");
96
+ expect(body).toHaveAttribute("data-type", "blog-post");
97
+
98
+ expect(screen.getByRole("heading", { level: 2, name: "blog.public.moreIn Game prep" })).toBeInTheDocument();
99
+ expect(screen.getByRole("link", { name: "Clues that point, not explain" })).toHaveAttribute("href", "/blog/clues");
100
+ expect(screen.getByRole("link", { name: "Three-way scenes" })).toHaveAttribute("href", "/blog/three-way");
101
+ });
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();
106
+
107
+ rerender(
108
+ <BlogPostView
109
+ post={{ ...main, updatedAt: "2026-10-12T15:00:00.000Z" }}
110
+ categories={cats}
111
+ related={[]}
112
+ basePath="/blog"
113
+ />,
114
+ );
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();
121
+ });
122
+
123
+ it("caps related posts at three", () => {
124
+ const many = [1, 2, 3, 4].map((n) => post({ id: `r${n}`, title: `Related ${n}`, slug: `related-${n}` }));
125
+ render(<BlogPostView post={main} categories={cats} related={many} basePath="/blog" />);
126
+ expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(3);
127
+ });
128
+ });
@@ -0,0 +1,30 @@
1
+ import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
2
+
3
+ /**
4
+ * Locale-free href to the public list, keeping the active filters.
5
+ * Parameter order is fixed (category, q, page) so links are stable.
6
+ */
7
+ export function blogListHref(basePath: string, params: { category?: string; q?: string; page?: number }): string {
8
+ const search = new URLSearchParams();
9
+ if (params.category) search.set("category", params.category);
10
+ if (params.q) search.set("q", params.q);
11
+ if (params.page && params.page > 1) search.set("page", String(params.page));
12
+ const query = search.toString();
13
+ return query ? `${basePath}?${query}` : basePath;
14
+ }
15
+
16
+ export function blogPostHref(basePath: string, slug: string): string {
17
+ return `${basePath}/${encodeURIComponent(slug)}`;
18
+ }
19
+
20
+ /** The first of the post's categories that the visitor's category list knows. */
21
+ export function resolvePostCategory(
22
+ post: PublicBlogPost,
23
+ categories: PublicBlogCategory[],
24
+ ): PublicBlogCategory | undefined {
25
+ for (const id of post.categoryIds) {
26
+ const category = categories.find((c) => c.id === id);
27
+ if (category) return category;
28
+ }
29
+ return undefined;
30
+ }
@@ -0,0 +1,3 @@
1
+ export { BlogList } from "./BlogList";
2
+ export type { BlogListProps } from "./BlogList";
3
+ export { BlogPostView } from "./BlogPostView";