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,106 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createPortalVoteHandler } from "./portal-voting";
3
+ import { createFeedbackModule } from "@feedbax/feedback";
4
+
5
+ function publishedFeedback() {
6
+ const now = new Date("2026-08-25T00:00:00.000Z");
7
+ return createFeedbackModule({
8
+ initialItems: [
9
+ {
10
+ id: "post",
11
+ slug: "post",
12
+ title: "Post",
13
+ description: "Visible.",
14
+ type: "Feature Request",
15
+ status: "Planned",
16
+ published: true,
17
+ voteCount: 2,
18
+ createdAt: now,
19
+ updatedAt: now,
20
+ },
21
+ ],
22
+ });
23
+ }
24
+
25
+ describe("portal Vote handler", () => {
26
+ it("rate-limits Vote operations and returns an actionable error", async () => {
27
+ const feedback = publishedFeedback();
28
+ const vote = createPortalVoteHandler({
29
+ feedback,
30
+ votingEnabled: true,
31
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: false }) },
32
+ rateLimitKey: "participant-ip",
33
+ });
34
+
35
+ await expect(vote({ slug: "post", intention: "add" })).rejects.toThrow(
36
+ "Vote rate limit exceeded",
37
+ );
38
+ await expect(feedback.getPublicPost("post")).resolves.toMatchObject({
39
+ voteCount: 2,
40
+ });
41
+ });
42
+
43
+ it("issues a thirty-minute Participation Pass after first verification and accepts reuse", async () => {
44
+ let now = 1_000;
45
+ const verify = vi.fn().mockResolvedValue(true);
46
+ const options = {
47
+ feedback: publishedFeedback(),
48
+ votingEnabled: true,
49
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
50
+ rateLimitKey: "participant-ip",
51
+ turnstileVerifier: { verify },
52
+ participationSigningSecret:
53
+ "a-high-entropy-secret-with-at-least-32-characters",
54
+ now: () => now,
55
+ };
56
+ const vote = createPortalVoteHandler(options);
57
+ const first = await vote({
58
+ slug: "post",
59
+ intention: "add",
60
+ turnstileToken: "verified",
61
+ });
62
+ now += 29 * 60 * 1_000;
63
+ await vote({
64
+ slug: "post",
65
+ intention: "remove",
66
+ participationPass: first.participationPass,
67
+ });
68
+
69
+ expect(first.participationPass).toEqual(expect.any(String));
70
+ expect(verify).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it("fails closed for a tampered or expired Participation Pass", async () => {
74
+ let now = 1_000;
75
+ const vote = createPortalVoteHandler({
76
+ feedback: publishedFeedback(),
77
+ votingEnabled: true,
78
+ rateLimiter: { limit: vi.fn().mockResolvedValue({ success: true }) },
79
+ rateLimitKey: "participant-ip",
80
+ turnstileVerifier: { verify: vi.fn().mockResolvedValue(true) },
81
+ participationSigningSecret:
82
+ "a-high-entropy-secret-with-at-least-32-characters",
83
+ now: () => now,
84
+ });
85
+ const first = await vote({
86
+ slug: "post",
87
+ intention: "add",
88
+ turnstileToken: "verified",
89
+ });
90
+ now += 31 * 60 * 1_000;
91
+ await expect(
92
+ vote({
93
+ slug: "post",
94
+ intention: "remove",
95
+ participationPass: first.participationPass,
96
+ }),
97
+ ).rejects.toThrow("verification is required");
98
+ await expect(
99
+ vote({
100
+ slug: "post",
101
+ intention: "remove",
102
+ participationPass: `${first.participationPass}x`,
103
+ }),
104
+ ).rejects.toThrow("verification is required");
105
+ });
106
+ });
@@ -0,0 +1,149 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ import type { FeedbackModule, VoteResult } from "@feedbax/feedback";
4
+ import type { TurnstileVerifier } from "./cloudflare-turnstile";
5
+ import type { PortalSubmissionRateLimiter } from "./portal-feedback-submission";
6
+ import { ActionablePortalFailure } from "./safe-public-failure";
7
+
8
+ const participationPassLifetime = 30 * 60 * 1_000;
9
+
10
+ export interface PortalVoteResult extends VoteResult {
11
+ participationPass?: string;
12
+ }
13
+
14
+ export function createPortalVoteHandler(options: {
15
+ feedback: Pick<FeedbackModule, "changeVote">;
16
+ votingEnabled: boolean;
17
+ rateLimiter: PortalSubmissionRateLimiter;
18
+ rateLimitKey: string;
19
+ turnstileVerifier?: TurnstileVerifier;
20
+ participationSigningSecret?: string;
21
+ now?: () => number;
22
+ }) {
23
+ return async (input: unknown): Promise<PortalVoteResult> => {
24
+ if (!options.votingEnabled)
25
+ throw new ActionablePortalFailure(
26
+ "Voting is disabled for this Installation.",
27
+ "voting_disabled",
28
+ );
29
+ const value = validateVoteInput(input);
30
+ const { success } = await options.rateLimiter.limit({
31
+ key: `vote:${options.rateLimitKey}`,
32
+ });
33
+ if (!success)
34
+ throw new ActionablePortalFailure(
35
+ "Vote rate limit exceeded. Try again shortly.",
36
+ "rate_limited",
37
+ );
38
+
39
+ let participationPass: string | undefined;
40
+ if (options.turnstileVerifier) {
41
+ const secret = requiredParticipationSigningSecret(
42
+ options.participationSigningSecret,
43
+ );
44
+ const now = options.now?.() ?? Date.now();
45
+ if (
46
+ !value.participationPass ||
47
+ !verifyParticipationPass(value.participationPass, secret, now)
48
+ ) {
49
+ if (!value.turnstileToken)
50
+ throw new ActionablePortalFailure(
51
+ "Vote verification is required.",
52
+ "verification_required",
53
+ );
54
+ const verified = await options.turnstileVerifier.verify({
55
+ token: value.turnstileToken,
56
+ remoteIp: options.rateLimitKey,
57
+ });
58
+ if (!verified)
59
+ throw new ActionablePortalFailure(
60
+ "Vote verification failed.",
61
+ "verification_failed",
62
+ );
63
+ participationPass = issueParticipationPass(secret, now);
64
+ }
65
+ }
66
+
67
+ const result = await options.feedback.changeVote(value);
68
+ return { ...result, ...(participationPass ? { participationPass } : {}) };
69
+ };
70
+ }
71
+
72
+ function validateVoteInput(input: unknown): {
73
+ slug: string;
74
+ intention: "add" | "remove";
75
+ turnstileToken?: string;
76
+ participationPass?: string;
77
+ } {
78
+ if (!input || typeof input !== "object" || Array.isArray(input))
79
+ throw new ActionablePortalFailure(
80
+ "Vote request is invalid.",
81
+ "invalid_request",
82
+ );
83
+ const value = input as Record<string, unknown>;
84
+ if (
85
+ typeof value.slug !== "string" ||
86
+ value.slug.length === 0 ||
87
+ (value.intention !== "add" && value.intention !== "remove")
88
+ )
89
+ throw new ActionablePortalFailure(
90
+ "Vote request is invalid.",
91
+ "invalid_request",
92
+ );
93
+ return {
94
+ slug: value.slug,
95
+ intention: value.intention,
96
+ ...(typeof value.turnstileToken === "string"
97
+ ? { turnstileToken: value.turnstileToken }
98
+ : {}),
99
+ ...(typeof value.participationPass === "string"
100
+ ? { participationPass: value.participationPass }
101
+ : {}),
102
+ };
103
+ }
104
+
105
+ export function requiredParticipationSigningSecret(secret?: string): string {
106
+ if (!secret || secret.length < 32)
107
+ throw new Error("Participation signing secret is missing or too short.");
108
+ return secret;
109
+ }
110
+
111
+ export function issueParticipationPass(secret: string, now: number): string {
112
+ const payload = Buffer.from(
113
+ JSON.stringify({ expiresAt: now + participationPassLifetime }),
114
+ ).toString("base64url");
115
+ return `${payload}.${sign(payload, secret)}`;
116
+ }
117
+
118
+ export function verifyParticipationPass(
119
+ pass: string,
120
+ secret: string,
121
+ now: number,
122
+ ): boolean {
123
+ const [payload, signature, extra] = pass.split(".");
124
+ if (!payload || !signature || extra) return false;
125
+ const expected = Buffer.from(sign(payload, secret));
126
+ const received = Buffer.from(signature);
127
+ if (
128
+ expected.length !== received.length ||
129
+ !timingSafeEqual(expected, received)
130
+ )
131
+ return false;
132
+ try {
133
+ const value: unknown = JSON.parse(
134
+ Buffer.from(payload, "base64url").toString("utf8"),
135
+ );
136
+ return Boolean(
137
+ value &&
138
+ typeof value === "object" &&
139
+ typeof Reflect.get(value, "expiresAt") === "number" &&
140
+ Reflect.get(value, "expiresAt") > now,
141
+ );
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ function sign(payload: string, secret: string): string {
148
+ return createHmac("sha256", secret).update(payload).digest("base64url");
149
+ }
@@ -0,0 +1,27 @@
1
+ import type { PostStatus, PostType } from "@feedbax/feedback";
2
+ import { Badge } from "@feedbax/ui/components/badge";
3
+
4
+ import {
5
+ postStatusPresentation,
6
+ postTypePresentation,
7
+ } from "./post-presentation";
8
+
9
+ export function PostTypeBadge({ type }: { type: PostType }) {
10
+ const { Icon } = postTypePresentation[type];
11
+ return (
12
+ <Badge variant="outline">
13
+ <Icon aria-hidden="true" />
14
+ {type}
15
+ </Badge>
16
+ );
17
+ }
18
+
19
+ export function PostStatusBadge({ status }: { status: PostStatus }) {
20
+ const { Icon } = postStatusPresentation[status];
21
+ return (
22
+ <Badge variant="outline" data-status={status}>
23
+ <Icon aria-hidden="true" />
24
+ {status}
25
+ </Badge>
26
+ );
27
+ }
@@ -0,0 +1,230 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import {
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ within,
11
+ } from "@testing-library/react";
12
+ import {
13
+ Outlet,
14
+ RouterProvider,
15
+ createMemoryHistory,
16
+ createRootRoute,
17
+ createRoute,
18
+ createRouter,
19
+ } from "@tanstack/react-router";
20
+ import { afterEach, describe, expect, it, vi } from "vitest";
21
+ import type { ComponentProps } from "react";
22
+
23
+ import { PostCreationFlow } from "./post-creation-flow";
24
+ import { authorizedDraftPostsQueryKey } from "./authorized-draft-query";
25
+ import { readCapabilities } from "./browser-post-state";
26
+ import { saveDeviceProfile } from "./browser-post-state";
27
+ import { DeviceProfileProvider } from "./components/device-profile-provider";
28
+
29
+ afterEach(() => {
30
+ cleanup();
31
+ window.localStorage.clear();
32
+ });
33
+
34
+ describe("routed Post creation", () => {
35
+ it("requests a complete Device Profile instead of exposing the Post form", async () => {
36
+ saveDeviceProfile(localStorage, { name: "Ama" });
37
+ const mutations = {
38
+ submitPost: vi.fn(),
39
+ editDraftPost: vi.fn(),
40
+ withdrawDraftPost: vi.fn(),
41
+ };
42
+ const history = createMemoryHistory({ initialEntries: ["/submit"] });
43
+ const router = createCreationRouter(history, mutations);
44
+
45
+ render(
46
+ <QueryClientProvider client={new QueryClient()}>
47
+ <RouterProvider router={router} />
48
+ </QueryClientProvider>,
49
+ );
50
+
51
+ expect(await screen.findByText("Complete your profile")).toBeTruthy();
52
+ expect(screen.queryByLabelText("Title")).toBeNull();
53
+ fireEvent.click(screen.getByRole("button", { name: "Complete profile" }));
54
+ fireEvent.change(await screen.findByLabelText("Email"), {
55
+ target: { value: "ama@example.com" },
56
+ });
57
+ fireEvent.click(screen.getByRole("button", { name: "Save profile" }));
58
+
59
+ expect(await screen.findByLabelText("Title")).toBeTruthy();
60
+ });
61
+
62
+ it("submits the Post Type selected from the three-card picker", async () => {
63
+ saveDeviceProfile(localStorage, {
64
+ name: "Ama",
65
+ email: "ama@example.com",
66
+ });
67
+ const created = {
68
+ id: "post-1",
69
+ slug: "keyboard-navigation",
70
+ title: "Keyboard navigation",
71
+ description: "Let Participants navigate without a pointer.",
72
+ type: "Bug Report" as const,
73
+ status: "New" as const,
74
+ published: false,
75
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
76
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
77
+ browserCapability: "browser-capability" as never,
78
+ };
79
+ const mutations = {
80
+ submitPost: vi.fn().mockResolvedValue(created),
81
+ editDraftPost: vi.fn(),
82
+ withdrawDraftPost: vi.fn(),
83
+ };
84
+ const history = createMemoryHistory({ initialEntries: ["/submit"] });
85
+ const router = createCreationRouter(history, mutations);
86
+
87
+ render(
88
+ <QueryClientProvider client={new QueryClient()}>
89
+ <RouterProvider router={router} />
90
+ </QueryClientProvider>,
91
+ );
92
+
93
+ const picker = await screen.findByRole("radiogroup", {
94
+ name: "Post Type",
95
+ });
96
+ expect(within(picker).getAllByRole("radio")).toHaveLength(3);
97
+ expect(
98
+ within(picker)
99
+ .getByRole("radio", { name: /Feature Request/ })
100
+ .getAttribute("aria-checked"),
101
+ ).toBe("true");
102
+
103
+ fireEvent.click(within(picker).getByRole("radio", { name: /Bug Report/ }));
104
+ fireEvent.change(screen.getByLabelText("Title"), {
105
+ target: { value: created.title },
106
+ });
107
+ fireEvent.change(screen.getByLabelText("Description"), {
108
+ target: { value: created.description },
109
+ });
110
+ fireEvent.submit(
111
+ screen.getByRole("button", { name: "Create Post" }).closest("form")!,
112
+ );
113
+
114
+ await waitFor(() =>
115
+ expect(mutations.submitPost).toHaveBeenCalledWith({
116
+ data: expect.objectContaining({ type: "Bug Report" }),
117
+ }),
118
+ );
119
+ });
120
+
121
+ it("retains authority, reconciles caches, and opens the authorized detail route", async () => {
122
+ saveDeviceProfile(localStorage, {
123
+ name: "Ama",
124
+ email: "ama@example.com",
125
+ });
126
+ const created = {
127
+ id: "post-1",
128
+ slug: "keyboard-navigation",
129
+ title: "Keyboard navigation",
130
+ description: "Let Participants navigate without a pointer.",
131
+ type: "Feature Request" as const,
132
+ status: "New" as const,
133
+ published: false,
134
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
135
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
136
+ browserCapability: "browser-capability" as never,
137
+ };
138
+ const mutations = {
139
+ submitPost: vi.fn().mockResolvedValue(created),
140
+ editDraftPost: vi.fn(),
141
+ withdrawDraftPost: vi.fn(),
142
+ };
143
+ const queryClient = new QueryClient();
144
+ queryClient.setQueryData(["public-posts", { sort: "trending" }], {
145
+ pages: [],
146
+ });
147
+ const history = createMemoryHistory({ initialEntries: ["/submit"] });
148
+ const router = createCreationRouter(history, mutations);
149
+
150
+ render(
151
+ <QueryClientProvider client={queryClient}>
152
+ <RouterProvider router={router} />
153
+ </QueryClientProvider>,
154
+ );
155
+ expect(await screen.findByText("0 / 5,000 characters")).toBeTruthy();
156
+ fireEvent.submit(
157
+ screen.getByRole("button", { name: "Create Post" }).closest("form")!,
158
+ );
159
+ expect(await screen.findByText("Enter a title.")).toBeTruthy();
160
+ expect(await screen.findByText("Enter a description.")).toBeTruthy();
161
+ expect(screen.getByLabelText("Title").getAttribute("aria-invalid")).toBe(
162
+ "true",
163
+ );
164
+ expect(
165
+ screen.getByLabelText("Description").getAttribute("aria-invalid"),
166
+ ).toBe("true");
167
+ fireEvent.change(await screen.findByLabelText("Title"), {
168
+ target: { value: created.title },
169
+ });
170
+ fireEvent.change(screen.getByLabelText("Description"), {
171
+ target: { value: created.description },
172
+ });
173
+ expect(screen.getByText("44 / 5,000 characters")).toBeTruthy();
174
+ expect(
175
+ screen.getByText(/Draft access is stored only in this browser/),
176
+ ).toBeTruthy();
177
+ fireEvent.submit(
178
+ screen.getByRole("button", { name: "Create Post" }).closest("form")!,
179
+ );
180
+
181
+ expect(
182
+ await screen.findByRole("heading", { name: "keyboard-navigation" }),
183
+ ).toBeTruthy();
184
+ expect(history.location.pathname).toBe("/p/keyboard-navigation");
185
+ expect(window.localStorage.getItem("feedbax:post-capabilities")).toContain(
186
+ "browser-capability",
187
+ );
188
+ expect(
189
+ queryClient.getQueryData(
190
+ authorizedDraftPostsQueryKey(readCapabilities(localStorage)),
191
+ ),
192
+ ).toEqual([
193
+ expect.objectContaining({ id: "post-1", slug: "keyboard-navigation" }),
194
+ ]);
195
+ expect(
196
+ queryClient.getQueryState(["public-posts", { sort: "trending" }])
197
+ ?.isInvalidated,
198
+ ).toBe(true);
199
+ });
200
+ });
201
+
202
+ function createCreationRouter(
203
+ history: ReturnType<typeof createMemoryHistory>,
204
+ mutations: ComponentProps<typeof PostCreationFlow>["mutations"],
205
+ ) {
206
+ const root = createRootRoute({
207
+ component: () => (
208
+ <DeviceProfileProvider>
209
+ <Outlet />
210
+ </DeviceProfileProvider>
211
+ ),
212
+ });
213
+ const submit = createRoute({
214
+ getParentRoute: () => root,
215
+ path: "/submit",
216
+ component: () => <PostCreationFlow mutations={mutations} />,
217
+ });
218
+ const detail = createRoute({
219
+ getParentRoute: () => root,
220
+ path: "/p/$slug",
221
+ component: function Detail() {
222
+ const { slug } = detail.useParams();
223
+ return <h1>{slug}</h1>;
224
+ },
225
+ });
226
+ return createRouter({
227
+ history,
228
+ routeTree: root.addChildren([submit, detail]),
229
+ });
230
+ }
@@ -0,0 +1,88 @@
1
+ import { useQueryClient } from "@tanstack/react-query";
2
+ import { useNavigate } from "@tanstack/react-router";
3
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
4
+
5
+ import {
6
+ PortalFeedbackForm,
7
+ type PortalFeedbackMutations,
8
+ } from "./portal-feedback-form";
9
+ import { authorizedDraftPostsQueryKey } from "./authorized-draft-query";
10
+ import { readCapabilities } from "./browser-post-state";
11
+ import {
12
+ DeviceProfilePrompt,
13
+ useDeviceProfile,
14
+ } from "./components/device-profile-provider";
15
+
16
+ export function PostCreationFlow({
17
+ display = "page",
18
+ onCancel,
19
+ mutations,
20
+ }: {
21
+ display?: "page" | "overlay";
22
+ onCancel?: () => void;
23
+ mutations: PortalFeedbackMutations;
24
+ }) {
25
+ const navigate = useNavigate();
26
+ const queryClient = useQueryClient();
27
+ const { completeProfile, ready } = useDeviceProfile();
28
+
29
+ if (!ready)
30
+ return (
31
+ <div className="profile-gate" aria-label="Loading profile" aria-busy />
32
+ );
33
+ if (!completeProfile) return <DeviceProfilePrompt purpose="post" />;
34
+
35
+ return (
36
+ <PortalFeedbackForm
37
+ display={display}
38
+ onCancel={onCancel}
39
+ mutations={mutations}
40
+ onCreated={async (post) => {
41
+ const draft = {
42
+ id: post.id,
43
+ slug: post.slug,
44
+ title: post.title,
45
+ description: post.description,
46
+ type: post.type,
47
+ status: post.status,
48
+ createdAt: post.createdAt,
49
+ updatedAt: post.updatedAt,
50
+ };
51
+ queryClient.setQueryData(
52
+ authorizedDraftPostsQueryKey(readCapabilities(localStorage)),
53
+ (current: (typeof draft)[] | undefined) => [
54
+ draft,
55
+ ...(current ?? []).filter((item) => item.id !== draft.id),
56
+ ],
57
+ );
58
+ await queryClient.invalidateQueries({ queryKey: ["public-posts"] });
59
+ await navigate({ to: "/p/$slug", params: { slug: post.slug } });
60
+ }}
61
+ />
62
+ );
63
+ }
64
+
65
+ export function PostCreationSkeleton() {
66
+ return (
67
+ <main
68
+ className="feedback-index submission-page"
69
+ aria-label="Loading Post creation"
70
+ aria-busy="true"
71
+ >
72
+ <section className="feedback-submit creation-skeleton" aria-hidden="true">
73
+ <div className="feedback-submit-copy">
74
+ <Skeleton className="creation-skeleton-eyebrow" />
75
+ <Skeleton className="creation-skeleton-heading" />
76
+ <Skeleton className="creation-skeleton-copy" />
77
+ </div>
78
+ <div className="feedback-submit-form">
79
+ <Skeleton className="creation-skeleton-field" />
80
+ <Skeleton className="creation-skeleton-textarea" />
81
+ <Skeleton className="creation-skeleton-field" />
82
+ <Skeleton className="creation-skeleton-action" />
83
+ </div>
84
+ </section>
85
+ <span className="sr-only">Loading Post creation…</span>
86
+ </main>
87
+ );
88
+ }