create-feedbax 0.1.0 → 0.2.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 (210) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +1261 -26
  4. package/dist/index.js +1402 -160
  5. package/package.json +40 -14
  6. package/template/.feedbax-template.sha256 +1 -0
  7. package/template/.gitignore.template +6 -0
  8. package/template/.npmignore.template +5 -0
  9. package/template/.prettierignore.template +4 -0
  10. package/template/LICENSE +201 -0
  11. package/template/components.json +25 -0
  12. package/template/eslint.config.mjs +25 -0
  13. package/template/package.json +70 -14
  14. package/template/public/feedbax-mark.svg +4 -0
  15. package/template/public/robots.txt +3 -0
  16. package/template/src/authorized-draft-post.test.tsx +212 -0
  17. package/template/src/authorized-draft-post.tsx +204 -0
  18. package/template/src/authorized-draft-query.ts +74 -0
  19. package/template/src/browser-post-state.test.ts +120 -0
  20. package/template/src/browser-post-state.ts +134 -0
  21. package/template/src/browser-vote-state.test.ts +14 -0
  22. package/template/src/browser-vote-state.ts +27 -0
  23. package/template/src/capability-authorization-error.ts +3 -0
  24. package/template/src/changelog-page.test.tsx +139 -0
  25. package/template/src/changelog-page.tsx +219 -0
  26. package/template/src/changelog-query.ts +13 -0
  27. package/template/src/changelog-runtime.ts +27 -0
  28. package/template/src/cloudflare-turnstile.ts +31 -0
  29. package/template/src/cloudflare-workers-test.ts +3 -0
  30. package/template/src/cloudflare-workers.d.ts +18 -0
  31. package/template/src/comment-creation.test.tsx +270 -0
  32. package/template/src/comment-profile.ts +5 -0
  33. package/template/src/comment-thread-page.ts +18 -0
  34. package/template/src/components/command-palette-context.tsx +71 -0
  35. package/template/src/components/command-palette.tsx +246 -0
  36. package/template/src/components/create-post-trigger.tsx +44 -0
  37. package/template/src/components/device-profile-provider.tsx +309 -0
  38. package/template/src/components/header.tsx +95 -0
  39. package/template/src/components/loader.tsx +10 -0
  40. package/template/src/components/profile-menu.tsx +89 -0
  41. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  42. package/template/src/components/ui/components/attachment.tsx +207 -0
  43. package/template/src/components/ui/components/avatar.tsx +107 -0
  44. package/template/src/components/ui/components/badge.tsx +52 -0
  45. package/template/src/components/ui/components/bubble.tsx +128 -0
  46. package/template/src/components/ui/components/button-group.tsx +87 -0
  47. package/template/src/components/ui/components/button.tsx +56 -0
  48. package/template/src/components/ui/components/card.tsx +103 -0
  49. package/template/src/components/ui/components/checkbox.tsx +26 -0
  50. package/template/src/components/ui/components/command.tsx +194 -0
  51. package/template/src/components/ui/components/dialog.tsx +154 -0
  52. package/template/src/components/ui/components/drawer.tsx +229 -0
  53. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  54. package/template/src/components/ui/components/empty.tsx +101 -0
  55. package/template/src/components/ui/components/field.tsx +236 -0
  56. package/template/src/components/ui/components/input-group.tsx +155 -0
  57. package/template/src/components/ui/components/input.tsx +20 -0
  58. package/template/src/components/ui/components/item.tsx +201 -0
  59. package/template/src/components/ui/components/kbd.tsx +26 -0
  60. package/template/src/components/ui/components/label.tsx +18 -0
  61. package/template/src/components/ui/components/marker.tsx +71 -0
  62. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  63. package/template/src/components/ui/components/message.tsx +92 -0
  64. package/template/src/components/ui/components/native-select.tsx +65 -0
  65. package/template/src/components/ui/components/popover.tsx +88 -0
  66. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  67. package/template/src/components/ui/components/radio-group.tsx +36 -0
  68. package/template/src/components/ui/components/scroll-area.tsx +52 -0
  69. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  70. package/template/src/components/ui/components/select.tsx +199 -0
  71. package/template/src/components/ui/components/separator.tsx +23 -0
  72. package/template/src/components/ui/components/sheet.tsx +135 -0
  73. package/template/src/components/ui/components/skeleton.tsx +13 -0
  74. package/template/src/components/ui/components/sonner.tsx +43 -0
  75. package/template/src/components/ui/components/spinner.tsx +16 -0
  76. package/template/src/components/ui/components/tabs.tsx +80 -0
  77. package/template/src/components/ui/components/textarea.tsx +18 -0
  78. package/template/src/components/ui/components/tooltip.tsx +64 -0
  79. package/template/src/components/ui/hooks/.gitkeep +0 -0
  80. package/template/src/components/ui/lib/utils.ts +6 -0
  81. package/template/src/components/ui/styles/globals.css +151 -0
  82. package/template/src/create-post-overlay.tsx +82 -0
  83. package/template/src/draft-post-list.tsx +39 -0
  84. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  85. package/template/src/feedback-cache-invalidation.ts +97 -0
  86. package/template/src/feedback-runtime.ts +38 -0
  87. package/template/src/feedbax/changelog/index.test.ts +156 -0
  88. package/template/src/feedbax/changelog/index.ts +206 -0
  89. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  90. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  91. package/template/src/feedbax/config/index.test.ts +111 -0
  92. package/template/src/feedbax/config/index.ts +271 -0
  93. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  94. package/template/src/feedbax/feedback/index.test.ts +900 -0
  95. package/template/src/feedbax/feedback/index.ts +942 -0
  96. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  97. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  98. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  99. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  100. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  101. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  102. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  103. package/template/src/feedbax.test-fixture.ts +64 -0
  104. package/template/src/feedbax.ts +69 -0
  105. package/template/src/index.css +2138 -0
  106. package/template/src/lazy-create-post-overlay.tsx +6 -0
  107. package/template/src/lazy-post-detail-overlay.tsx +10 -0
  108. package/template/src/masked-post-link.tsx +27 -0
  109. package/template/src/overlay-post-detail.test.tsx +130 -0
  110. package/template/src/overlay-post-detail.tsx +92 -0
  111. package/template/src/portal-comments.test.ts +375 -0
  112. package/template/src/portal-comments.ts +384 -0
  113. package/template/src/portal-features.test.tsx +150 -0
  114. package/template/src/portal-feedback-form.tsx +515 -0
  115. package/template/src/portal-feedback-runtime.test.ts +32 -0
  116. package/template/src/portal-feedback-runtime.ts +55 -0
  117. package/template/src/portal-feedback-server-function.ts +93 -0
  118. package/template/src/portal-feedback-submission.test.ts +307 -0
  119. package/template/src/portal-feedback-submission.ts +214 -0
  120. package/template/src/portal-head.ts +58 -0
  121. package/template/src/portal-server-entry.ts +64 -0
  122. package/template/src/portal-voting.test.ts +106 -0
  123. package/template/src/portal-voting.ts +149 -0
  124. package/template/src/post-badges.tsx +27 -0
  125. package/template/src/post-creation-flow.test.tsx +230 -0
  126. package/template/src/post-creation-flow.tsx +88 -0
  127. package/template/src/post-detail-overlay.test.tsx +114 -0
  128. package/template/src/post-detail-overlay.tsx +67 -0
  129. package/template/src/post-feed-controls.test.tsx +239 -0
  130. package/template/src/post-feed-controls.tsx +298 -0
  131. package/template/src/post-presentation.ts +37 -0
  132. package/template/src/post-queries.test.ts +158 -0
  133. package/template/src/post-queries.ts +26 -0
  134. package/template/src/post-results.tsx +165 -0
  135. package/template/src/public-cache-policy.test.ts +141 -0
  136. package/template/src/public-cache-policy.ts +119 -0
  137. package/template/src/public-cache-tags.ts +7 -0
  138. package/template/src/public-changelog-page.ts +15 -0
  139. package/template/src/public-changelog-server-function.ts +27 -0
  140. package/template/src/public-date.ts +11 -0
  141. package/template/src/public-failure-state.test.tsx +69 -0
  142. package/template/src/public-feedback-index.tsx +124 -0
  143. package/template/src/public-feedback-page.test.tsx +290 -0
  144. package/template/src/public-feedback-page.ts +75 -0
  145. package/template/src/public-feedback-server-function.ts +22 -0
  146. package/template/src/public-portal-error.tsx +27 -0
  147. package/template/src/public-post-detail.tsx +682 -0
  148. package/template/src/public-post-page.test.tsx +173 -0
  149. package/template/src/public-post-page.ts +15 -0
  150. package/template/src/public-post-route.test.tsx +214 -0
  151. package/template/src/public-post-route.tsx +171 -0
  152. package/template/src/public-post-server-function.ts +48 -0
  153. package/template/src/public-post-unavailable.tsx +10 -0
  154. package/template/src/public-roadmap-page.test.tsx +139 -0
  155. package/template/src/public-roadmap-page.ts +24 -0
  156. package/template/src/public-roadmap-route.test.tsx +218 -0
  157. package/template/src/public-roadmap-route.tsx +50 -0
  158. package/template/src/public-roadmap-server-function.ts +44 -0
  159. package/template/src/public-roadmap-view.tsx +378 -0
  160. package/template/src/public-shell.test.tsx +518 -0
  161. package/template/src/roadmap-query.ts +21 -0
  162. package/template/src/root-branding.test.ts +53 -0
  163. package/template/src/routeTree.gen.ts +131 -0
  164. package/template/src/routed-post-creation-form.tsx +28 -0
  165. package/template/src/router.tsx +25 -0
  166. package/template/src/routes/__root.tsx +116 -0
  167. package/template/src/routes/changelog.tsx +39 -0
  168. package/template/src/routes/index.tsx +61 -0
  169. package/template/src/routes/p.$slug.tsx +16 -0
  170. package/template/src/routes/roadmap.tsx +11 -0
  171. package/template/src/routes/submit.tsx +28 -0
  172. package/template/src/safe-public-failure.ts +45 -0
  173. package/template/src/server.ts +111 -0
  174. package/template/src/similar-posts.tsx +45 -0
  175. package/template/src/submit-route.test.tsx +134 -0
  176. package/template/src/trusted-feedback-handler.test.ts +425 -0
  177. package/template/src/trusted-feedback-handler.ts +181 -0
  178. package/template/src/trusted-feedback-runtime.ts +17 -0
  179. package/template/src/turnstile-configuration.test.ts +37 -0
  180. package/template/src/turnstile-configuration.ts +19 -0
  181. package/template/src/use-media-query.ts +17 -0
  182. package/template/src/vote-toggle.test.tsx +171 -0
  183. package/template/src/vote-toggle.tsx +211 -0
  184. package/template/tsconfig.json +45 -0
  185. package/template/vite.config.ts +20 -0
  186. package/template/vitest.config.ts +4 -0
  187. package/template/vitest.setup.ts +3 -0
  188. package/template/wrangler.jsonc +35 -0
  189. package/dist/cli.d.ts +0 -3
  190. package/dist/cli.d.ts.map +0 -1
  191. package/dist/cli.js.map +0 -1
  192. package/dist/index.d.ts +0 -21
  193. package/dist/index.d.ts.map +0 -1
  194. package/dist/index.js.map +0 -1
  195. package/template/.env.example +0 -4
  196. package/template/README.md +0 -7
  197. package/template/apps/portal/components.json +0 -20
  198. package/template/apps/portal/package.json +0 -31
  199. package/template/apps/portal/src/lib/utils.ts +0 -6
  200. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  201. package/template/apps/portal/src/router.tsx +0 -10
  202. package/template/apps/portal/src/routes/__root.tsx +0 -31
  203. package/template/apps/portal/src/routes/index.tsx +0 -35
  204. package/template/apps/portal/src/styles.css +0 -91
  205. package/template/apps/portal/tsconfig.json +0 -15
  206. package/template/apps/portal/vite.config.ts +0 -13
  207. package/template/feedbax.config.ts +0 -13
  208. package/template/feedbax.jsonc +0 -11
  209. package/template/pnpm-workspace.yaml +0 -3
  210. package/template/turbo.json +0 -8
