create-feedbax 0.1.0 → 0.2.1

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 (251) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +3283 -22
  4. package/dist/index.js +3394 -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/AGENTS.md +13 -0
  11. package/template/CONNECTED.md +404 -0
  12. package/template/LICENSE +201 -0
  13. package/template/README.md +56 -5
  14. package/template/components.json +25 -0
  15. package/template/eslint.config.mjs +48 -0
  16. package/template/migrations/0001_connected.sql +6 -0
  17. package/template/migrations/0002_participation.sql +8 -0
  18. package/template/migrations/0003_images.sql +3 -0
  19. package/template/migrations/0004_followed_status.sql +9 -0
  20. package/template/package.json +74 -14
  21. package/template/public/feedbax-mark.svg +4 -0
  22. package/template/public/robots.txt +3 -0
  23. package/template/src/authorized-draft-post.test.tsx +212 -0
  24. package/template/src/authorized-draft-post.tsx +205 -0
  25. package/template/src/authorized-draft-query.ts +74 -0
  26. package/template/src/browser-post-state.test.ts +120 -0
  27. package/template/src/browser-post-state.ts +134 -0
  28. package/template/src/browser-vote-state.test.ts +14 -0
  29. package/template/src/browser-vote-state.ts +27 -0
  30. package/template/src/capability-authorization-error.ts +3 -0
  31. package/template/src/changelog-page.test.tsx +139 -0
  32. package/template/src/changelog-page.tsx +228 -0
  33. package/template/src/changelog-query.ts +13 -0
  34. package/template/src/changelog-runtime.ts +27 -0
  35. package/template/src/cloudflare-turnstile.ts +31 -0
  36. package/template/src/cloudflare-workers-test.ts +3 -0
  37. package/template/src/cloudflare-workers.d.ts +24 -0
  38. package/template/src/comment-creation.test.tsx +311 -0
  39. package/template/src/comment-profile.ts +5 -0
  40. package/template/src/comment-thread-page.ts +28 -0
  41. package/template/src/components/command-palette-context.tsx +71 -0
  42. package/template/src/components/command-palette.tsx +275 -0
  43. package/template/src/components/connected-identity-provider.tsx +108 -0
  44. package/template/src/components/create-post-trigger.tsx +44 -0
  45. package/template/src/components/device-profile-provider.tsx +324 -0
  46. package/template/src/components/follow-post-button.tsx +89 -0
  47. package/template/src/components/header.tsx +97 -0
  48. package/template/src/components/image-attachments.tsx +187 -0
  49. package/template/src/components/loader.tsx +53 -0
  50. package/template/src/components/notification-inbox.tsx +232 -0
  51. package/template/src/components/portal-footer.tsx +14 -0
  52. package/template/src/components/profile-menu.tsx +119 -0
  53. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  54. package/template/src/components/ui/components/attachment.tsx +207 -0
  55. package/template/src/components/ui/components/avatar.tsx +107 -0
  56. package/template/src/components/ui/components/badge.tsx +52 -0
  57. package/template/src/components/ui/components/bubble.tsx +128 -0
  58. package/template/src/components/ui/components/button-group.tsx +87 -0
  59. package/template/src/components/ui/components/button.tsx +56 -0
  60. package/template/src/components/ui/components/card.tsx +103 -0
  61. package/template/src/components/ui/components/checkbox.tsx +26 -0
  62. package/template/src/components/ui/components/command.tsx +195 -0
  63. package/template/src/components/ui/components/dialog.tsx +154 -0
  64. package/template/src/components/ui/components/drawer.tsx +229 -0
  65. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  66. package/template/src/components/ui/components/empty.tsx +101 -0
  67. package/template/src/components/ui/components/field.tsx +236 -0
  68. package/template/src/components/ui/components/input-group.tsx +155 -0
  69. package/template/src/components/ui/components/input.tsx +20 -0
  70. package/template/src/components/ui/components/item.tsx +201 -0
  71. package/template/src/components/ui/components/kbd.tsx +26 -0
  72. package/template/src/components/ui/components/label.tsx +18 -0
  73. package/template/src/components/ui/components/marker.tsx +71 -0
  74. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  75. package/template/src/components/ui/components/message.tsx +92 -0
  76. package/template/src/components/ui/components/native-select.tsx +65 -0
  77. package/template/src/components/ui/components/popover.tsx +88 -0
  78. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  79. package/template/src/components/ui/components/radio-group.tsx +36 -0
  80. package/template/src/components/ui/components/scroll-area.tsx +51 -0
  81. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  82. package/template/src/components/ui/components/select.tsx +199 -0
  83. package/template/src/components/ui/components/separator.tsx +23 -0
  84. package/template/src/components/ui/components/sheet.tsx +135 -0
  85. package/template/src/components/ui/components/skeleton.tsx +13 -0
  86. package/template/src/components/ui/components/sonner.tsx +43 -0
  87. package/template/src/components/ui/components/spinner.tsx +16 -0
  88. package/template/src/components/ui/components/switch.tsx +29 -0
  89. package/template/src/components/ui/components/tabs.tsx +80 -0
  90. package/template/src/components/ui/components/textarea.tsx +18 -0
  91. package/template/src/components/ui/components/tooltip.tsx +64 -0
  92. package/template/src/components/ui/hooks/.gitkeep +0 -0
  93. package/template/src/components/ui/lib/utils.ts +6 -0
  94. package/template/src/components/ui/styles/globals.css +151 -0
  95. package/template/src/connected-identity.test.ts +144 -0
  96. package/template/src/connected-identity.ts +122 -0
  97. package/template/src/connected-participation.test.tsx +67 -0
  98. package/template/src/connected-runtime.test.ts +241 -0
  99. package/template/src/connected-runtime.ts +176 -0
  100. package/template/src/connected-social.test.tsx +158 -0
  101. package/template/src/create-post-overlay.tsx +82 -0
  102. package/template/src/draft-post-list.tsx +39 -0
  103. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  104. package/template/src/feedback-cache-invalidation.ts +98 -0
  105. package/template/src/feedback-runtime.ts +105 -0
  106. package/template/src/feedbax/changelog/index.test.ts +156 -0
  107. package/template/src/feedbax/changelog/index.ts +206 -0
  108. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  109. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  110. package/template/src/feedbax/config/index.test.ts +139 -0
  111. package/template/src/feedbax/config/index.ts +364 -0
  112. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  113. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  114. package/template/src/feedbax/feedback/connected.ts +215 -0
  115. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  116. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  117. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  118. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  119. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  120. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  121. package/template/src/feedbax/feedback/images.test.ts +223 -0
  122. package/template/src/feedbax/feedback/images.ts +324 -0
  123. package/template/src/feedbax/feedback/index.test.ts +900 -0
  124. package/template/src/feedbax/feedback/index.ts +996 -0
  125. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  126. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  127. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  128. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  129. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  130. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  131. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  132. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  133. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  134. package/template/src/feedbax/feedback/participation.ts +502 -0
  135. package/template/src/feedbax.test-fixture.ts +64 -0
  136. package/template/src/feedbax.ts +69 -0
  137. package/template/src/images-runtime.test.ts +133 -0
  138. package/template/src/images-runtime.ts +125 -0
  139. package/template/src/index.css +1795 -0
  140. package/template/src/lazy-create-post-overlay.tsx +6 -0
  141. package/template/src/lazy-post-detail-overlay.tsx +21 -0
  142. package/template/src/masked-post-link.tsx +27 -0
  143. package/template/src/overlay-post-detail.test.tsx +181 -0
  144. package/template/src/overlay-post-detail.tsx +43 -0
  145. package/template/src/participation-client.ts +63 -0
  146. package/template/src/participation-runtime.test.ts +35 -0
  147. package/template/src/participation-runtime.ts +255 -0
  148. package/template/src/portal-comments.test.ts +375 -0
  149. package/template/src/portal-comments.ts +403 -0
  150. package/template/src/portal-features.test.tsx +150 -0
  151. package/template/src/portal-feedback-form.tsx +530 -0
  152. package/template/src/portal-feedback-runtime.test.ts +32 -0
  153. package/template/src/portal-feedback-runtime.ts +58 -0
  154. package/template/src/portal-feedback-server-function.ts +96 -0
  155. package/template/src/portal-feedback-submission.test.ts +307 -0
  156. package/template/src/portal-feedback-submission.ts +224 -0
  157. package/template/src/portal-head.ts +58 -0
  158. package/template/src/portal-server-entry.ts +68 -0
  159. package/template/src/portal-voting.test.ts +106 -0
  160. package/template/src/portal-voting.ts +149 -0
  161. package/template/src/post-badges.tsx +27 -0
  162. package/template/src/post-creation-flow.test.tsx +230 -0
  163. package/template/src/post-creation-flow.tsx +88 -0
  164. package/template/src/post-detail-content.test.tsx +233 -0
  165. package/template/src/post-detail-content.tsx +162 -0
  166. package/template/src/post-detail-overlay.test.tsx +114 -0
  167. package/template/src/post-detail-overlay.tsx +67 -0
  168. package/template/src/post-detail-query.ts +34 -0
  169. package/template/src/post-feed-controls.test.tsx +239 -0
  170. package/template/src/post-feed-controls.tsx +298 -0
  171. package/template/src/post-presentation.ts +37 -0
  172. package/template/src/post-queries.test.ts +158 -0
  173. package/template/src/post-queries.ts +26 -0
  174. package/template/src/post-results.tsx +165 -0
  175. package/template/src/public-cache-policy.test.ts +148 -0
  176. package/template/src/public-cache-policy.ts +119 -0
  177. package/template/src/public-cache-tags.ts +7 -0
  178. package/template/src/public-changelog-page.ts +15 -0
  179. package/template/src/public-changelog-server-function.ts +27 -0
  180. package/template/src/public-date.ts +11 -0
  181. package/template/src/public-failure-state.test.tsx +69 -0
  182. package/template/src/public-feedback-index.tsx +94 -0
  183. package/template/src/public-feedback-page.test.tsx +355 -0
  184. package/template/src/public-feedback-page.ts +86 -0
  185. package/template/src/public-feedback-server-function.ts +22 -0
  186. package/template/src/public-portal-error.tsx +27 -0
  187. package/template/src/public-post-detail-skeleton.tsx +54 -0
  188. package/template/src/public-post-detail.tsx +772 -0
  189. package/template/src/public-post-page.test.tsx +173 -0
  190. package/template/src/public-post-page.ts +15 -0
  191. package/template/src/public-post-route.test.tsx +250 -0
  192. package/template/src/public-post-route.tsx +158 -0
  193. package/template/src/public-post-server-function.ts +48 -0
  194. package/template/src/public-post-unavailable.tsx +10 -0
  195. package/template/src/public-roadmap-page.test.tsx +139 -0
  196. package/template/src/public-roadmap-page.ts +24 -0
  197. package/template/src/public-roadmap-route.test.tsx +254 -0
  198. package/template/src/public-roadmap-route.tsx +50 -0
  199. package/template/src/public-roadmap-server-function.ts +44 -0
  200. package/template/src/public-roadmap-view.tsx +411 -0
  201. package/template/src/public-shell.test.tsx +544 -0
  202. package/template/src/roadmap-query.ts +21 -0
  203. package/template/src/root-branding.test.ts +53 -0
  204. package/template/src/routeTree.gen.ts +177 -0
  205. package/template/src/routed-post-creation-form.tsx +28 -0
  206. package/template/src/router.tsx +27 -0
  207. package/template/src/routes/__root.tsx +114 -0
  208. package/template/src/routes/changelog.tsx +39 -0
  209. package/template/src/routes/email-preferences.tsx +77 -0
  210. package/template/src/routes/index.tsx +61 -0
  211. package/template/src/routes/p.$slug.tsx +16 -0
  212. package/template/src/routes/roadmap.tsx +11 -0
  213. package/template/src/routes/submit.tsx +28 -0
  214. package/template/src/safe-public-failure.ts +47 -0
  215. package/template/src/server.ts +151 -0
  216. package/template/src/similar-posts.tsx +45 -0
  217. package/template/src/submit-route.test.tsx +134 -0
  218. package/template/src/trusted-feedback-handler.test.ts +425 -0
  219. package/template/src/trusted-feedback-handler.ts +181 -0
  220. package/template/src/trusted-feedback-runtime.ts +17 -0
  221. package/template/src/turnstile-configuration.test.ts +37 -0
  222. package/template/src/turnstile-configuration.ts +19 -0
  223. package/template/src/use-media-query.ts +17 -0
  224. package/template/src/vote-toggle.test.tsx +171 -0
  225. package/template/src/vote-toggle.tsx +244 -0
  226. package/template/tsconfig.json +45 -0
  227. package/template/vite.config.ts +20 -0
  228. package/template/vitest.config.ts +4 -0
  229. package/template/vitest.setup.ts +3 -0
  230. package/template/wrangler.jsonc +35 -0
  231. package/dist/cli.d.ts +0 -3
  232. package/dist/cli.d.ts.map +0 -1
  233. package/dist/cli.js.map +0 -1
  234. package/dist/index.d.ts +0 -21
  235. package/dist/index.d.ts.map +0 -1
  236. package/dist/index.js.map +0 -1
  237. package/template/.env.example +0 -4
  238. package/template/apps/portal/components.json +0 -20
  239. package/template/apps/portal/package.json +0 -31
  240. package/template/apps/portal/src/lib/utils.ts +0 -6
  241. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  242. package/template/apps/portal/src/router.tsx +0 -10
  243. package/template/apps/portal/src/routes/__root.tsx +0 -31
  244. package/template/apps/portal/src/routes/index.tsx +0 -35
  245. package/template/apps/portal/src/styles.css +0 -91
  246. package/template/apps/portal/tsconfig.json +0 -15
  247. package/template/apps/portal/vite.config.ts +0 -13
  248. package/template/feedbax.config.ts +0 -13
  249. package/template/feedbax.jsonc +0 -11
  250. package/template/pnpm-workspace.yaml +0 -3
  251. package/template/turbo.json +0 -8
