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,298 @@
1
+ import type { PostStatus, PostType, PublicPostQuery } from "@feedbax/feedback";
2
+ import type { PortalFeatures } from "@feedbax/config";
3
+ import { Button } from "@feedbax/ui/components/button";
4
+ import { ButtonGroup } from "@feedbax/ui/components/button-group";
5
+ import { Card } from "@feedbax/ui/components/card";
6
+ import {
7
+ Sheet,
8
+ SheetContent,
9
+ SheetDescription,
10
+ SheetHeader,
11
+ SheetTitle,
12
+ SheetTrigger,
13
+ } from "@feedbax/ui/components/sheet";
14
+ import { List, ListFilter } from "lucide-react";
15
+ import { type ComponentType } from "react";
16
+
17
+ import { CommandPaletteTrigger } from "./components/command-palette-context";
18
+ import { CreatePostTrigger } from "./components/create-post-trigger";
19
+ import { postStatuses, postTypes } from "./public-feedback-page";
20
+ import {
21
+ postStatusPresentation,
22
+ postTypePresentation,
23
+ } from "./post-presentation";
24
+ import { useMediaQuery } from "./use-media-query";
25
+ import feedbax from "./feedbax";
26
+
27
+ type SearchChange = (search: PublicPostQuery) => void;
28
+
29
+ const boards = [
30
+ { label: "All posts", type: undefined, icon: List },
31
+ ...postTypes.map((type) => ({
32
+ label: postTypePresentation[type].boardLabel,
33
+ type,
34
+ icon: postTypePresentation[type].Icon,
35
+ })),
36
+ ] as const satisfies readonly {
37
+ label: string;
38
+ type: PostType | undefined;
39
+ icon: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
40
+ }[];
41
+
42
+ const roadmapStatuses = [
43
+ "Planned",
44
+ "In Progress",
45
+ "Shipped",
46
+ ] as const satisfies readonly PostStatus[];
47
+
48
+ const sortOptions = [
49
+ { label: "Top", value: "top" },
50
+ { label: "New", value: "new" },
51
+ ] as const satisfies readonly {
52
+ label: string;
53
+ value: NonNullable<PublicPostQuery["sort"]>;
54
+ }[];
55
+
56
+ const mobileMediaQuery = "(max-width: 1023px)";
57
+
58
+ export function PostFeedControls({
59
+ search,
60
+ onSearchChange,
61
+ pending = false,
62
+ contextualCreatePost = false,
63
+ features = feedbax.features,
64
+ }: {
65
+ search: PublicPostQuery;
66
+ onSearchChange?: SearchChange;
67
+ pending?: boolean;
68
+ contextualCreatePost?: boolean;
69
+ features?: PortalFeatures;
70
+ }) {
71
+ const mobile = useMediaQuery(mobileMediaQuery);
72
+ const newPost = (
73
+ <CreatePostTrigger
74
+ className={
75
+ mobile
76
+ ? "order-2 h-12 w-full px-5 text-sm"
77
+ : "order-3 h-10 w-auto px-5 text-sm"
78
+ }
79
+ contextual={contextualCreatePost}
80
+ />
81
+ );
82
+
83
+ return (
84
+ <div
85
+ className="flex min-w-0 flex-1 flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
86
+ role="group"
87
+ aria-label="Post feed controls"
88
+ aria-busy={pending || undefined}
89
+ >
90
+ {pending ? <span className="sr-only">Updating Posts…</span> : null}
91
+ <ButtonGroup
92
+ aria-label="Sort Posts"
93
+ className={
94
+ mobile
95
+ ? `order-1 grid h-12 w-full ${features.voting ? "grid-cols-2" : "grid-cols-1"}`
96
+ : `order-1 grid h-10 ${features.voting ? "w-48 grid-cols-2" : "w-24 grid-cols-1"}`
97
+ }
98
+ >
99
+ {sortOptions
100
+ .filter(({ value }) => features.voting || value === "new")
101
+ .map(({ label, value }) => (
102
+ <Button
103
+ key={value}
104
+ type="button"
105
+ variant="outline"
106
+ aria-pressed={
107
+ (search.sort ?? (features.voting ? "top" : "new")) === value
108
+ }
109
+ className={`${mobile ? "h-12" : "h-10"} px-5 text-sm aria-pressed:bg-muted aria-pressed:text-foreground`}
110
+ onClick={() =>
111
+ onSearchChange?.({
112
+ ...search,
113
+ cursor: undefined,
114
+ sort: value,
115
+ })
116
+ }
117
+ >
118
+ {label}
119
+ </Button>
120
+ ))}
121
+ </ButtonGroup>
122
+ {mobile ? (
123
+ <>
124
+ {newPost}
125
+ <div className="order-3 grid grid-cols-[minmax(0,1fr)_auto] gap-3">
126
+ <CommandPaletteTrigger className="h-12 w-full justify-start" />
127
+ <MobilePostFilters
128
+ search={search}
129
+ onSearchChange={onSearchChange}
130
+ />
131
+ </div>
132
+ </>
133
+ ) : (
134
+ <>
135
+ <CommandPaletteTrigger className="order-2 ml-auto justify-center" />
136
+ {newPost}
137
+ </>
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ export function BoardNavigation({
144
+ search,
145
+ onSearchChange,
146
+ headingId = "boards-heading",
147
+ ariaLabel,
148
+ }: {
149
+ search: PublicPostQuery;
150
+ onSearchChange?: SearchChange;
151
+ headingId?: string;
152
+ ariaLabel?: string;
153
+ }) {
154
+ const activeType = search.types?.length === 1 ? search.types[0] : undefined;
155
+ const hasNoType = !search.types?.length;
156
+
157
+ return (
158
+ <nav
159
+ aria-labelledby={ariaLabel ? undefined : headingId}
160
+ aria-label={ariaLabel}
161
+ >
162
+ <h2 id={headingId} className="mb-3 text-sm font-medium">
163
+ Boards
164
+ </h2>
165
+ <Card className="gap-0 overflow-hidden py-0">
166
+ {boards.map(({ label, type, icon: Icon }) => {
167
+ const active = type ? activeType === type : hasNoType;
168
+ return (
169
+ <Button
170
+ key={label}
171
+ type="button"
172
+ variant="ghost"
173
+ aria-pressed={active}
174
+ className="h-12 w-full justify-start gap-3 border-b px-4 text-sm text-muted-foreground last:border-b-0 aria-pressed:bg-muted aria-pressed:text-foreground"
175
+ onClick={() =>
176
+ onSearchChange?.({
177
+ ...search,
178
+ cursor: undefined,
179
+ types: !type || active ? undefined : [type],
180
+ })
181
+ }
182
+ >
183
+ <Icon className="size-4" aria-hidden="true" />
184
+ {label}
185
+ </Button>
186
+ );
187
+ })}
188
+ </Card>
189
+ </nav>
190
+ );
191
+ }
192
+
193
+ export function StatusNavigation({
194
+ search,
195
+ onSearchChange,
196
+ headingId = "status-filters-heading",
197
+ ariaLabel = "Status filters",
198
+ statuses = roadmapStatuses,
199
+ }: {
200
+ search: PublicPostQuery;
201
+ onSearchChange?: SearchChange;
202
+ headingId?: string;
203
+ ariaLabel?: string;
204
+ statuses?: readonly PostStatus[];
205
+ }) {
206
+ const selected = search.statuses ?? [];
207
+
208
+ return (
209
+ <section aria-labelledby={headingId}>
210
+ <h2 id={headingId} className="mb-3 text-sm font-medium">
211
+ Status
212
+ </h2>
213
+ <Card
214
+ className="gap-0 overflow-hidden py-0"
215
+ role="group"
216
+ aria-label={ariaLabel}
217
+ >
218
+ {statuses.map((status) => {
219
+ const { Icon, label } = postStatusPresentation[status];
220
+ const active = selected.includes(status);
221
+ const nextStatuses = active
222
+ ? selected.filter((candidate) => candidate !== status)
223
+ : [...selected, status];
224
+ return (
225
+ <Button
226
+ key={status}
227
+ type="button"
228
+ variant="ghost"
229
+ aria-pressed={active}
230
+ className="h-12 w-full justify-start gap-3 border-b px-4 text-sm text-muted-foreground last:border-b-0 aria-pressed:bg-muted aria-pressed:text-foreground"
231
+ onClick={() =>
232
+ onSearchChange?.({
233
+ ...search,
234
+ cursor: undefined,
235
+ statuses: nextStatuses.length ? nextStatuses : undefined,
236
+ })
237
+ }
238
+ >
239
+ <Icon className="size-4" aria-hidden="true" />
240
+ {label}
241
+ </Button>
242
+ );
243
+ })}
244
+ </Card>
245
+ </section>
246
+ );
247
+ }
248
+
249
+ export function MobilePostFilters({
250
+ search,
251
+ onSearchChange,
252
+ }: {
253
+ search: PublicPostQuery;
254
+ onSearchChange?: SearchChange;
255
+ }) {
256
+ return (
257
+ <Sheet>
258
+ <SheetTrigger
259
+ render={
260
+ <Button
261
+ className="h-12 gap-2 px-4 text-sm lg:hidden"
262
+ type="button"
263
+ variant="outline"
264
+ />
265
+ }
266
+ >
267
+ <ListFilter aria-hidden="true" />
268
+ Filters
269
+ </SheetTrigger>
270
+ <SheetContent
271
+ className="w-[min(100%,24rem)] overflow-y-auto lg:hidden"
272
+ side="right"
273
+ >
274
+ <SheetHeader className="border-b p-6 pr-14">
275
+ <SheetTitle className="text-lg">Filter Posts</SheetTitle>
276
+ <SheetDescription>
277
+ Choose a Board and combine any Post Statuses.
278
+ </SheetDescription>
279
+ </SheetHeader>
280
+ <div className="space-y-8 p-6">
281
+ <BoardNavigation
282
+ search={search}
283
+ onSearchChange={onSearchChange}
284
+ headingId="mobile-boards-heading"
285
+ ariaLabel="Mobile Boards"
286
+ />
287
+ <StatusNavigation
288
+ search={search}
289
+ onSearchChange={onSearchChange}
290
+ headingId="mobile-status-filters-heading"
291
+ ariaLabel="Mobile Status filters"
292
+ statuses={postStatuses}
293
+ />
294
+ </div>
295
+ </SheetContent>
296
+ </Sheet>
297
+ );
298
+ }
@@ -0,0 +1,37 @@
1
+ import type { PostStatus, PostType } from "@feedbax/feedback";
2
+ import {
3
+ Bug,
4
+ CalendarDays,
5
+ CheckCircle2,
6
+ Circle,
7
+ CircleDot,
8
+ Lightbulb,
9
+ MessageCircle,
10
+ Search,
11
+ XCircle,
12
+ type LucideIcon,
13
+ } from "lucide-react";
14
+
15
+ export const postTypePresentation = {
16
+ "Feature Request": {
17
+ Icon: Lightbulb,
18
+ boardLabel: "Feature requests",
19
+ },
20
+ "Bug Report": {
21
+ Icon: Bug,
22
+ boardLabel: "Bug reports",
23
+ },
24
+ "General Feedback": {
25
+ Icon: MessageCircle,
26
+ boardLabel: "General feedback",
27
+ },
28
+ } satisfies Record<PostType, { Icon: LucideIcon; boardLabel: string }>;
29
+
30
+ export const postStatusPresentation = {
31
+ New: { Icon: Circle, label: "New" },
32
+ Reviewing: { Icon: Search, label: "Reviewing" },
33
+ Planned: { Icon: CalendarDays, label: "Planned" },
34
+ "In Progress": { Icon: CircleDot, label: "In progress" },
35
+ Shipped: { Icon: CheckCircle2, label: "Shipped" },
36
+ Closed: { Icon: XCircle, label: "Closed" },
37
+ } satisfies Record<PostStatus, { Icon: LucideIcon; label: string }>;
@@ -0,0 +1,158 @@
1
+ import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query";
2
+ import type { PublicPostPage } from "@feedbax/feedback";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { publicPostsQuery } from "./post-queries";
6
+ import { authorizedDraftPostsQuery } from "./authorized-draft-query";
7
+ import { capabilitiesKey, readCapabilities } from "./browser-post-state";
8
+
9
+ vi.mock("./public-feedback-server-function", () => ({
10
+ getPublicPostPage: vi.fn(),
11
+ }));
12
+
13
+ describe("public Posts query", () => {
14
+ it("reuses the loader page and retains it when another cursor page loads", async () => {
15
+ const pages: Record<string, PublicPostPage> = {
16
+ first: {
17
+ items: [post("first-post")],
18
+ nextCursor: "opaque-cursor",
19
+ },
20
+ "opaque-cursor": { items: [post("second-post")] },
21
+ };
22
+ const fetchPage = vi.fn(async ({ cursor }: { cursor?: string }) =>
23
+ cursor ? pages[cursor]! : pages.first!,
24
+ );
25
+ const client = new QueryClient();
26
+ const options = publicPostsQuery({ sort: "trending" }, fetchPage);
27
+
28
+ await client.ensureInfiniteQueryData(options);
29
+ expect(fetchPage).toHaveBeenCalledTimes(1);
30
+
31
+ const observer = new InfiniteQueryObserver(client, options);
32
+ await observer.fetchNextPage();
33
+
34
+ expect(fetchPage).toHaveBeenCalledTimes(2);
35
+ expect(client.getQueryData(options.queryKey)).toMatchObject({
36
+ pages: [pages.first, pages["opaque-cursor"]],
37
+ pageParams: [undefined, "opaque-cursor"],
38
+ });
39
+ });
40
+ });
41
+
42
+ describe("authorized Draft Posts query", () => {
43
+ it("keeps Browser Capability secrets out of its key and cached result", async () => {
44
+ const secret = "private-browser-capability";
45
+ const capabilities = {
46
+ "draft-id": {
47
+ id: "draft-id",
48
+ slug: "private-draft",
49
+ browserCapability: secret,
50
+ },
51
+ };
52
+ const fetchDraft = vi.fn(async () => draft("draft-id"));
53
+ const client = new QueryClient();
54
+ const options = authorizedDraftPostsQuery(capabilities, fetchDraft);
55
+
56
+ await client.ensureQueryData(options);
57
+
58
+ expect(fetchDraft).toHaveBeenCalledWith({
59
+ data: {
60
+ id: "draft-id",
61
+ slug: "private-draft",
62
+ browserCapability: secret,
63
+ },
64
+ });
65
+ expect(JSON.stringify(options.queryKey)).not.toContain(secret);
66
+ expect(JSON.stringify(client.getQueryData(options.queryKey))).not.toContain(
67
+ secret,
68
+ );
69
+ });
70
+
71
+ it("removes only capabilities proven invalid while retaining valid drafts", async () => {
72
+ const storage = memoryStorage({
73
+ [capabilitiesKey]: JSON.stringify({
74
+ valid: {
75
+ id: "valid",
76
+ slug: "valid-draft",
77
+ browserCapability: "valid-secret",
78
+ },
79
+ expired: {
80
+ id: "expired",
81
+ slug: "expired-draft",
82
+ browserCapability: "expired-secret",
83
+ },
84
+ }),
85
+ });
86
+ const fetchDraft = vi.fn(async ({ data }: { data: { id: string } }) => {
87
+ if (data.id === "expired") {
88
+ throw new Error("Browser Capability did not authorize this draft.");
89
+ }
90
+ return draft(data.id);
91
+ });
92
+ const client = new QueryClient();
93
+
94
+ const drafts = await client.ensureQueryData(
95
+ authorizedDraftPostsQuery(readCapabilities(storage), fetchDraft, storage),
96
+ );
97
+
98
+ expect(drafts.map((item) => item.id)).toEqual(["valid"]);
99
+ expect(Object.keys(readCapabilities(storage))).toEqual(["valid"]);
100
+ });
101
+
102
+ it("keeps cached drafts visible when a private retrieval fails transiently", async () => {
103
+ const capabilities = {
104
+ valid: {
105
+ id: "valid",
106
+ slug: "valid-draft",
107
+ browserCapability: "valid-secret",
108
+ },
109
+ };
110
+ const options = authorizedDraftPostsQuery(capabilities, async () => {
111
+ throw new Error("Post service unavailable");
112
+ });
113
+ const cached = [draft("valid")];
114
+ const client = new QueryClient();
115
+ client.setQueryData(options.queryKey, cached);
116
+ await client.invalidateQueries({ queryKey: options.queryKey });
117
+
118
+ await expect(client.fetchQuery(options)).rejects.toThrow(
119
+ "Post service unavailable",
120
+ );
121
+
122
+ expect(client.getQueryData(options.queryKey)).toEqual(cached);
123
+ });
124
+ });
125
+
126
+ function post(slug: string) {
127
+ return {
128
+ slug,
129
+ title: slug,
130
+ description: `${slug} description`,
131
+ type: "Feature Request" as const,
132
+ status: "New" as const,
133
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
134
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
135
+ };
136
+ }
137
+
138
+ function draft(id: string) {
139
+ return {
140
+ id,
141
+ slug: `${id}-draft`,
142
+ title: `${id} draft`,
143
+ description: `${id} description`,
144
+ type: "Feature Request" as const,
145
+ status: "New" as const,
146
+ submitter: {},
147
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
148
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
149
+ };
150
+ }
151
+
152
+ function memoryStorage(initial: Record<string, string>) {
153
+ const values = new Map(Object.entries(initial));
154
+ return {
155
+ getItem: (key: string) => values.get(key) ?? null,
156
+ setItem: (key: string, value: string) => values.set(key, value),
157
+ };
158
+ }
@@ -0,0 +1,26 @@
1
+ import { infiniteQueryOptions } from "@tanstack/react-query";
2
+ import type { PublicPostQuery, PublicPostPage } from "@feedbax/feedback";
3
+
4
+ export type FetchPublicPostsPage = (
5
+ search: PublicPostQuery,
6
+ ) => Promise<PublicPostPage>;
7
+
8
+ export const publicPostsQueryKey = ["public-posts"] as const;
9
+
10
+ export function publicPostsQuery(
11
+ search: PublicPostQuery,
12
+ fetchPage: FetchPublicPostsPage,
13
+ ) {
14
+ const querySearch = { ...search, cursor: undefined };
15
+ return infiniteQueryOptions({
16
+ queryKey: [...publicPostsQueryKey, querySearch] as const,
17
+ initialPageParam: undefined as string | undefined,
18
+ queryFn: ({ pageParam }) =>
19
+ fetchPage({
20
+ ...querySearch,
21
+ ...(pageParam ? { cursor: pageParam } : {}),
22
+ }),
23
+ getNextPageParam: (page) => page.nextCursor,
24
+ staleTime: 30_000,
25
+ });
26
+ }
@@ -0,0 +1,165 @@
1
+ import type { DraftPost, PublicPost } from "@feedbax/feedback";
2
+ import type { PortalFeatures } from "@feedbax/config";
3
+ import { Badge } from "@feedbax/ui/components/badge";
4
+ import { Button } from "@feedbax/ui/components/button";
5
+ import {
6
+ Empty,
7
+ EmptyContent,
8
+ EmptyDescription,
9
+ EmptyHeader,
10
+ EmptyMedia,
11
+ EmptyTitle,
12
+ } from "@feedbax/ui/components/empty";
13
+ import { SearchX } from "lucide-react";
14
+
15
+ import { PostLink } from "./masked-post-link";
16
+ import { PostStatusBadge, PostTypeBadge } from "./post-badges";
17
+ import feedbax from "./feedbax";
18
+ import { VoteToggle } from "./vote-toggle";
19
+
20
+ export function PostResults({
21
+ drafts = [],
22
+ initialItems,
23
+ nextCursor,
24
+ loadMore,
25
+ loadingMore = false,
26
+ loadMoreError = false,
27
+ maskPostLinks = false,
28
+ features = feedbax.features,
29
+ }: {
30
+ drafts?: DraftPost[];
31
+ initialItems: PublicPost[];
32
+ nextCursor?: string;
33
+ loadMore?: () => void;
34
+ loadingMore?: boolean;
35
+ loadMoreError?: boolean;
36
+ maskPostLinks?: boolean;
37
+ features?: PortalFeatures;
38
+ }) {
39
+ if (!drafts.length && !initialItems.length) {
40
+ return (
41
+ <Empty className="min-h-72 border">
42
+ <EmptyHeader>
43
+ <EmptyMedia variant="icon">
44
+ <SearchX aria-hidden="true" />
45
+ </EmptyMedia>
46
+ <EmptyTitle>No Posts match this view.</EmptyTitle>
47
+ <EmptyDescription>
48
+ Try another Board or remove a Status filter.
49
+ </EmptyDescription>
50
+ </EmptyHeader>
51
+ <EmptyContent>
52
+ <Button
53
+ render={<a href="/" />}
54
+ nativeButton={false}
55
+ variant="outline"
56
+ >
57
+ Clear filters
58
+ </Button>
59
+ </EmptyContent>
60
+ </Empty>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <>
66
+ <ol className="divide-y overflow-hidden border bg-card/30">
67
+ {drafts.map((post) => (
68
+ <PostResultRow
69
+ key={post.id}
70
+ post={post}
71
+ draft
72
+ maskPostLinks={maskPostLinks}
73
+ features={features}
74
+ />
75
+ ))}
76
+ {initialItems.map((post) => (
77
+ <PostResultRow
78
+ key={post.slug}
79
+ post={post}
80
+ maskPostLinks={maskPostLinks}
81
+ features={features}
82
+ />
83
+ ))}
84
+ </ol>
85
+ {loadMoreError ? (
86
+ <div role="alert" className="mt-6 flex items-center gap-3 border p-4">
87
+ <p className="mr-auto text-sm">Couldn’t load more Posts.</p>
88
+ <Button type="button" onClick={loadMore}>
89
+ Try again
90
+ </Button>
91
+ </div>
92
+ ) : null}
93
+ {nextCursor ? (
94
+ <Button
95
+ className="mt-6"
96
+ type="button"
97
+ onClick={loadMore}
98
+ disabled={loadingMore || loadMoreError || !loadMore}
99
+ >
100
+ {loadingMore ? "Loading…" : "Load More"}
101
+ </Button>
102
+ ) : null}
103
+ </>
104
+ );
105
+ }
106
+
107
+ function PostResultRow({
108
+ post,
109
+ draft = false,
110
+ maskPostLinks,
111
+ features,
112
+ }: {
113
+ post: DraftPost | PublicPost;
114
+ draft?: boolean;
115
+ maskPostLinks: boolean;
116
+ features: PortalFeatures;
117
+ }) {
118
+ const votingEligible =
119
+ features.voting &&
120
+ !draft &&
121
+ "voteCount" in post &&
122
+ post.voteCount !== undefined;
123
+ return (
124
+ <li className="relative">
125
+ {votingEligible ? (
126
+ <div className="absolute top-5 left-5 z-10 flex h-[4.75rem] w-16 items-center justify-center sm:w-[4.75rem]">
127
+ <VoteToggle post={post} />
128
+ </div>
129
+ ) : null}
130
+ <PostLink
131
+ className={draft ? "post-link draft-post-link" : "post-link"}
132
+ slug={post.slug}
133
+ contextual={maskPostLinks}
134
+ >
135
+ <article
136
+ className={`relative grid ${features.voting ? "grid-cols-[4rem_minmax(0,1fr)] sm:grid-cols-[4.75rem_minmax(0,1fr)]" : "grid-cols-1"} items-start gap-4 p-5 transition-colors duration-150 hover:bg-muted/30 sm:items-center`}
137
+ >
138
+ {features.voting ? (
139
+ <span className="h-[4.75rem]" aria-hidden="true" />
140
+ ) : null}
141
+ <span className="min-w-0 sm:pr-16">
142
+ <span className="block text-base font-medium text-foreground sm:text-lg">
143
+ {post.title}
144
+ </span>
145
+ <span className="mt-1 block text-sm leading-relaxed text-muted-foreground">
146
+ {post.description}
147
+ </span>
148
+ <span
149
+ className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
150
+ role="group"
151
+ aria-label="Post metadata"
152
+ >
153
+ <PostTypeBadge type={post.type} />
154
+ {draft ? (
155
+ <Badge variant="outline">Draft</Badge>
156
+ ) : (
157
+ <PostStatusBadge status={post.status} />
158
+ )}
159
+ </span>
160
+ </span>
161
+ </article>
162
+ </PostLink>
163
+ </li>
164
+ );
165
+ }