@@ -0,0 +1,378 @@
1
+ import type {
2
+ PublicPost,
3
+ PublicPostRoadmap,
4
+ PublicRoadmapPage,
5
+ PublicRoadmapQuery,
6
+ RoadmapStatus,
7
+ } from "@feedbax/feedback";
8
+ import { Button } from "@feedbax/ui/components/button";
9
+ import { ButtonGroup } from "@feedbax/ui/components/button-group";
10
+ import { Card } from "@feedbax/ui/components/card";
11
+ import {
12
+ Empty,
13
+ EmptyDescription,
14
+ EmptyHeader,
15
+ EmptyMedia,
16
+ EmptyTitle,
17
+ } from "@feedbax/ui/components/empty";
18
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
19
+ import { Spinner } from "@feedbax/ui/components/spinner";
20
+ import { Tabs, TabsList, TabsTrigger } from "@feedbax/ui/components/tabs";
21
+ import { CircleDashed } from "lucide-react";
22
+ import { useEffect, useState, type ReactNode } from "react";
23
+
24
+ import { CommandPaletteTrigger } from "./components/command-palette-context";
25
+ import { CreatePostTrigger } from "./components/create-post-trigger";
26
+ import { PostLink } from "./masked-post-link";
27
+ import { PostTypeBadge } from "./post-badges";
28
+ import type { FetchPublicRoadmapStatusPage } from "./roadmap-query";
29
+ import { useMediaQuery } from "./use-media-query";
30
+
31
+ const roadmapGroups = [
32
+ {
33
+ status: "Planned",
34
+ heading: "Planned",
35
+ headingId: "roadmap-planned",
36
+ dot: "bg-status-planned",
37
+ },
38
+ {
39
+ status: "In Progress",
40
+ heading: "In progress",
41
+ headingId: "roadmap-in-progress",
42
+ dot: "bg-status-in-progress",
43
+ },
44
+ {
45
+ status: "Shipped",
46
+ heading: "Shipped",
47
+ headingId: "roadmap-shipped",
48
+ dot: "bg-status-shipped",
49
+ },
50
+ ] as const;
51
+
52
+ const mobileMediaQuery = "(max-width: 1023px)";
53
+
54
+ export function PublicRoadmapView({
55
+ roadmap,
56
+ loadMore,
57
+ maskPostLinks = false,
58
+ }: {
59
+ roadmap: PublicPostRoadmap;
60
+ loadMore?: FetchPublicRoadmapStatusPage;
61
+ maskPostLinks?: boolean;
62
+ }) {
63
+ const mobile = useMediaQuery(mobileMediaQuery);
64
+ const [activeStatus, setActiveStatus] = useState<RoadmapStatus>("Planned");
65
+ const [columns, setColumns] = useState(roadmap);
66
+ const [loading, setLoading] = useState<Partial<Record<RoadmapStatus, true>>>(
67
+ {},
68
+ );
69
+ const [errors, setErrors] = useState<Partial<Record<RoadmapStatus, true>>>(
70
+ {},
71
+ );
72
+ const [loadStatusMessage, setLoadStatusMessage] = useState("");
73
+
74
+ useEffect(() => setColumns(roadmap), [roadmap]);
75
+
76
+ const visibleGroups = mobile
77
+ ? roadmapGroups.filter(({ status }) => status === activeStatus)
78
+ : roadmapGroups;
79
+
80
+ async function loadNextPage(query: PublicRoadmapQuery) {
81
+ if (!loadMore || loading[query.status]) return;
82
+ const group = roadmapGroups.find(({ status }) => status === query.status);
83
+ if (!group) return;
84
+ setLoadStatusMessage(`Loading more ${group.heading} Posts.`);
85
+ setLoading((current) => ({ ...current, [query.status]: true }));
86
+ setErrors((current) => {
87
+ const next = { ...current };
88
+ delete next[query.status];
89
+ return next;
90
+ });
91
+ try {
92
+ const nextPage = await loadMore(query);
93
+ setColumns((current) => ({
94
+ ...current,
95
+ [query.status]: appendRoadmapPage(current[query.status], nextPage),
96
+ }));
97
+ setLoadStatusMessage(
98
+ `${nextPage.items.length} more ${group.heading} ${nextPage.items.length === 1 ? "Post" : "Posts"} loaded.`,
99
+ );
100
+ } catch {
101
+ setErrors((current) => ({ ...current, [query.status]: true }));
102
+ setLoadStatusMessage(`Couldn’t load more ${group.heading} Posts.`);
103
+ } finally {
104
+ setLoading((current) => {
105
+ const next = { ...current };
106
+ delete next[query.status];
107
+ return next;
108
+ });
109
+ }
110
+ }
111
+
112
+ return (
113
+ <main className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10">
114
+ <RoadmapIntro />
115
+
116
+ <ButtonGroup className="mt-8 grid w-full grid-cols-[1fr_auto] sm:ml-auto sm:flex sm:w-fit lg:-mt-12">
117
+ <CommandPaletteTrigger className="h-11 justify-start lg:h-10" />
118
+ <CreatePostTrigger
119
+ className="h-11 px-5 text-sm lg:h-10"
120
+ contextual={maskPostLinks}
121
+ />
122
+ </ButtonGroup>
123
+
124
+ {mobile ? (
125
+ <Tabs
126
+ className="mt-6"
127
+ value={activeStatus}
128
+ onValueChange={(value) => {
129
+ if (isRoadmapStatus(value)) setActiveStatus(value);
130
+ }}
131
+ >
132
+ <TabsList className="grid h-12 w-full grid-cols-3" variant="line">
133
+ {roadmapGroups.map(({ status, heading }) => (
134
+ <TabsTrigger key={status} value={status} data-status={status}>
135
+ {heading}{" "}
136
+ <strong className="ml-2 font-medium">
137
+ {columns[status].totalCount}
138
+ </strong>
139
+ </TabsTrigger>
140
+ ))}
141
+ </TabsList>
142
+ </Tabs>
143
+ ) : null}
144
+
145
+ <p aria-label="Roadmap status" className="sr-only" role="status">
146
+ {loadStatusMessage}
147
+ </p>
148
+
149
+ <div
150
+ className={`mt-8 grid gap-5 ${mobile ? "grid-cols-1" : "lg:grid-cols-3"}`}
151
+ aria-label="Product roadmap"
152
+ >
153
+ {visibleGroups.map(({ status, heading, headingId, dot }) => {
154
+ const column = columns[status];
155
+ return (
156
+ <RoadmapColumn
157
+ count={
158
+ <span data-roadmap-count className="text-muted-foreground">
159
+ {column.totalCount}
160
+ </span>
161
+ }
162
+ dot={dot}
163
+ heading={heading}
164
+ headingId={headingId}
165
+ key={status}
166
+ >
167
+ {column.items.length === 0 ? (
168
+ <RoadmapEmptyState heading={heading} />
169
+ ) : (
170
+ <ol className="grid gap-3">
171
+ {column.items.map((item) => (
172
+ <RoadmapItem
173
+ item={item}
174
+ key={item.slug}
175
+ masked={maskPostLinks}
176
+ />
177
+ ))}
178
+ </ol>
179
+ )}
180
+ {errors[status] ? (
181
+ <div
182
+ role="alert"
183
+ className="mt-4 flex items-center justify-between gap-3 border p-3"
184
+ >
185
+ <p className="text-xs text-muted-foreground">
186
+ Couldn’t load more Posts.
187
+ </p>
188
+ <Button
189
+ type="button"
190
+ variant="outline"
191
+ onClick={() => {
192
+ if (column.nextCursor)
193
+ void loadNextPage({
194
+ status,
195
+ cursor: column.nextCursor,
196
+ });
197
+ }}
198
+ >
199
+ Try again
200
+ </Button>
201
+ </div>
202
+ ) : null}
203
+ {column.nextCursor && !errors[status] && loadMore ? (
204
+ <Button
205
+ aria-label={`Load more ${heading} Posts`}
206
+ className="mt-4 h-10 w-full gap-2"
207
+ disabled={loading[status] === true}
208
+ type="button"
209
+ variant="ghost"
210
+ onClick={() =>
211
+ void loadNextPage({
212
+ status,
213
+ cursor: column.nextCursor,
214
+ })
215
+ }
216
+ >
217
+ {loading[status] ? (
218
+ <>
219
+ <Spinner aria-hidden="true" /> Loading…
220
+ </>
221
+ ) : (
222
+ "Load more"
223
+ )}
224
+ </Button>
225
+ ) : null}
226
+ </RoadmapColumn>
227
+ );
228
+ })}
229
+ </div>
230
+ </main>
231
+ );
232
+ }
233
+
234
+ export function PublicRoadmapSkeleton() {
235
+ return (
236
+ <main
237
+ className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10"
238
+ aria-label="Loading roadmap"
239
+ aria-busy="true"
240
+ >
241
+ <RoadmapIntro />
242
+ <div className="mt-20 grid gap-5 lg:grid-cols-3">
243
+ {roadmapGroups.map(({ status, heading, headingId, dot }) => (
244
+ <RoadmapColumn
245
+ count={<Skeleton className="h-4 w-6" />}
246
+ dot={dot}
247
+ heading={heading}
248
+ headingId={headingId}
249
+ key={status}
250
+ skeleton
251
+ >
252
+ <div className="grid gap-3">
253
+ {Array.from({ length: 2 }, (_, index) => (
254
+ <div className="border p-5" key={index}>
255
+ <Skeleton className="h-5 w-2/3" />
256
+ <Skeleton className="mt-3 h-4 w-full" />
257
+ <Skeleton className="mt-5 h-7 w-1/2" />
258
+ </div>
259
+ ))}
260
+ </div>
261
+ </RoadmapColumn>
262
+ ))}
263
+ </div>
264
+ <span className="sr-only">Loading roadmap Posts…</span>
265
+ </main>
266
+ );
267
+ }
268
+
269
+ function RoadmapColumn({
270
+ children,
271
+ count,
272
+ dot,
273
+ heading,
274
+ headingId,
275
+ skeleton = false,
276
+ }: {
277
+ children: ReactNode;
278
+ count: ReactNode;
279
+ dot: string;
280
+ heading: string;
281
+ headingId: string;
282
+ skeleton?: boolean;
283
+ }) {
284
+ return (
285
+ <Card
286
+ className="min-w-0 gap-0 self-start p-4"
287
+ aria-labelledby={headingId}
288
+ {...(skeleton ? { "data-roadmap-skeleton-column": "" } : {})}
289
+ >
290
+ <header className="flex items-center justify-between px-1 pb-4">
291
+ <h2
292
+ className="flex items-center gap-2 text-sm font-medium"
293
+ id={headingId}
294
+ >
295
+ <span className={`size-2.5 rounded-full ${dot}`} aria-hidden="true" />
296
+ {heading}
297
+ </h2>
298
+ {count}
299
+ </header>
300
+ {children}
301
+ </Card>
302
+ );
303
+ }
304
+
305
+ function RoadmapIntro() {
306
+ return (
307
+ <section aria-labelledby="roadmap-heading">
308
+ <h1
309
+ className="text-3xl font-semibold tracking-tight"
310
+ id="roadmap-heading"
311
+ >
312
+ Roadmap
313
+ </h1>
314
+ <p className="mt-2 text-base text-muted-foreground">
315
+ What we’re planning, building, and shipping.
316
+ </p>
317
+ </section>
318
+ );
319
+ }
320
+
321
+ function RoadmapEmptyState({ heading }: { heading: string }) {
322
+ return (
323
+ <Empty className="min-h-56 border">
324
+ <EmptyHeader>
325
+ <EmptyMedia variant="icon">
326
+ <CircleDashed aria-hidden="true" />
327
+ </EmptyMedia>
328
+ <EmptyTitle>No Posts here yet.</EmptyTitle>
329
+ <EmptyDescription>
330
+ {heading} Posts will appear here when they’re available.
331
+ </EmptyDescription>
332
+ </EmptyHeader>
333
+ </Empty>
334
+ );
335
+ }
336
+
337
+ function RoadmapItem({ item, masked }: { item: PublicPost; masked: boolean }) {
338
+ return (
339
+ <li>
340
+ <PostLink
341
+ slug={item.slug}
342
+ contextual={masked}
343
+ className="group block border bg-background/30 transition-colors duration-150 hover:bg-muted/30"
344
+ >
345
+ <article className="p-5">
346
+ <h3 className="text-base font-medium group-hover:text-foreground">
347
+ {item.title}
348
+ </h3>
349
+ <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
350
+ {item.description}
351
+ </p>
352
+ <div
353
+ className="mt-4 flex flex-wrap items-center gap-3 text-xs text-muted-foreground"
354
+ role="group"
355
+ aria-label="Post metadata"
356
+ >
357
+ <PostTypeBadge type={item.type} />
358
+ </div>
359
+ </article>
360
+ </PostLink>
361
+ </li>
362
+ );
363
+ }
364
+
365
+ function appendRoadmapPage(
366
+ current: PublicRoadmapPage,
367
+ next: PublicRoadmapPage,
368
+ ): PublicRoadmapPage {
369
+ return {
370
+ items: [...current.items, ...next.items],
371
+ ...(next.nextCursor ? { nextCursor: next.nextCursor } : {}),
372
+ totalCount: next.totalCount,
373
+ };
374
+ }
375
+
376
+ function isRoadmapStatus(value: unknown): value is RoadmapStatus {
377
+ return roadmapGroups.some(({ status }) => status === value);
378
+ }