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,212 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ within,
9
+ } from "@testing-library/react";
10
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
11
+ import {
12
+ RouterProvider,
13
+ createMemoryHistory,
14
+ createRootRoute,
15
+ createRouter,
16
+ } from "@tanstack/react-router";
17
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
18
+
19
+ import userEvent from "@testing-library/user-event";
20
+
21
+ import { capabilitiesKey } from "./browser-post-state";
22
+ import { readCapabilities } from "./browser-post-state";
23
+ import { AuthorizedDraftPost } from "./authorized-draft-post";
24
+ import {
25
+ editPortalFeedbackDraft,
26
+ getPortalDraftPost,
27
+ withdrawPortalFeedbackDraft,
28
+ } from "./portal-feedback-server-function";
29
+
30
+ vi.mock("./portal-feedback-server-function", () => ({
31
+ getPortalDraftPost: vi.fn(),
32
+ editPortalFeedbackDraft: vi.fn(),
33
+ withdrawPortalFeedbackDraft: vi.fn(),
34
+ submitPortalPost: vi.fn(),
35
+ }));
36
+
37
+ beforeEach(() => {
38
+ localStorage.clear();
39
+ localStorage.setItem(
40
+ capabilitiesKey,
41
+ JSON.stringify({
42
+ draft: {
43
+ id: "draft",
44
+ slug: "editable-draft",
45
+ browserCapability: "secret",
46
+ },
47
+ }),
48
+ );
49
+ vi.mocked(getPortalDraftPost).mockResolvedValue(draft);
50
+ });
51
+
52
+ afterEach(() => {
53
+ cleanup();
54
+ vi.clearAllMocks();
55
+ });
56
+
57
+ it("shows an authorized Draft Post in the shared detail layout", async () => {
58
+ renderDraft();
59
+
60
+ const article = within(
61
+ await screen.findByRole("article", { name: "Original title" }),
62
+ );
63
+ expect(article.getByText("Draft")).toBeTruthy();
64
+ expect(article.queryByRole("button", { name: /vote/i })).toBeNull();
65
+ expect(article.queryByLabelText("Comments unavailable")).toBeNull();
66
+ expect(article.queryByRole("region", { name: "Comments" })).toBeNull();
67
+ const details = within(
68
+ article.getByRole("complementary", { name: "Details" }),
69
+ );
70
+ expect(details.getByText("New")).toBeTruthy();
71
+ expect(details.getByText("Feature Request")).toBeTruthy();
72
+ expect(details.queryByText("Author")).toBeNull();
73
+ expect(screen.getByRole("button", { name: "Edit" })).toBeTruthy();
74
+ expect(screen.getByRole("button", { name: "Draft actions" })).toBeTruthy();
75
+ });
76
+
77
+ it("edits in place, keeps Cancel unchanged, and reconciles the visible draft", async () => {
78
+ vi.mocked(editPortalFeedbackDraft).mockResolvedValue({
79
+ ...draft,
80
+ title: "Edited title",
81
+ } as never);
82
+ const client = new QueryClient();
83
+ client.setQueryData(["authorized-draft-posts", ["draft"]], [draft]);
84
+ renderDraft(client);
85
+
86
+ expect(
87
+ await screen.findByRole("heading", { name: "Original title" }),
88
+ ).toBeTruthy();
89
+ fireEvent.click(screen.getByRole("button", { name: "Edit" }));
90
+ fireEvent.change(screen.getByLabelText("Title"), {
91
+ target: { value: "Discarded title" },
92
+ });
93
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
94
+ expect(screen.getByRole("heading", { name: "Original title" })).toBeTruthy();
95
+
96
+ fireEvent.click(screen.getByRole("button", { name: "Edit" }));
97
+ fireEvent.change(screen.getByLabelText("Title"), {
98
+ target: { value: "Edited title" },
99
+ });
100
+ fireEvent.submit(screen.getByRole("button", { name: "Save changes" }));
101
+
102
+ expect(
103
+ await screen.findByRole("heading", { name: "Edited title" }),
104
+ ).toBeTruthy();
105
+ expect(
106
+ client.getQueryData<Array<{ title: string }>>([
107
+ "authorized-draft-posts",
108
+ ["draft"],
109
+ ])?.[0]?.title,
110
+ ).toBe("Edited title");
111
+ });
112
+
113
+ it("keeps edit mode open and reports a mutation failure", async () => {
114
+ vi.mocked(editPortalFeedbackDraft).mockRejectedValue(new Error("offline"));
115
+ renderDraft();
116
+ fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
117
+ fireEvent.submit(screen.getByRole("button", { name: "Save changes" }));
118
+
119
+ expect(
120
+ await screen.findByText(
121
+ "The draft could not be changed. Please try again.",
122
+ ),
123
+ ).toBeTruthy();
124
+ expect(screen.getByRole("button", { name: "Save changes" })).toBeTruthy();
125
+ });
126
+
127
+ it("removes expired authority when Save proves the capability invalid", async () => {
128
+ vi.mocked(editPortalFeedbackDraft).mockRejectedValue(
129
+ new Error("Browser Capability did not authorize this draft."),
130
+ );
131
+ renderDraft();
132
+ fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
133
+ fireEvent.submit(screen.getByRole("button", { name: "Save changes" }));
134
+
135
+ expect(
136
+ await screen.findByRole("heading", { name: "This Post isn’t available." }),
137
+ ).toBeTruthy();
138
+ expect(readCapabilities(localStorage)).toEqual({});
139
+ });
140
+
141
+ it("confirms withdrawal, removes visible state, and cleans up authority", async () => {
142
+ vi.mocked(withdrawPortalFeedbackDraft).mockResolvedValue(undefined);
143
+ const client = new QueryClient();
144
+ client.setQueryData(["authorized-draft-posts", ["draft"]], [draft]);
145
+ renderDraft(client);
146
+
147
+ const actions = await screen.findByRole("button", { name: "Draft actions" });
148
+ const user = userEvent.setup();
149
+ await user.click(actions);
150
+ await user.click(
151
+ await screen.findByRole("menuitem", { name: "Withdraw draft" }),
152
+ );
153
+ await user.click(
154
+ await screen.findByRole("button", { name: "Withdraw Draft Post" }),
155
+ );
156
+
157
+ await vi.waitFor(() => expect(readCapabilities(localStorage)).toEqual({}));
158
+ expect(
159
+ client.getQueryData<unknown[]>(["authorized-draft-posts", ["draft"]]),
160
+ ).toEqual([]);
161
+ });
162
+
163
+ it("keeps the draft visible and reports a withdrawal failure", async () => {
164
+ vi.mocked(withdrawPortalFeedbackDraft).mockRejectedValue(
165
+ new Error("offline"),
166
+ );
167
+ renderDraft();
168
+
169
+ const actions = await screen.findByRole("button", { name: "Draft actions" });
170
+ const user = userEvent.setup();
171
+ await user.click(actions);
172
+ await user.click(
173
+ await screen.findByRole("menuitem", { name: "Withdraw draft" }),
174
+ );
175
+ await user.click(
176
+ await screen.findByRole("button", { name: "Withdraw Draft Post" }),
177
+ );
178
+
179
+ const alert = await screen.findByRole("alert");
180
+ expect(alert.textContent).toBe(
181
+ "The draft could not be withdrawn. Please try again.",
182
+ );
183
+ expect(screen.getByRole("heading", { name: "Original title" })).toBeTruthy();
184
+ expect(Object.keys(readCapabilities(localStorage))).toEqual(["draft"]);
185
+ });
186
+
187
+ function renderDraft(client = new QueryClient()) {
188
+ const root = createRootRoute({
189
+ component: () => <AuthorizedDraftPost slug="editable-draft" />,
190
+ });
191
+ const router = createRouter({
192
+ history: createMemoryHistory({ initialEntries: ["/p/editable-draft"] }),
193
+ routeTree: root,
194
+ });
195
+ render(
196
+ <QueryClientProvider client={client}>
197
+ <RouterProvider router={router} />
198
+ </QueryClientProvider>,
199
+ );
200
+ }
201
+
202
+ const draft = {
203
+ id: "draft",
204
+ slug: "editable-draft",
205
+ title: "Original title",
206
+ description: "Original description",
207
+ type: "Feature Request" as const,
208
+ status: "New" as const,
209
+ submitter: {},
210
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
211
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
212
+ };
@@ -0,0 +1,205 @@
1
+ import type { DraftPost } from "@feedbax/feedback";
2
+ import { MoreHorizontal } from "lucide-react";
3
+ import { useEffect, useState } from "react";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
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 {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuTrigger,
21
+ } from "@feedbax/ui/components/dropdown-menu";
22
+ import { Badge } from "@feedbax/ui/components/badge";
23
+ import { Button } from "@feedbax/ui/components/button";
24
+ import { toast } from "sonner";
25
+
26
+ import {
27
+ PortalFeedbackForm,
28
+ type PortalFeedbackMutations,
29
+ } from "./portal-feedback-form";
30
+ import { readCapabilities, removeCapability } from "./browser-post-state";
31
+ import {
32
+ editPortalFeedbackDraft,
33
+ getPortalDraftPost,
34
+ submitPortalPost,
35
+ withdrawPortalFeedbackDraft,
36
+ } from "./portal-feedback-server-function";
37
+ import { PublicPostUnavailable } from "./public-post-unavailable";
38
+ import { isCapabilityAuthorizationFailure } from "./capability-authorization-error";
39
+ import { PublicPostDetail } from "./public-post-detail";
40
+
41
+ export function AuthorizedDraftPost({ slug }: { slug: string }) {
42
+ const [post, setPost] = useState<DraftPost | null>();
43
+ const [editing, setEditing] = useState(false);
44
+ const [confirmingWithdrawal, setConfirmingWithdrawal] = useState(false);
45
+ const [withdrawalError, setWithdrawalError] = useState<string>();
46
+ const queryClient = useQueryClient();
47
+ const router = useRouter();
48
+ const navigate = useNavigate();
49
+ const overlaid = useRouterState({
50
+ select: (state) => Boolean(state.location.state.postDetailOverlay),
51
+ });
52
+ const capability = Object.values(readCapabilities(localStorage)).find(
53
+ (item) => item.slug === slug,
54
+ );
55
+
56
+ useEffect(() => {
57
+ setPost(undefined);
58
+ if (!capability) {
59
+ setPost(null);
60
+ return;
61
+ }
62
+ getPortalDraftPost({ data: capability })
63
+ .then(setPost)
64
+ .catch((error: unknown) => {
65
+ if (isCapabilityAuthorizationFailure(error)) {
66
+ removeCapability(localStorage, capability.id);
67
+ }
68
+ setPost(null);
69
+ });
70
+ }, [capability?.browserCapability, capability?.id, slug]);
71
+
72
+ if (post === undefined)
73
+ return (
74
+ <main className="feedback-detail">
75
+ <p>Loading Draft Post…</p>
76
+ </main>
77
+ );
78
+ if (post === null || !capability) return <PublicPostUnavailable />;
79
+
80
+ const updateVisibleDraft = (
81
+ edited: Awaited<ReturnType<PortalFeedbackMutations["editDraftPost"]>>,
82
+ ) => {
83
+ const next = { ...post, ...edited, slug: post.slug };
84
+ setPost(next);
85
+ setEditing(false);
86
+ queryClient.setQueriesData<DraftPost[]>(
87
+ { queryKey: ["authorized-draft-posts"] },
88
+ (current) => current?.map((item) => (item.id === next.id ? next : item)),
89
+ );
90
+ toast.success("Draft updated.");
91
+ };
92
+
93
+ const withdraw = async () => {
94
+ setWithdrawalError(undefined);
95
+ try {
96
+ await withdrawPortalFeedbackDraft({ data: capability });
97
+ removeCapability(localStorage, post.id);
98
+ queryClient.setQueriesData<DraftPost[]>(
99
+ { queryKey: ["authorized-draft-posts"] },
100
+ (current) => current?.filter((item) => item.id !== post.id),
101
+ );
102
+ await queryClient.invalidateQueries({ queryKey: ["public-posts"] });
103
+ if (overlaid) router.history.back();
104
+ else await navigate({ to: "/" });
105
+ } catch (error) {
106
+ if (isCapabilityAuthorizationFailure(error)) {
107
+ removeCapability(localStorage, post.id);
108
+ setPost(null);
109
+ } else {
110
+ setWithdrawalError(
111
+ "The draft could not be withdrawn. Please try again.",
112
+ );
113
+ }
114
+ } finally {
115
+ setConfirmingWithdrawal(false);
116
+ }
117
+ };
118
+
119
+ return (
120
+ <>
121
+ {editing ? (
122
+ <main className="feedback-detail">
123
+ <PortalFeedbackForm
124
+ display="overlay"
125
+ onCancel={() => setEditing(false)}
126
+ onEdited={updateVisibleDraft}
127
+ onAuthorizationLost={() => {
128
+ removeCapability(localStorage, post.id);
129
+ setPost(null);
130
+ }}
131
+ showWithdrawal={false}
132
+ mutations={{
133
+ submitPost: submitPortalPost,
134
+ editDraftPost: editPortalFeedbackDraft,
135
+ withdrawDraftPost: withdrawPortalFeedbackDraft,
136
+ }}
137
+ initialDraft={{
138
+ id: post.id,
139
+ browserCapability: capability.browserCapability,
140
+ title: post.title,
141
+ description: post.description,
142
+ type: post.type,
143
+ }}
144
+ />
145
+ </main>
146
+ ) : (
147
+ <PublicPostDetail
148
+ post={post}
149
+ published={false}
150
+ display={overlaid ? "overlay" : "page"}
151
+ features={{ voting: false, comments: false, changelog: true }}
152
+ summaryMarker={<Badge variant="outline">Draft</Badge>}
153
+ summaryActions={
154
+ <>
155
+ <div className="draft-detail-actions">
156
+ <Button type="button" onClick={() => setEditing(true)}>
157
+ Edit
158
+ </Button>
159
+ <DropdownMenu>
160
+ <DropdownMenuTrigger
161
+ aria-label="Draft actions"
162
+ render={
163
+ <Button type="button" variant="ghost" size="icon" />
164
+ }
165
+ >
166
+ <MoreHorizontal aria-hidden="true" />
167
+ </DropdownMenuTrigger>
168
+ <DropdownMenuContent align="end">
169
+ <DropdownMenuItem
170
+ onClick={() => setConfirmingWithdrawal(true)}
171
+ >
172
+ Withdraw draft
173
+ </DropdownMenuItem>
174
+ </DropdownMenuContent>
175
+ </DropdownMenu>
176
+ </div>
177
+ {withdrawalError ? <p role="alert">{withdrawalError}</p> : null}
178
+ </>
179
+ }
180
+ />
181
+ )}
182
+ <AlertDialog
183
+ open={confirmingWithdrawal}
184
+ onOpenChange={setConfirmingWithdrawal}
185
+ >
186
+ <AlertDialogContent>
187
+ <AlertDialogHeader>
188
+ <AlertDialogTitle>
189
+ Withdraw this Draft Post permanently?
190
+ </AlertDialogTitle>
191
+ <AlertDialogDescription>
192
+ This cannot be undone.
193
+ </AlertDialogDescription>
194
+ </AlertDialogHeader>
195
+ <AlertDialogFooter>
196
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
197
+ <AlertDialogAction onClick={withdraw}>
198
+ Withdraw Draft Post
199
+ </AlertDialogAction>
200
+ </AlertDialogFooter>
201
+ </AlertDialogContent>
202
+ </AlertDialog>
203
+ </>
204
+ );
205
+ }
@@ -0,0 +1,74 @@
1
+ import { queryOptions } from "@tanstack/react-query";
2
+ import type { DraftPost } from "@feedbax/feedback";
3
+
4
+ import {
5
+ removeCapability,
6
+ type StoredPostCapability,
7
+ } from "./browser-post-state";
8
+
9
+ type FetchAuthorizedDraft = (input: {
10
+ data: StoredPostCapability;
11
+ }) => Promise<DraftPost>;
12
+
13
+ type CapabilityStorage = Pick<Storage, "getItem" | "setItem">;
14
+
15
+ export const authorizedDraftPostsQueryRoot = [
16
+ "authorized-draft-posts",
17
+ ] as const;
18
+
19
+ export function authorizedDraftPostsQueryKey(
20
+ capabilities: Record<string, StoredPostCapability>,
21
+ ) {
22
+ return [
23
+ ...authorizedDraftPostsQueryRoot,
24
+ Object.keys(capabilities).sort(),
25
+ ] as const;
26
+ }
27
+
28
+ export function authorizedDraftPostsQuery(
29
+ capabilities: Record<string, StoredPostCapability>,
30
+ fetchDraft: FetchAuthorizedDraft,
31
+ storage?: CapabilityStorage,
32
+ ) {
33
+ const entries = Object.values(capabilities);
34
+ return queryOptions({
35
+ queryKey: authorizedDraftPostsQueryKey(capabilities),
36
+ queryFn: async () => {
37
+ const results = await Promise.allSettled(
38
+ entries.map(async (capability) => ({
39
+ post: await fetchDraft({ data: capability }),
40
+ })),
41
+ );
42
+ const drafts: DraftPost[] = [];
43
+ let transientFailure: unknown;
44
+ for (const [index, result] of results.entries()) {
45
+ if (result.status === "fulfilled") {
46
+ drafts.push(result.value.post);
47
+ continue;
48
+ }
49
+ const capability = entries[index];
50
+ if (storage && capability && isAuthorizationFailure(result.reason)) {
51
+ removeCapability(storage, capability.id);
52
+ } else {
53
+ transientFailure = result.reason;
54
+ }
55
+ }
56
+ if (transientFailure) throw transientFailure;
57
+ return drafts.sort(
58
+ (left, right) =>
59
+ new Date(right.updatedAt).getTime() -
60
+ new Date(left.updatedAt).getTime() ||
61
+ right.id.localeCompare(left.id),
62
+ );
63
+ },
64
+ staleTime: 30_000,
65
+ });
66
+ }
67
+
68
+ function isAuthorizationFailure(error: unknown): boolean {
69
+ return (
70
+ error instanceof Error &&
71
+ (error.name === "BrowserCapabilityAuthorizationError" ||
72
+ error.message.includes("did not authorize"))
73
+ );
74
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ capabilitiesKey,
5
+ clearDeviceProfile,
6
+ deriveDeviceProfileInitials,
7
+ deviceProfileKey,
8
+ isCompleteDeviceProfile,
9
+ readCapabilities,
10
+ readDeviceProfile,
11
+ retainCapability,
12
+ saveDeviceProfile,
13
+ } from "./browser-post-state";
14
+
15
+ describe("device-local Participant state", () => {
16
+ it("persists a valid Device Profile and derives its initials", () => {
17
+ const storage = memoryStorage();
18
+
19
+ saveDeviceProfile(storage, {
20
+ name: " Ama Mensah ",
21
+ email: " ama@example.com ",
22
+ });
23
+
24
+ expect(readDeviceProfile(storage)).toEqual({
25
+ name: "Ama Mensah",
26
+ email: "ama@example.com",
27
+ });
28
+ expect(deriveDeviceProfileInitials(readDeviceProfile(storage)!)).toBe("AM");
29
+ expect(isCompleteDeviceProfile(readDeviceProfile(storage))).toBe(true);
30
+ });
31
+
32
+ it("preserves a legacy name-only profile but treats it as incomplete", () => {
33
+ const storage = memoryStorage({
34
+ [deviceProfileKey]: JSON.stringify({ name: "Ama" }),
35
+ });
36
+
37
+ expect(readDeviceProfile(storage)).toEqual({ name: "Ama" });
38
+ expect(isCompleteDeviceProfile(readDeviceProfile(storage))).toBe(false);
39
+ });
40
+
41
+ it("treats malformed email addresses as incomplete", () => {
42
+ const storage = memoryStorage({
43
+ [deviceProfileKey]: JSON.stringify({
44
+ name: "Ama",
45
+ email: "not-an-email",
46
+ }),
47
+ });
48
+
49
+ expect(isCompleteDeviceProfile(readDeviceProfile(storage))).toBe(false);
50
+ });
51
+
52
+ it("rejects malformed saved profiles", () => {
53
+ const malformedProfiles = [
54
+ null,
55
+ {},
56
+ { name: "" },
57
+ { name: "Ama", email: 42 },
58
+ { name: "Ama", extra: "unexpected" },
59
+ ];
60
+
61
+ for (const profile of malformedProfiles) {
62
+ const storage = memoryStorage({
63
+ [deviceProfileKey]: JSON.stringify(profile),
64
+ });
65
+ expect(readDeviceProfile(storage)).toBeUndefined();
66
+ }
67
+ });
68
+
69
+ it("clears only the Device Profile and preserves Browser Capabilities", () => {
70
+ const capability = {
71
+ id: "post-id",
72
+ slug: "keyboard-navigation",
73
+ browserCapability: "secret-capability",
74
+ };
75
+ const storage = memoryStorage({
76
+ [deviceProfileKey]: JSON.stringify({ name: "Ama" }),
77
+ [capabilitiesKey]: JSON.stringify({ [capability.id]: capability }),
78
+ });
79
+
80
+ clearDeviceProfile(storage);
81
+
82
+ expect(readDeviceProfile(storage)).toBeUndefined();
83
+ expect(readCapabilities(storage)).toEqual({ [capability.id]: capability });
84
+ });
85
+
86
+ it("retains several valid Browser Capabilities and ignores malformed saved entries", () => {
87
+ const storage = memoryStorage({
88
+ [capabilitiesKey]: JSON.stringify({
89
+ malformed: { id: "malformed", slug: "missing-secret" },
90
+ }),
91
+ });
92
+ const first = {
93
+ id: "post-1",
94
+ slug: "keyboard-navigation",
95
+ browserCapability: "first-secret",
96
+ };
97
+ const second = {
98
+ id: "post-2",
99
+ slug: "dark-mode",
100
+ browserCapability: "second-secret",
101
+ };
102
+
103
+ retainCapability(storage, first);
104
+ retainCapability(storage, second);
105
+
106
+ expect(readCapabilities(storage)).toEqual({
107
+ [first.id]: first,
108
+ [second.id]: second,
109
+ });
110
+ });
111
+ });
112
+
113
+ function memoryStorage(initial: Record<string, string> = {}) {
114
+ const values = new Map(Object.entries(initial));
115
+ return {
116
+ getItem: (key: string) => values.get(key) ?? null,
117
+ setItem: (key: string, value: string) => values.set(key, value),
118
+ removeItem: (key: string) => values.delete(key),
119
+ };
120
+ }