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