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,151 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+ @import "@fontsource-variable/geist";
5
+ @source "../../../apps/**/*.{ts,tsx}";
6
+ @source "../**/*.{ts,tsx}";
7
+
8
+ @custom-variant dark (&:is(.dark *));
9
+
10
+ :root {
11
+ --background: oklch(1 0 0);
12
+ --foreground: oklch(0.145 0 0);
13
+ --card: oklch(1 0 0);
14
+ --card-foreground: oklch(0.145 0 0);
15
+ --popover: oklch(1 0 0);
16
+ --popover-foreground: oklch(0.145 0 0);
17
+ --primary: oklch(0.205 0 0);
18
+ --primary-foreground: oklch(0.985 0 0);
19
+ --secondary: oklch(0.97 0 0);
20
+ --secondary-foreground: oklch(0.205 0 0);
21
+ --muted: oklch(0.97 0 0);
22
+ --muted-foreground: oklch(0.556 0 0);
23
+ --accent: oklch(0.97 0 0);
24
+ --accent-foreground: oklch(0.205 0 0);
25
+ --destructive: oklch(0.577 0.245 27.325);
26
+ --border: oklch(0.922 0 0);
27
+ --input: oklch(0.922 0 0);
28
+ --ring: oklch(0.708 0 0);
29
+ --chart-1: oklch(0.87 0 0);
30
+ --chart-2: oklch(0.556 0 0);
31
+ --chart-3: oklch(0.439 0 0);
32
+ --chart-4: oklch(0.371 0 0);
33
+ --chart-5: oklch(0.269 0 0);
34
+ --status-planned: oklch(0.606 0.25 292.7);
35
+ --status-in-progress: oklch(0.623 0.214 259.8);
36
+ --status-shipped: oklch(0.723 0.219 149.6);
37
+ --changelog-new: oklch(0.55 0.17 255);
38
+ --changelog-improved: oklch(0.58 0.16 80);
39
+ --radius: 0.625rem;
40
+ --sidebar: oklch(0.985 0 0);
41
+ --sidebar-foreground: oklch(0.145 0 0);
42
+ --sidebar-primary: oklch(0.205 0 0);
43
+ --sidebar-primary-foreground: oklch(0.985 0 0);
44
+ --sidebar-accent: oklch(0.97 0 0);
45
+ --sidebar-accent-foreground: oklch(0.205 0 0);
46
+ --sidebar-border: oklch(0.922 0 0);
47
+ --sidebar-ring: oklch(0.708 0 0);
48
+ }
49
+
50
+ .dark {
51
+ --background: oklch(0.16 0.009 258);
52
+ --foreground: oklch(0.985 0 0);
53
+ --card: oklch(0.205 0.011 258);
54
+ --card-foreground: oklch(0.985 0 0);
55
+ --popover: oklch(0.205 0.011 258);
56
+ --popover-foreground: oklch(0.985 0 0);
57
+ --primary: oklch(0.97 0 0);
58
+ --primary-foreground: oklch(0.205 0.011 258);
59
+ --secondary: oklch(0.269 0.011 258);
60
+ --secondary-foreground: oklch(0.985 0 0);
61
+ --muted: oklch(0.269 0.011 258);
62
+ --muted-foreground: oklch(0.708 0.008 258);
63
+ --accent: oklch(0.269 0.011 258);
64
+ --accent-foreground: oklch(0.985 0 0);
65
+ --destructive: oklch(0.704 0.191 22.216);
66
+ --border: oklch(1 0 0 / 10%);
67
+ --input: oklch(1 0 0 / 15%);
68
+ --ring: oklch(0.556 0 0);
69
+ --chart-1: oklch(0.87 0 0);
70
+ --chart-2: oklch(0.556 0 0);
71
+ --chart-3: oklch(0.439 0 0);
72
+ --chart-4: oklch(0.371 0 0);
73
+ --chart-5: oklch(0.269 0 0);
74
+ --status-planned: oklch(0.606 0.25 292.7);
75
+ --status-in-progress: oklch(0.623 0.214 259.8);
76
+ --status-shipped: oklch(0.723 0.219 149.6);
77
+ --changelog-new: oklch(0.72 0.15 255);
78
+ --changelog-improved: oklch(0.8 0.16 80);
79
+ --sidebar: oklch(0.205 0.011 258);
80
+ --sidebar-foreground: oklch(0.985 0 0);
81
+ --sidebar-primary: oklch(0.488 0.243 264.376);
82
+ --sidebar-primary-foreground: oklch(0.985 0 0);
83
+ --sidebar-accent: oklch(0.269 0.011 258);
84
+ --sidebar-accent-foreground: oklch(0.985 0 0);
85
+ --sidebar-border: oklch(1 0 0 / 10%);
86
+ --sidebar-ring: oklch(0.556 0 0);
87
+ }
88
+
89
+ @theme inline {
90
+ --font-sans: "Geist Variable", sans-serif;
91
+ --color-sidebar-ring: var(--sidebar-ring);
92
+ --color-sidebar-border: var(--sidebar-border);
93
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
94
+ --color-sidebar-accent: var(--sidebar-accent);
95
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
96
+ --color-sidebar-primary: var(--sidebar-primary);
97
+ --color-sidebar-foreground: var(--sidebar-foreground);
98
+ --color-sidebar: var(--sidebar);
99
+ --color-chart-5: var(--chart-5);
100
+ --color-status-planned: var(--status-planned);
101
+ --color-status-in-progress: var(--status-in-progress);
102
+ --color-status-shipped: var(--status-shipped);
103
+ --color-changelog-new: var(--changelog-new);
104
+ --color-changelog-improved: var(--changelog-improved);
105
+ --color-chart-4: var(--chart-4);
106
+ --color-chart-3: var(--chart-3);
107
+ --color-chart-2: var(--chart-2);
108
+ --color-chart-1: var(--chart-1);
109
+ --color-ring: var(--ring);
110
+ --color-input: var(--input);
111
+ --color-border: var(--border);
112
+ --color-destructive: var(--destructive);
113
+ --color-accent-foreground: var(--accent-foreground);
114
+ --color-accent: var(--accent);
115
+ --color-muted-foreground: var(--muted-foreground);
116
+ --color-muted: var(--muted);
117
+ --color-secondary-foreground: var(--secondary-foreground);
118
+ --color-secondary: var(--secondary);
119
+ --color-primary-foreground: var(--primary-foreground);
120
+ --color-primary: var(--primary);
121
+ --color-popover-foreground: var(--popover-foreground);
122
+ --color-popover: var(--popover);
123
+ --color-card-foreground: var(--card-foreground);
124
+ --color-card: var(--card);
125
+ --color-foreground: var(--foreground);
126
+ --color-background: var(--background);
127
+ --radius-sm: calc(var(--radius) - 4px);
128
+ --radius-md: calc(var(--radius) - 2px);
129
+ --radius-lg: var(--radius);
130
+ --radius-xl: calc(var(--radius) + 4px);
131
+ --radius-2xl: calc(var(--radius) + 8px);
132
+ --radius-3xl: calc(var(--radius) + 12px);
133
+ --radius-4xl: calc(var(--radius) + 16px);
134
+ --font-heading: var(--font-sans);
135
+ }
136
+
137
+ @layer base {
138
+ * {
139
+ @apply border-border outline-ring/50;
140
+ }
141
+ body {
142
+ @apply font-sans bg-background text-foreground;
143
+ }
144
+ html {
145
+ @apply font-sans;
146
+ color-scheme: light;
147
+ }
148
+ html.dark {
149
+ color-scheme: dark;
150
+ }
151
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ Dialog,
3
+ DialogContent,
4
+ DialogDescription,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ } from "@feedbax/ui/components/dialog";
8
+ import {
9
+ Drawer,
10
+ DrawerContent,
11
+ DrawerDescription,
12
+ DrawerHeader,
13
+ DrawerTitle,
14
+ } from "@feedbax/ui/components/drawer";
15
+ import { useRouter, useRouterState } from "@tanstack/react-router";
16
+ import type { PortalFeedbackMutations } from "./portal-feedback-form";
17
+ import { PostCreationFlow } from "./post-creation-flow";
18
+ import { useMediaQuery } from "./use-media-query";
19
+
20
+ const mobileMediaQuery = "(max-width: 720px)";
21
+
22
+ declare module "@tanstack/react-router" {
23
+ interface HistoryState {
24
+ createPostOverlay?: boolean;
25
+ }
26
+ }
27
+
28
+ export function CreatePostOverlay({
29
+ mutations,
30
+ }: {
31
+ mutations: PortalFeedbackMutations;
32
+ }) {
33
+ const router = useRouter();
34
+ const overlayRequested = useRouterState({
35
+ select: (state) => state.location.state.createPostOverlay,
36
+ });
37
+ const mobile = useMediaQuery(mobileMediaQuery);
38
+ const open = overlayRequested === true;
39
+ const close = (nextOpen: boolean) => {
40
+ if (!nextOpen) router.history.back();
41
+ };
42
+
43
+ if (!open) return null;
44
+
45
+ if (mobile) {
46
+ return (
47
+ <Drawer open onOpenChange={close}>
48
+ <DrawerContent className="create-post-drawer">
49
+ <DrawerHeader className="sr-only">
50
+ <DrawerTitle>Create a Post</DrawerTitle>
51
+ <DrawerDescription>
52
+ Share a feature request, bug report, or product observation.
53
+ </DrawerDescription>
54
+ </DrawerHeader>
55
+ <PostCreationFlow
56
+ display="overlay"
57
+ onCancel={() => close(false)}
58
+ mutations={mutations}
59
+ />
60
+ </DrawerContent>
61
+ </Drawer>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <Dialog open onOpenChange={close}>
67
+ <DialogContent className="create-post-dialog">
68
+ <DialogHeader className="sr-only">
69
+ <DialogTitle>Create a Post</DialogTitle>
70
+ <DialogDescription>
71
+ Share a feature request, bug report, or product observation.
72
+ </DialogDescription>
73
+ </DialogHeader>
74
+ <PostCreationFlow
75
+ display="overlay"
76
+ onCancel={() => close(false)}
77
+ mutations={mutations}
78
+ />
79
+ </DialogContent>
80
+ </Dialog>
81
+ );
82
+ }
@@ -0,0 +1,39 @@
1
+ import type { PublicPostQuery } from "@feedbax/feedback";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { readCapabilities } from "./browser-post-state";
4
+ import { authorizedDraftPostsQuery } from "./authorized-draft-query";
5
+
6
+ const fetchAuthorizedDraft = async (
7
+ input: Parameters<
8
+ typeof import("./portal-feedback-server-function").getPortalDraftPost
9
+ >[0],
10
+ ) => {
11
+ const { getPortalDraftPost } =
12
+ await import("./portal-feedback-server-function");
13
+ return getPortalDraftPost(input);
14
+ };
15
+
16
+ export function useAuthorizedDraftPosts(search: PublicPostQuery) {
17
+ const browser = typeof window !== "undefined";
18
+ const capabilities = browser ? readCapabilities(window.localStorage) : {};
19
+ const draftQuery = useQuery({
20
+ ...authorizedDraftPostsQuery(
21
+ capabilities,
22
+ fetchAuthorizedDraft,
23
+ browser ? window.localStorage : undefined,
24
+ ),
25
+ enabled: browser && Object.keys(capabilities).length > 0,
26
+ });
27
+ const drafts = draftQuery.data ?? [];
28
+ const searchQuery = search.search?.toLocaleLowerCase();
29
+ const visible = drafts.filter(
30
+ (post) =>
31
+ (!searchQuery ||
32
+ `${post.title}\n${post.description}`
33
+ .toLocaleLowerCase()
34
+ .includes(searchQuery)) &&
35
+ (!search.types?.length || search.types.includes(post.type)) &&
36
+ (!search.statuses?.length || search.statuses.includes(post.status)),
37
+ );
38
+ return visible;
39
+ }
@@ -0,0 +1,185 @@
1
+ import { createFeedbackModule } from "@feedbax/feedback";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import {
5
+ createInvalidatingFeedbackModule,
6
+ createPublicCacheInvalidator,
7
+ } from "./feedback-cache-invalidation";
8
+
9
+ describe("feedback cache invalidation", () => {
10
+ it("purges only feedback-list and changed-Post tags after a successful write", async () => {
11
+ const purge = vi.fn().mockResolvedValue({ success: true, errors: [] });
12
+ const feedback = createInvalidatingFeedbackModule({
13
+ feedback: createFeedbackModule(),
14
+ invalidator: createPublicCacheInvalidator({ purge }),
15
+ });
16
+
17
+ const submitted = await feedback.submitPost({
18
+ title: "Keyboard navigation",
19
+ description: "Let participants navigate without a mouse.",
20
+ type: "Feature Request",
21
+ });
22
+
23
+ expect(purge).toHaveBeenCalledWith({
24
+ tags: ["feedbax-feedback", `feedbax-post-${submitted.slug}`],
25
+ });
26
+ expect(purge.mock.calls[0]?.[0].tags).not.toContain("feedbax-roadmap");
27
+ });
28
+
29
+ it("invalidates public feedback after a trusted submission", async () => {
30
+ const invalidatePost = vi.fn().mockResolvedValue(undefined);
31
+ const feedback = createInvalidatingFeedbackModule({
32
+ feedback: createFeedbackModule(),
33
+ invalidator: { invalidatePost, invalidateVote: invalidatePost },
34
+ });
35
+
36
+ const submitted = await feedback.submitTrustedPost({
37
+ externalId: "product-a:feedback-123",
38
+ title: "Keyboard navigation",
39
+ description: "Let participants navigate without a mouse.",
40
+ type: "Feature Request",
41
+ });
42
+
43
+ expect(invalidatePost).toHaveBeenCalledExactlyOnceWith(submitted.slug);
44
+ });
45
+
46
+ it("invalidates feed, Post detail, and roadmap projections after a confirmed Vote", async () => {
47
+ const purge = vi.fn().mockResolvedValue({ success: true, errors: [] });
48
+ const now = new Date("2026-08-25T10:00:00.000Z");
49
+ const feedback = createInvalidatingFeedbackModule({
50
+ feedback: createFeedbackModule({
51
+ initialItems: [
52
+ {
53
+ id: "post-id",
54
+ slug: "keyboard-navigation",
55
+ title: "Keyboard navigation",
56
+ description: "Navigate without a mouse.",
57
+ type: "Feature Request",
58
+ status: "Planned",
59
+ published: true,
60
+ voteCount: 2,
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ },
64
+ ],
65
+ }),
66
+ invalidator: createPublicCacheInvalidator({ purge }),
67
+ });
68
+
69
+ await feedback.changeVote({
70
+ slug: "keyboard-navigation",
71
+ intention: "add",
72
+ });
73
+
74
+ expect(purge).toHaveBeenCalledWith({
75
+ tags: [
76
+ "feedbax-feedback",
77
+ "feedbax-post-keyboard-navigation",
78
+ "feedbax-roadmap",
79
+ ],
80
+ });
81
+ });
82
+
83
+ it("keeps a confirmed Vote successful when cache invalidation fails", async () => {
84
+ const now = new Date("2026-08-25T10:00:00.000Z");
85
+ const feedback = createInvalidatingFeedbackModule({
86
+ feedback: createFeedbackModule({
87
+ initialItems: [
88
+ {
89
+ id: "post-id",
90
+ slug: "keyboard-navigation",
91
+ title: "Keyboard navigation",
92
+ description: "Navigate without a mouse.",
93
+ type: "Feature Request",
94
+ status: "Planned",
95
+ published: true,
96
+ voteCount: 2,
97
+ createdAt: now,
98
+ updatedAt: now,
99
+ },
100
+ ],
101
+ }),
102
+ invalidator: {
103
+ invalidatePost: vi.fn(),
104
+ invalidateVote: vi.fn().mockRejectedValue(new Error("purge failed")),
105
+ },
106
+ });
107
+
108
+ await expect(
109
+ feedback.changeVote({
110
+ slug: "keyboard-navigation",
111
+ intention: "add",
112
+ }),
113
+ ).resolves.toEqual({ slug: "keyboard-navigation", voteCount: 3 });
114
+ });
115
+
116
+ it("invalidates after edits and withdrawals without passing private write data", async () => {
117
+ const invalidatePost = vi.fn().mockResolvedValue(undefined);
118
+ const feedback = createInvalidatingFeedbackModule({
119
+ feedback: createFeedbackModule(),
120
+ invalidator: { invalidatePost, invalidateVote: invalidatePost },
121
+ });
122
+ const submitted = await feedback.submitPost({
123
+ title: "Original title",
124
+ description: "Original description",
125
+ type: "General Feedback",
126
+ submitter: { email: "private@example.com" },
127
+ });
128
+ invalidatePost.mockClear();
129
+
130
+ await feedback.editDraftPost({
131
+ id: submitted.id,
132
+ browserCapability: submitted.browserCapability,
133
+ title: "Revised title",
134
+ description: "Revised description",
135
+ type: "Bug Report",
136
+ });
137
+ await feedback.withdrawDraftPost({
138
+ id: submitted.id,
139
+ browserCapability: submitted.browserCapability,
140
+ });
141
+
142
+ expect(invalidatePost.mock.calls).toEqual([
143
+ [submitted.slug],
144
+ [submitted.slug],
145
+ ]);
146
+ });
147
+
148
+ it("does not purge when a write fails", async () => {
149
+ const invalidatePost = vi.fn().mockResolvedValue(undefined);
150
+ const feedback = createInvalidatingFeedbackModule({
151
+ feedback: {
152
+ ...createFeedbackModule(),
153
+ submitPost: vi.fn().mockRejectedValue(new Error("write failed")),
154
+ },
155
+ invalidator: { invalidatePost, invalidateVote: invalidatePost },
156
+ });
157
+
158
+ await expect(
159
+ feedback.submitPost({
160
+ title: "Keyboard navigation",
161
+ description: "Let participants navigate without a mouse.",
162
+ type: "Feature Request",
163
+ }),
164
+ ).rejects.toThrow("write failed");
165
+ expect(invalidatePost).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("reports purge rejection without exposing Cloudflare details", async () => {
169
+ const invalidator = createPublicCacheInvalidator({
170
+ purge: vi.fn().mockResolvedValue({
171
+ success: false,
172
+ errors: [{ code: 1234, message: "private infrastructure detail" }],
173
+ }),
174
+ });
175
+
176
+ const invalidation = invalidator.invalidatePost("feedback-123");
177
+
178
+ await expect(invalidation).rejects.toThrow(
179
+ "Public feedback cache invalidation failed.",
180
+ );
181
+ await expect(invalidation).rejects.not.toThrow(
182
+ "private infrastructure detail",
183
+ );
184
+ });
185
+ });
@@ -0,0 +1,97 @@
1
+ import type { FeedbackModule } from "@feedbax/feedback";
2
+
3
+ import {
4
+ publicFeedbackCacheTag,
5
+ publicPostCacheTag,
6
+ publicRoadmapCacheTag,
7
+ } from "./public-cache-tags";
8
+
9
+ interface PublicCacheInvalidator {
10
+ invalidatePost(slug: string): Promise<void>;
11
+ invalidateVote(slug: string): Promise<void>;
12
+ }
13
+
14
+ interface CachePurgeResult {
15
+ success: boolean;
16
+ errors: Array<{ code: number; message: string }>;
17
+ }
18
+
19
+ type PurgePublicCache = (options: {
20
+ tags: string[];
21
+ }) => Promise<CachePurgeResult>;
22
+
23
+ export function createPublicCacheInvalidator({
24
+ purge,
25
+ }: {
26
+ purge: PurgePublicCache;
27
+ }): PublicCacheInvalidator {
28
+ async function purgePost(slug: string, tags: string[]) {
29
+ const result = await purge({
30
+ tags: [publicFeedbackCacheTag, publicPostCacheTag(slug), ...tags],
31
+ });
32
+ if (!result.success) {
33
+ throw new Error("Public feedback cache invalidation failed.");
34
+ }
35
+ }
36
+ return {
37
+ invalidatePost: (slug) => purgePost(slug, []),
38
+ invalidateVote: (slug) => purgePost(slug, [publicRoadmapCacheTag]),
39
+ };
40
+ }
41
+
42
+ export function createInvalidatingFeedbackModule({
43
+ feedback,
44
+ invalidator,
45
+ }: {
46
+ feedback: FeedbackModule;
47
+ invalidator: PublicCacheInvalidator;
48
+ }): FeedbackModule {
49
+ return {
50
+ editComment: (input) => feedback.editComment(input),
51
+ deleteComment: (input) => feedback.deleteComment(input),
52
+ async createComment(input) {
53
+ const comment = await feedback.createComment(input);
54
+ await invalidator.invalidatePost(input.slug).catch(() => undefined);
55
+ return comment;
56
+ },
57
+ async replyToCommentThread(input) {
58
+ const comment = await feedback.replyToCommentThread(input);
59
+ await invalidator.invalidatePost(input.slug).catch(() => undefined);
60
+ return comment;
61
+ },
62
+ async changeVote(input) {
63
+ const result = await feedback.changeVote(input);
64
+ await invalidator.invalidateVote(result.slug).catch(() => undefined);
65
+ return result;
66
+ },
67
+ submitPost: (input) =>
68
+ runInvalidatingWrite(() => feedback.submitPost(input), invalidator),
69
+ editDraftPost: (input) =>
70
+ runInvalidatingWrite(() => feedback.editDraftPost(input), invalidator),
71
+ getPublicPost: (slug) => feedback.getPublicPost(slug),
72
+ listCommentThreads: (input) => feedback.listCommentThreads(input),
73
+ getDraftPost: (input) => feedback.getDraftPost(input),
74
+ listPublicPosts: (query) => feedback.listPublicPosts(query),
75
+ listPublicRoadmapPosts: (query) => feedback.listPublicRoadmapPosts(query),
76
+ submitTrustedPost: (input) =>
77
+ runInvalidatingWrite(
78
+ () => feedback.submitTrustedPost(input),
79
+ invalidator,
80
+ ),
81
+ async withdrawDraftPost(input) {
82
+ const draft = await feedback.getDraftPost(input);
83
+ await feedback.withdrawDraftPost(input);
84
+ await invalidator.invalidatePost(draft.slug);
85
+ },
86
+ getPublicPostRoadmap: () => feedback.getPublicPostRoadmap(),
87
+ };
88
+ }
89
+
90
+ async function runInvalidatingWrite<Item extends { slug: string }>(
91
+ write: () => Promise<Item>,
92
+ invalidator: PublicCacheInvalidator,
93
+ ): Promise<Item> {
94
+ const item = await write();
95
+ await invalidator.invalidatePost(item.slug);
96
+ return item;
97
+ }
@@ -0,0 +1,38 @@
1
+ import { createNotionFeedbackModule } from "@feedbax/feedback";
2
+ import { cache } from "cloudflare:workers";
3
+
4
+ import {
5
+ createInvalidatingFeedbackModule,
6
+ createPublicCacheInvalidator,
7
+ } from "./feedback-cache-invalidation";
8
+ import feedbax from "./feedbax";
9
+
10
+ export const feedbackSubmissionLimits = {
11
+ maxTitleLength: feedbax.submissions.maxTitleLength,
12
+ maxDescriptionLength: feedbax.submissions.maxDescriptionLength,
13
+ } as const;
14
+
15
+ export function createConfiguredFeedbackModule() {
16
+ configuredFeedback ??= createInvalidatingFeedbackModule({
17
+ feedback: createNotionFeedbackModule({
18
+ votingEnabled: feedbax.features.voting,
19
+ commentsEnabled: feedbax.features.comments,
20
+ token: requiredEnvironmentValue("NOTION_TOKEN"),
21
+ dataSourceId: feedbax.notion.dataSourceId,
22
+ propertyIds: feedbax.notion.properties,
23
+ }),
24
+ invalidator: createPublicCacheInvalidator({
25
+ purge: (options) => cache.purge(options),
26
+ }),
27
+ });
28
+ return configuredFeedback;
29
+ }
30
+
31
+ let configuredFeedback:
32
+ ReturnType<typeof createInvalidatingFeedbackModule> | undefined;
33
+
34
+ export function requiredEnvironmentValue(name: string): string {
35
+ const value = process.env[name];
36
+ if (!value) throw new Error("Feedback configuration is missing.");
37
+ return value;
38
+ }