@@ -0,0 +1,530 @@
1
+ import { ImageAttachments } from "./components/image-attachments";
2
+ import { configuredTurnstileSiteKey } from "./turnstile-configuration";
3
+ import type { Post, PostType, SubmittedPost } from "@feedbax/feedback";
4
+ import { useForm } from "@tanstack/react-form";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import * as z from "zod";
7
+ import {
8
+ AlertDialog,
9
+ AlertDialogAction,
10
+ AlertDialogCancel,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
16
+ } from "@feedbax/ui/components/alert-dialog";
17
+ import { Button } from "@feedbax/ui/components/button";
18
+ import {
19
+ Field,
20
+ FieldDescription,
21
+ FieldError,
22
+ FieldGroup,
23
+ FieldLabel,
24
+ } from "@feedbax/ui/components/field";
25
+ import { Input } from "@feedbax/ui/components/input";
26
+ import { RadioGroup, RadioGroupItem } from "@feedbax/ui/components/radio-group";
27
+ import { Textarea } from "@feedbax/ui/components/textarea";
28
+ import {
29
+ Bug,
30
+ Lightbulb,
31
+ MessageSquareText,
32
+ type LucideIcon,
33
+ } from "lucide-react";
34
+
35
+ import {
36
+ readDeviceProfile,
37
+ removeCapability,
38
+ retainCapability,
39
+ } from "./browser-post-state";
40
+ import { isCapabilityAuthorizationFailure } from "./capability-authorization-error";
41
+ import feedbax from "./feedbax";
42
+ import { SimilarPosts } from "./similar-posts";
43
+
44
+ const draftStorageKey = "feedbax:portal-draft";
45
+ const turnstileSiteKey = configuredTurnstileSiteKey();
46
+
47
+ export interface StoredDraft {
48
+ id: string;
49
+ browserCapability: string;
50
+ title: string;
51
+ description: string;
52
+ type: PostType;
53
+ }
54
+
55
+ export interface PortalFeedbackMutations {
56
+ submitPost(input: { data: unknown }): Promise<SubmittedPost>;
57
+ editDraftPost(input: { data: unknown }): Promise<Post>;
58
+ withdrawDraftPost(input: { data: unknown }): Promise<void>;
59
+ }
60
+
61
+ const postTypeOptions = [
62
+ {
63
+ type: "Feature Request",
64
+ description: "Suggest an improvement",
65
+ icon: Lightbulb,
66
+ },
67
+ {
68
+ type: "Bug Report",
69
+ description: "Report unexpected behavior",
70
+ icon: Bug,
71
+ },
72
+ {
73
+ type: "General Feedback",
74
+ description: "Share an observation",
75
+ icon: MessageSquareText,
76
+ },
77
+ ] as const satisfies readonly {
78
+ type: PostType;
79
+ description: string;
80
+ icon: LucideIcon;
81
+ }[];
82
+
83
+ const feedbackFormSchema = z.object({
84
+ title: z
85
+ .string()
86
+ .max(
87
+ feedbax.submissions.maxTitleLength,
88
+ `Title must be at most ${feedbax.submissions.maxTitleLength} characters.`,
89
+ )
90
+ .refine((value) => value.trim().length > 0, "Enter a title."),
91
+ description: z
92
+ .string()
93
+ .max(
94
+ feedbax.submissions.maxDescriptionLength,
95
+ `Description must be at most ${feedbax.submissions.maxDescriptionLength.toLocaleString("en-US")} characters.`,
96
+ )
97
+ .refine((value) => value.trim().length > 0, "Enter a description."),
98
+ type: z.enum(postTypeOptions.map(({ type }) => type)),
99
+ });
100
+
101
+ type FeedbackFormValues = z.infer<typeof feedbackFormSchema>;
102
+
103
+ export function PortalFeedbackForm({
104
+ initialDraft,
105
+ display = "page",
106
+ onCancel,
107
+ onCreated,
108
+ onEdited,
109
+ onAuthorizationLost,
110
+ mutations,
111
+ showWithdrawal = true,
112
+ }: {
113
+ initialDraft?: StoredDraft;
114
+ display?: "page" | "overlay";
115
+ onCancel?: () => void;
116
+ onCreated?: (post: SubmittedPost) => void | Promise<void>;
117
+ onEdited?: (post: Post) => void | Promise<void>;
118
+ onAuthorizationLost?: () => void;
119
+ mutations: PortalFeedbackMutations;
120
+ showWithdrawal?: boolean;
121
+ }) {
122
+ const [draft, setDraft] = useState<StoredDraft | undefined>(initialDraft);
123
+ const [pending, setPending] = useState(false);
124
+ const [images, setImages] = useState<
125
+ import("@feedbax/feedback").ImageAttachment[]
126
+ >([]);
127
+ const [uploading, setUploading] = useState(false);
128
+ const [message, setMessage] = useState<string>();
129
+ const [confirmingWithdrawal, setConfirmingWithdrawal] = useState(false);
130
+ const turnstileToken = useRef<string | undefined>(undefined);
131
+ const form = useForm({
132
+ defaultValues: {
133
+ title: initialDraft?.title ?? "",
134
+ description: initialDraft?.description ?? "",
135
+ type: initialDraft?.type ?? ("Feature Request" as PostType),
136
+ },
137
+ validators: { onSubmit: feedbackFormSchema },
138
+ onSubmit: async ({ value }) => {
139
+ if (draft) await edit(value);
140
+ else await submit(value);
141
+ },
142
+ });
143
+
144
+ useEffect(() => {
145
+ if (!turnstileSiteKey || document.querySelector("script[data-turnstile]")) {
146
+ return;
147
+ }
148
+ const script = document.createElement("script");
149
+ script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
150
+ script.async = true;
151
+ script.defer = true;
152
+ script.dataset.turnstile = "true";
153
+ document.head.append(script);
154
+ }, []);
155
+
156
+ async function submit(values: FeedbackFormValues) {
157
+ if (!browserCanStoreDraft()) {
158
+ setMessage(
159
+ "Enable browser storage before submitting so this browser can retain draft access.",
160
+ );
161
+ return;
162
+ }
163
+ setPending(true);
164
+ setMessage(undefined);
165
+ try {
166
+ const result = await mutations.submitPost({
167
+ data: {
168
+ ...values,
169
+ ...(images.length
170
+ ? { imageIds: images.map((image) => image.id) }
171
+ : {}),
172
+ ...(turnstileToken.current
173
+ ? { turnstileToken: turnstileToken.current }
174
+ : {}),
175
+ submitter: readDeviceProfile(window.localStorage),
176
+ },
177
+ });
178
+ const storedDraft: StoredDraft = {
179
+ id: result.id,
180
+ browserCapability: result.browserCapability,
181
+ title: values.title,
182
+ description: values.description,
183
+ type: values.type,
184
+ };
185
+ setDraft(storedDraft);
186
+ try {
187
+ retainCapability(window.localStorage, {
188
+ id: result.id,
189
+ slug: result.slug,
190
+ browserCapability: result.browserCapability,
191
+ });
192
+ window.localStorage.setItem(
193
+ draftStorageKey,
194
+ JSON.stringify(storedDraft),
195
+ );
196
+ } catch {
197
+ setMessage(
198
+ "Draft submitted, but this browser could not save access. Keep this page open to manage it.",
199
+ );
200
+ return;
201
+ }
202
+ setMessage(
203
+ "Draft submitted. You can edit or withdraw it from this browser.",
204
+ );
205
+ if (onCreated) await onCreated(result);
206
+ else window.location.assign(`/p/${encodeURIComponent(result.slug)}`);
207
+ } catch (error) {
208
+ setMessage(submissionFailureMessage(error));
209
+ } finally {
210
+ setPending(false);
211
+ }
212
+ }
213
+
214
+ async function edit(values: FeedbackFormValues) {
215
+ if (!draft) return;
216
+ setPending(true);
217
+ setMessage(undefined);
218
+ try {
219
+ const edited = await mutations.editDraftPost({
220
+ data: {
221
+ id: draft.id,
222
+ browserCapability: draft.browserCapability,
223
+ ...values,
224
+ },
225
+ });
226
+ const storedDraft = { ...draft, ...values };
227
+ window.localStorage.setItem(draftStorageKey, JSON.stringify(storedDraft));
228
+ setDraft(storedDraft);
229
+ setMessage("Draft updated.");
230
+ await onEdited?.(edited);
231
+ } catch (error) {
232
+ if (isCapabilityAuthorizationFailure(error)) onAuthorizationLost?.();
233
+ setMessage(draftFailureMessage(error));
234
+ } finally {
235
+ setPending(false);
236
+ }
237
+ }
238
+
239
+ async function withdraw() {
240
+ if (!draft) return;
241
+ setPending(true);
242
+ setMessage(undefined);
243
+ try {
244
+ await mutations.withdrawDraftPost({
245
+ data: {
246
+ id: draft.id,
247
+ browserCapability: draft.browserCapability,
248
+ },
249
+ });
250
+ window.localStorage.removeItem(draftStorageKey);
251
+ removeCapability(window.localStorage, draft.id);
252
+ setDraft(undefined);
253
+ setMessage("Draft withdrawn.");
254
+ window.location.assign("/");
255
+ } catch (error) {
256
+ setMessage(draftFailureMessage(error));
257
+ } finally {
258
+ setPending(false);
259
+ }
260
+ }
261
+
262
+ return (
263
+ <section
264
+ className="feedback-submit"
265
+ data-display={display}
266
+ aria-labelledby="submit-heading"
267
+ >
268
+ <div className="feedback-submit-copy">
269
+ <p className="feedback-eyebrow">Share feedback</p>
270
+ <h2 id="submit-heading">
271
+ {draft ? "Manage your Draft Post." : "Create a Post"}
272
+ </h2>
273
+ <p>
274
+ Draft access is stored only in this browser. It does not verify your
275
+ identity, cannot be moved to another device, and cannot be recovered
276
+ if browser storage is cleared.
277
+ </p>
278
+ </div>
279
+
280
+ <form
281
+ className="feedback-submit-form"
282
+ noValidate
283
+ onSubmit={(event) => {
284
+ event.preventDefault();
285
+ const token = new FormData(event.currentTarget).get(
286
+ "cf-turnstile-response",
287
+ );
288
+ turnstileToken.current = token ? String(token) : undefined;
289
+ form.handleSubmit();
290
+ }}
291
+ >
292
+ <FieldGroup>
293
+ <form.Field
294
+ name="title"
295
+ children={(field) => {
296
+ const isInvalid = field.state.meta.errors.length > 0;
297
+ return (
298
+ <Field
299
+ className="feedback-submit-field"
300
+ data-invalid={isInvalid}
301
+ >
302
+ <FieldLabel htmlFor={field.name}>Title</FieldLabel>
303
+ <Input
304
+ id={field.name}
305
+ name={field.name}
306
+ required
307
+ maxLength={feedbax.submissions.maxTitleLength}
308
+ value={field.state.value}
309
+ onBlur={field.handleBlur}
310
+ onChange={(event) =>
311
+ field.handleChange(event.currentTarget.value)
312
+ }
313
+ aria-invalid={isInvalid}
314
+ />
315
+ {isInvalid ? (
316
+ <FieldError errors={field.state.meta.errors} />
317
+ ) : null}
318
+ {!draft ? <SimilarPosts title={field.state.value} /> : null}
319
+ </Field>
320
+ );
321
+ }}
322
+ />
323
+ {turnstileSiteKey && !draft ? (
324
+ <div
325
+ className="cf-turnstile"
326
+ data-sitekey={turnstileSiteKey}
327
+ data-theme="light"
328
+ />
329
+ ) : null}
330
+ <form.Field
331
+ name="description"
332
+ children={(field) => {
333
+ const isInvalid = field.state.meta.errors.length > 0;
334
+ return (
335
+ <Field
336
+ className="feedback-submit-field"
337
+ data-invalid={isInvalid}
338
+ >
339
+ <FieldLabel htmlFor={field.name}>Description</FieldLabel>
340
+ <Textarea
341
+ id={field.name}
342
+ name={field.name}
343
+ required
344
+ maxLength={feedbax.submissions.maxDescriptionLength}
345
+ rows={6}
346
+ value={field.state.value}
347
+ onBlur={field.handleBlur}
348
+ onChange={(event) =>
349
+ field.handleChange(event.currentTarget.value)
350
+ }
351
+ aria-invalid={isInvalid}
352
+ aria-describedby="description-count"
353
+ />
354
+ <FieldDescription
355
+ id="description-count"
356
+ className="feedback-character-count"
357
+ >
358
+ {`${field.state.value.length.toLocaleString("en-US")} / ${feedbax.submissions.maxDescriptionLength.toLocaleString("en-US")} characters`}
359
+ </FieldDescription>
360
+ <form.Subscribe selector={(state) => state.values.type}>
361
+ {(type) => (
362
+ <FieldDescription>
363
+ {descriptionGuidance[type]}
364
+ </FieldDescription>
365
+ )}
366
+ </form.Subscribe>
367
+ {isInvalid ? (
368
+ <FieldError errors={field.state.meta.errors} />
369
+ ) : null}
370
+ </Field>
371
+ );
372
+ }}
373
+ />
374
+ <form.Field
375
+ name="type"
376
+ children={(field) => {
377
+ const isInvalid = field.state.meta.errors.length > 0;
378
+ return (
379
+ <Field
380
+ className="feedback-submit-field"
381
+ data-invalid={isInvalid}
382
+ >
383
+ <FieldLabel id={`${field.name}-label`}>Post Type</FieldLabel>
384
+ <RadioGroup
385
+ aria-labelledby={`${field.name}-label`}
386
+ aria-invalid={isInvalid}
387
+ className="feedback-post-type-picker"
388
+ name={field.name}
389
+ value={field.state.value}
390
+ onBlur={field.handleBlur}
391
+ onValueChange={(value) => field.handleChange(value)}
392
+ >
393
+ {postTypeOptions.map(
394
+ ({ description, icon: Icon, type }) => (
395
+ <FieldLabel
396
+ className="feedback-post-type-card"
397
+ key={type}
398
+ >
399
+ <RadioGroupItem
400
+ aria-invalid={isInvalid}
401
+ value={type}
402
+ />
403
+ <Icon aria-hidden="true" />
404
+ <span className="feedback-post-type-copy">
405
+ <strong>{type}</strong>
406
+ <small>{description}</small>
407
+ </span>
408
+ </FieldLabel>
409
+ ),
410
+ )}
411
+ </RadioGroup>
412
+ {isInvalid ? (
413
+ <FieldError errors={field.state.meta.errors} />
414
+ ) : null}
415
+ </Field>
416
+ );
417
+ }}
418
+ />
419
+ {!draft ? (
420
+ <ImageAttachments
421
+ value={images}
422
+ onChange={setImages}
423
+ onUploadingChange={setUploading}
424
+ />
425
+ ) : null}
426
+ </FieldGroup>
427
+ <div className="feedback-submit-actions">
428
+ <Button type="submit" disabled={pending || uploading}>
429
+ {pending ? "Working…" : draft ? "Save changes" : "Create Post"}
430
+ </Button>
431
+ {display === "overlay" ? (
432
+ <Button
433
+ type="button"
434
+ variant="ghost"
435
+ className="feedback-cancel"
436
+ onClick={onCancel}
437
+ disabled={pending || uploading}
438
+ >
439
+ Cancel
440
+ </Button>
441
+ ) : null}
442
+ {draft && showWithdrawal ? (
443
+ <Button
444
+ type="button"
445
+ variant="destructive"
446
+ className="feedback-withdraw"
447
+ onClick={() => setConfirmingWithdrawal(true)}
448
+ disabled={pending || uploading}
449
+ >
450
+ Withdraw draft
451
+ </Button>
452
+ ) : null}
453
+ </div>
454
+ {message ? (
455
+ <p className="feedback-submit-message" role="status">
456
+ {message}
457
+ </p>
458
+ ) : null}
459
+ </form>
460
+ <AlertDialog
461
+ open={confirmingWithdrawal}
462
+ onOpenChange={setConfirmingWithdrawal}
463
+ >
464
+ <AlertDialogContent>
465
+ <AlertDialogHeader>
466
+ <AlertDialogTitle>
467
+ Withdraw this Draft Post permanently?
468
+ </AlertDialogTitle>
469
+ <AlertDialogDescription>
470
+ This cannot be undone.
471
+ </AlertDialogDescription>
472
+ </AlertDialogHeader>
473
+ <AlertDialogFooter>
474
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
475
+ <AlertDialogAction onClick={withdraw}>
476
+ Withdraw Draft Post
477
+ </AlertDialogAction>
478
+ </AlertDialogFooter>
479
+ </AlertDialogContent>
480
+ </AlertDialog>
481
+ </section>
482
+ );
483
+ }
484
+
485
+ const descriptionGuidance: Record<PostType, string> = {
486
+ "Feature Request":
487
+ "Describe the problem, desired outcome, and any current workaround.",
488
+ "Bug Report":
489
+ "Include steps, expected behavior, actual behavior, and your environment.",
490
+ "General Feedback": "Share the context, your observation, and its impact.",
491
+ };
492
+
493
+ function submissionFailureMessage(error: unknown): string {
494
+ const detail = error instanceof Error ? error.message : "";
495
+ if (detail.includes("rate limit")) {
496
+ return "Too many submissions were attempted. Please wait and try again.";
497
+ }
498
+ if (detail.includes("verification")) {
499
+ if (!turnstileSiteKey) {
500
+ return "Spam protection is not configured correctly. Please contact the Product Team.";
501
+ }
502
+ return "Spam verification did not complete. Please retry the challenge.";
503
+ }
504
+ if (detail.includes("invalid")) {
505
+ return "Check the required fields and their length, then try again.";
506
+ }
507
+ return "Feedback could not be submitted. Please try again.";
508
+ }
509
+
510
+ function browserCanStoreDraft(): boolean {
511
+ const key = `${draftStorageKey}:check`;
512
+ try {
513
+ window.localStorage.setItem(key, "available");
514
+ window.localStorage.removeItem(key);
515
+ return true;
516
+ } catch {
517
+ return false;
518
+ }
519
+ }
520
+
521
+ function draftFailureMessage(error: unknown): string {
522
+ const detail = error instanceof Error ? error.message : "";
523
+ if (detail.includes("did not authorize")) {
524
+ return "This draft is no longer editable from this browser.";
525
+ }
526
+ if (detail.includes("invalid")) {
527
+ return "Check the required fields and their length, then try again.";
528
+ }
529
+ return "The draft could not be changed. Please try again.";
530
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { warnIfTurnstileDisabled } from "./portal-feedback-runtime";
4
+
5
+ describe("portal feedback submission security configuration", () => {
6
+ it("warns prominently only in production when Turnstile is disabled", () => {
7
+ const localWarning = vi.fn();
8
+ const productionWarning = vi.fn();
9
+ const rateLimiter = {
10
+ limit: vi.fn().mockResolvedValue({ success: true }),
11
+ };
12
+
13
+ warnIfTurnstileDisabled({
14
+ turnstile: { enabled: false },
15
+ environment: { PORTAL_SUBMISSIONS_RATE_LIMITER: rateLimiter },
16
+ nodeEnv: "development",
17
+ warn: localWarning,
18
+ });
19
+ warnIfTurnstileDisabled({
20
+ turnstile: { enabled: false },
21
+ environment: { PORTAL_SUBMISSIONS_RATE_LIMITER: rateLimiter },
22
+ nodeEnv: "production",
23
+ warn: productionWarning,
24
+ });
25
+
26
+ expect(localWarning).not.toHaveBeenCalled();
27
+ expect(productionWarning).toHaveBeenCalledOnce();
28
+ expect(productionWarning).toHaveBeenCalledWith(
29
+ "WARNING: Turnstile is disabled in production; this Installation has weaker spam protection.",
30
+ );
31
+ });
32
+ });
@@ -0,0 +1,58 @@
1
+ import feedbax from "./feedbax";
2
+ import type { FeedbaxConfig } from "@feedbax/config";
3
+ import { configuredTurnstileSecret } from "./turnstile-configuration";
4
+ import { createCloudflareTurnstileVerifier } from "./cloudflare-turnstile";
5
+ import type { PortalSubmissionRateLimiter } from "./portal-feedback-submission";
6
+
7
+ interface PortalSubmissionEnvironment {
8
+ PORTAL_SUBMISSIONS_RATE_LIMITER: PortalSubmissionRateLimiter;
9
+ TURNSTILE_SECRET_KEY?: string;
10
+ }
11
+
12
+ interface PortalSubmissionSecurityOptions {
13
+ turnstile?: FeedbaxConfig["turnstile"];
14
+ environment: PortalSubmissionEnvironment;
15
+ rateLimitKey: string;
16
+ }
17
+
18
+ interface TurnstileWarningOptions {
19
+ turnstile?: FeedbaxConfig["turnstile"];
20
+ environment: PortalSubmissionEnvironment;
21
+ nodeEnv: "development" | "production" | "test";
22
+ warn?: (message: string) => void;
23
+ }
24
+
25
+ export function createPortalSubmissionSecurity({
26
+ turnstile = feedbax.turnstile,
27
+ environment,
28
+ rateLimitKey,
29
+ }: PortalSubmissionSecurityOptions) {
30
+ const turnstileSecretKey = configuredTurnstileSecret(
31
+ environment.TURNSTILE_SECRET_KEY,
32
+ turnstile,
33
+ );
34
+
35
+ return {
36
+ rateLimiter: environment.PORTAL_SUBMISSIONS_RATE_LIMITER,
37
+ rateLimitKey,
38
+ ...(turnstileSecretKey
39
+ ? {
40
+ turnstileVerifier: createCloudflareTurnstileVerifier({
41
+ secretKey: turnstileSecretKey,
42
+ }),
43
+ }
44
+ : {}),
45
+ };
46
+ }
47
+
48
+ export function warnIfTurnstileDisabled({
49
+ turnstile = feedbax.turnstile,
50
+ nodeEnv,
51
+ warn = console.warn,
52
+ }: TurnstileWarningOptions): void {
53
+ if (nodeEnv === "production" && !turnstile.enabled) {
54
+ warn(
55
+ "WARNING: Turnstile is disabled in production; this Installation has weaker spam protection.",
56
+ );
57
+ }
58
+ }