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,139 @@
1
+ import { createFeedbackModule } from "@feedbax/feedback";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { loadPublicRoadmapPage } from "./public-roadmap-page";
6
+ import {
7
+ PublicRoadmapSkeleton,
8
+ PublicRoadmapView,
9
+ } from "./public-roadmap-view";
10
+
11
+ describe("public roadmap page", () => {
12
+ it("loads Published Posts in the agreed roadmap groups and order", async () => {
13
+ const createdAt = new Date("2026-08-01T09:00:00.000Z");
14
+ const feedback = createFeedbackModule({
15
+ initialItems: [
16
+ roadmapItem("planned-older", "Planned earlier", "Planned", createdAt),
17
+ roadmapItem(
18
+ "planned-newer",
19
+ "Planned later",
20
+ "Planned",
21
+ new Date("2026-08-20T09:00:00.000Z"),
22
+ ),
23
+ roadmapItem(
24
+ "in-progress",
25
+ "Building now",
26
+ "In Progress",
27
+ new Date("2026-08-19T09:00:00.000Z"),
28
+ ),
29
+ roadmapItem(
30
+ "shipped",
31
+ "Already shipped",
32
+ "Shipped",
33
+ new Date("2026-08-18T09:00:00.000Z"),
34
+ ),
35
+ {
36
+ ...roadmapItem(
37
+ "private",
38
+ "Private plan",
39
+ "Planned",
40
+ new Date("2026-08-21T09:00:00.000Z"),
41
+ ),
42
+ published: false,
43
+ },
44
+ ],
45
+ });
46
+
47
+ const roadmap = await loadPublicRoadmapPage({ feedback });
48
+
49
+ expect(roadmap.Planned.items.map((item) => item.title)).toEqual([
50
+ "Planned later",
51
+ "Planned earlier",
52
+ ]);
53
+ expect(roadmap["In Progress"].items.map((item) => item.title)).toEqual([
54
+ "Building now",
55
+ ]);
56
+ expect(roadmap.Shipped.items.map((item) => item.title)).toEqual([
57
+ "Already shipped",
58
+ ]);
59
+ });
60
+
61
+ it("renders explicit empty groups", () => {
62
+ const html = renderToStaticMarkup(
63
+ <PublicRoadmapView roadmap={emptyRoadmap()} />,
64
+ );
65
+
66
+ expect(html).toContain("Planned");
67
+ expect(html).toContain("In progress");
68
+ expect(html).toContain("Shipped");
69
+ expect(html.match(/No Posts here yet\./g)).toHaveLength(3);
70
+ });
71
+
72
+ it("renders the roadmap workspace described by the visual brief", () => {
73
+ const html = renderToStaticMarkup(
74
+ <PublicRoadmapView roadmap={emptyRoadmap()} />,
75
+ );
76
+
77
+ expect(html).toContain("What we’re planning, building, and shipping.");
78
+ expect(html).toContain("Search");
79
+ expect(html).not.toContain("Filters");
80
+ expect(html).toContain("New post");
81
+ });
82
+
83
+ it("links roadmap Posts by immutable slug", () => {
84
+ const item = roadmapItem(
85
+ "stable-notion-page-id",
86
+ "Keyboard-first search!",
87
+ "Planned",
88
+ new Date("2026-08-20T09:00:00.000Z"),
89
+ );
90
+ const html = renderToStaticMarkup(
91
+ <PublicRoadmapView
92
+ roadmap={{
93
+ ...emptyRoadmap(),
94
+ Planned: { items: [item], totalCount: 1 },
95
+ }}
96
+ />,
97
+ );
98
+
99
+ expect(html).toContain('href="/p/keyboard-first-search"');
100
+ });
101
+
102
+ it("renders three roadmap skeleton columns while the route is loading", () => {
103
+ const html = renderToStaticMarkup(<PublicRoadmapSkeleton />);
104
+
105
+ expect(html).toContain('aria-label="Loading roadmap"');
106
+ expect(html.match(/data-roadmap-skeleton-column/g)).toHaveLength(3);
107
+ expect(html).toContain("What we’re planning, building, and shipping.");
108
+ });
109
+ });
110
+
111
+ function roadmapItem(
112
+ id: string,
113
+ title: string,
114
+ status: "Planned" | "In Progress" | "Shipped",
115
+ updatedAt: Date,
116
+ ) {
117
+ return {
118
+ id,
119
+ slug: title
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9]+/g, "-")
122
+ .replace(/-$/, ""),
123
+ title,
124
+ description: `${title} description`,
125
+ type: "Feature Request" as const,
126
+ status,
127
+ published: true,
128
+ createdAt: new Date("2026-08-01T09:00:00.000Z"),
129
+ updatedAt,
130
+ };
131
+ }
132
+
133
+ function emptyRoadmap() {
134
+ return {
135
+ Planned: { items: [], totalCount: 0 },
136
+ "In Progress": { items: [], totalCount: 0 },
137
+ Shipped: { items: [], totalCount: 0 },
138
+ };
139
+ }
@@ -0,0 +1,24 @@
1
+ import type {
2
+ FeedbackModule,
3
+ PublicPostRoadmap,
4
+ PublicRoadmapPage,
5
+ PublicRoadmapQuery,
6
+ } from "@feedbax/feedback";
7
+
8
+ export function loadPublicRoadmapPage({
9
+ feedback,
10
+ }: {
11
+ feedback: Pick<FeedbackModule, "getPublicPostRoadmap">;
12
+ }): Promise<PublicPostRoadmap> {
13
+ return feedback.getPublicPostRoadmap();
14
+ }
15
+
16
+ export function loadPublicRoadmapStatusPage({
17
+ feedback,
18
+ query,
19
+ }: {
20
+ feedback: Pick<FeedbackModule, "listPublicRoadmapPosts">;
21
+ query: PublicRoadmapQuery;
22
+ }): Promise<PublicRoadmapPage> {
23
+ return feedback.listPublicRoadmapPosts(query);
24
+ }
@@ -0,0 +1,218 @@
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
+ } from "@testing-library/react";
11
+ import {
12
+ Outlet,
13
+ RouterProvider,
14
+ createMemoryHistory,
15
+ createRootRouteWithContext,
16
+ createRoute,
17
+ createRouter,
18
+ } from "@tanstack/react-router";
19
+ import { afterEach, describe, expect, it, vi } from "vitest";
20
+
21
+ import type { RouterAppContext } from "./routes/__root";
22
+ import { createPublicRoadmapRoute } from "./public-roadmap-route";
23
+ import { PublicRoadmapView } from "./public-roadmap-view";
24
+
25
+ afterEach(cleanup);
26
+
27
+ describe("/roadmap route", () => {
28
+ it("hydrates once, renders ordered groups, and navigates cards canonically", async () => {
29
+ const queryClient = new QueryClient();
30
+ const fetchRoadmap = vi.fn(async () => ({
31
+ Planned: {
32
+ items: [post("planned-post", "Planned")],
33
+ totalCount: 1,
34
+ },
35
+ "In Progress": {
36
+ items: [post("building-post", "In Progress")],
37
+ totalCount: 1,
38
+ },
39
+ Shipped: { items: [], totalCount: 0 },
40
+ }));
41
+ const history = createMemoryHistory({ initialEntries: ["/roadmap"] });
42
+ const root = createRootRouteWithContext<RouterAppContext>()({
43
+ component: () => (
44
+ <QueryClientProvider client={queryClient}>
45
+ <Outlet />
46
+ </QueryClientProvider>
47
+ ),
48
+ });
49
+ const roadmap = createPublicRoadmapRoute({ fetchRoadmap }).update({
50
+ id: "/roadmap",
51
+ path: "/roadmap",
52
+ getParentRoute: () => root,
53
+ } as never);
54
+ const postDetail = createRoute({
55
+ getParentRoute: () => root,
56
+ path: "/p/$slug",
57
+ component: () => <div>Post detail</div>,
58
+ });
59
+ const router = createRouter({
60
+ context: { queryClient },
61
+ history,
62
+ routeTree: root.addChildren([roadmap, postDetail]),
63
+ });
64
+
65
+ render(<RouterProvider router={router} />);
66
+
67
+ const headings = await screen.findAllByRole("heading", { level: 2 });
68
+ expect(headings.map((heading) => heading.textContent)).toEqual([
69
+ "Planned",
70
+ "In progress",
71
+ "Shipped",
72
+ ]);
73
+ expect(fetchRoadmap).toHaveBeenCalledTimes(1);
74
+
75
+ fireEvent.click(screen.getByRole("link", { name: /planned-post/i }));
76
+
77
+ await waitFor(() =>
78
+ expect(history.location.pathname).toBe("/p/planned-post"),
79
+ );
80
+ });
81
+
82
+ it("appends the next page in one column and removes its button on exhaustion", async () => {
83
+ const queryClient = new QueryClient();
84
+ const fetchRoadmap = vi.fn(async () => ({
85
+ Planned: {
86
+ items: [post("planned-page-1", "Planned")],
87
+ nextCursor: "planned-cursor",
88
+ totalCount: 2,
89
+ },
90
+ "In Progress": { items: [], totalCount: 0 },
91
+ Shipped: { items: [], totalCount: 0 },
92
+ }));
93
+ let resolveNextPage!: (page: {
94
+ items: ReturnType<typeof post>[];
95
+ totalCount: number;
96
+ }) => void;
97
+ const fetchRoadmapStatusPage = vi.fn(
98
+ () =>
99
+ new Promise<{
100
+ items: ReturnType<typeof post>[];
101
+ totalCount: number;
102
+ }>((resolve) => {
103
+ resolveNextPage = resolve;
104
+ }),
105
+ );
106
+ const history = createMemoryHistory({ initialEntries: ["/roadmap"] });
107
+ const root = createRootRouteWithContext<RouterAppContext>()({
108
+ component: () => (
109
+ <QueryClientProvider client={queryClient}>
110
+ <Outlet />
111
+ </QueryClientProvider>
112
+ ),
113
+ });
114
+ const roadmap = createPublicRoadmapRoute({
115
+ fetchRoadmap,
116
+ fetchRoadmapStatusPage,
117
+ }).update({
118
+ id: "/roadmap",
119
+ path: "/roadmap",
120
+ getParentRoute: () => root,
121
+ } as never);
122
+ const router = createRouter({
123
+ context: { queryClient },
124
+ history,
125
+ routeTree: root.addChildren([roadmap]),
126
+ });
127
+
128
+ render(<RouterProvider router={router} />);
129
+
130
+ const loadMore = await screen.findByRole("button", {
131
+ name: "Load more Planned Posts",
132
+ });
133
+ fireEvent.click(loadMore);
134
+
135
+ expect(fetchRoadmapStatusPage).toHaveBeenCalledWith({
136
+ status: "Planned",
137
+ cursor: "planned-cursor",
138
+ });
139
+ expect(loadMore.textContent).toContain("Loading");
140
+ expect((loadMore as HTMLButtonElement).disabled).toBe(true);
141
+ expect(
142
+ screen.getByRole("status", { name: "Roadmap status" }).textContent,
143
+ ).toBe("Loading more Planned Posts.");
144
+
145
+ resolveNextPage({
146
+ items: [post("planned-page-2", "Planned")],
147
+ totalCount: 2,
148
+ });
149
+
150
+ expect(
151
+ await screen.findByRole("link", { name: /planned-page-2/i }),
152
+ ).toBeTruthy();
153
+ await waitFor(() =>
154
+ expect(
155
+ screen.queryByRole("button", { name: "Load more Planned Posts" }),
156
+ ).toBeNull(),
157
+ );
158
+ expect(
159
+ screen.getByText("2", { selector: "[data-roadmap-count]" }),
160
+ ).toBeTruthy();
161
+ expect(
162
+ screen.getByRole("status", { name: "Roadmap status" }).textContent,
163
+ ).toBe("1 more Planned Post loaded.");
164
+ });
165
+
166
+ it("shows one status column at a time behind mobile status tabs", async () => {
167
+ Object.defineProperty(window, "matchMedia", {
168
+ configurable: true,
169
+ value: vi.fn().mockReturnValue({
170
+ matches: true,
171
+ addEventListener: vi.fn(),
172
+ removeEventListener: vi.fn(),
173
+ }),
174
+ });
175
+ render(
176
+ <PublicRoadmapView
177
+ roadmap={{
178
+ Planned: {
179
+ items: [post("planned-mobile", "Planned")],
180
+ totalCount: 1,
181
+ },
182
+ "In Progress": { items: [], totalCount: 0 },
183
+ Shipped: {
184
+ items: [
185
+ {
186
+ ...post("shipped-mobile", "Planned"),
187
+ status: "Shipped",
188
+ },
189
+ ],
190
+ totalCount: 1,
191
+ },
192
+ }}
193
+ />,
194
+ );
195
+
196
+ expect(screen.getByRole("link", { name: /planned-mobile/i })).toBeTruthy();
197
+ expect(screen.queryByRole("link", { name: /shipped-mobile/i })).toBeNull();
198
+
199
+ fireEvent.click(screen.getByRole("tab", { name: /Shipped 1/i }));
200
+
201
+ expect(
202
+ await screen.findByRole("link", { name: /shipped-mobile/i }),
203
+ ).toBeTruthy();
204
+ expect(screen.queryByRole("link", { name: /planned-mobile/i })).toBeNull();
205
+ });
206
+ });
207
+
208
+ function post(slug: string, status: "Planned" | "In Progress") {
209
+ return {
210
+ slug,
211
+ title: slug,
212
+ description: `${slug} description`,
213
+ type: "Feature Request" as const,
214
+ status,
215
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
216
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
217
+ };
218
+ }
@@ -0,0 +1,50 @@
1
+ import { useSuspenseQuery } from "@tanstack/react-query";
2
+ import { createFileRoute } from "@tanstack/react-router";
3
+
4
+ import { PublicPortalError } from "./public-portal-error";
5
+ import {
6
+ PublicRoadmapSkeleton,
7
+ PublicRoadmapView,
8
+ } from "./public-roadmap-view";
9
+ import {
10
+ publicRoadmapQuery,
11
+ type FetchPublicRoadmap,
12
+ type FetchPublicRoadmapStatusPage,
13
+ } from "./roadmap-query";
14
+ import feedbax from "./feedbax";
15
+ import { createRouteHead } from "./portal-head";
16
+
17
+ export function createPublicRoadmapRoute({
18
+ fetchRoadmap,
19
+ fetchRoadmapStatusPage,
20
+ }: {
21
+ fetchRoadmap: FetchPublicRoadmap;
22
+ fetchRoadmapStatusPage?: FetchPublicRoadmapStatusPage;
23
+ }) {
24
+ const query = publicRoadmapQuery(fetchRoadmap);
25
+ const route = createFileRoute("/roadmap")({
26
+ head: () =>
27
+ createRouteHead(feedbax.product, {
28
+ title: "Roadmap",
29
+ description: `See what ${feedbax.product.name} is considering, building, and shipping.`,
30
+ path: "/roadmap",
31
+ }),
32
+ loader: ({ context }) => context.queryClient.ensureQueryData(query),
33
+ component: RoadmapComponent,
34
+ pendingComponent: PublicRoadmapSkeleton,
35
+ errorComponent: PublicPortalError,
36
+ });
37
+
38
+ function RoadmapComponent() {
39
+ const roadmap = useSuspenseQuery(query).data;
40
+ return (
41
+ <PublicRoadmapView
42
+ roadmap={roadmap}
43
+ loadMore={fetchRoadmapStatusPage}
44
+ maskPostLinks
45
+ />
46
+ );
47
+ }
48
+
49
+ return route;
50
+ }
@@ -0,0 +1,44 @@
1
+ import { roadmapStatuses, type RoadmapStatus } from "@feedbax/feedback";
2
+ import { createServerFn } from "@tanstack/react-start";
3
+
4
+ import { createConfiguredFeedbackModule } from "./feedback-runtime";
5
+ import {
6
+ loadPublicRoadmapPage,
7
+ loadPublicRoadmapStatusPage,
8
+ } from "./public-roadmap-page";
9
+ import { runSafePublicRead } from "./safe-public-failure";
10
+
11
+ const roadmapStatusSet = new Set<string>(roadmapStatuses);
12
+
13
+ export const getPublicRoadmapPage = createServerFn({ method: "GET" }).handler(
14
+ () =>
15
+ runSafePublicRead(() =>
16
+ loadPublicRoadmapPage({
17
+ feedback: createConfiguredFeedbackModule(),
18
+ }),
19
+ ),
20
+ );
21
+
22
+ export const getPublicRoadmapStatusPage = createServerFn({ method: "GET" })
23
+ .validator((input: unknown) => {
24
+ if (!input || typeof input !== "object")
25
+ throw new Error("Roadmap request is invalid.");
26
+ const status = Reflect.get(input, "status");
27
+ const cursor = Reflect.get(input, "cursor");
28
+ if (typeof status !== "string" || !roadmapStatusSet.has(status))
29
+ throw new Error("Roadmap request is invalid.");
30
+ if (cursor !== undefined && typeof cursor !== "string")
31
+ throw new Error("Roadmap request is invalid.");
32
+ return {
33
+ status: status as RoadmapStatus,
34
+ ...(cursor ? { cursor } : {}),
35
+ };
36
+ })
37
+ .handler(({ data }) =>
38
+ runSafePublicRead(() =>
39
+ loadPublicRoadmapStatusPage({
40
+ feedback: createConfiguredFeedbackModule(),
41
+ query: data,
42
+ }),
43
+ ),
44
+ );