create-feedbax 0.2.0 → 0.2.2

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 (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -0,0 +1,233 @@
1
+ // @vitest-environment jsdom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { afterEach, expect, it, vi } from "vitest";
11
+ import { PostDetailContent } from "./post-detail-content";
12
+ import { saveDeviceProfile } from "./browser-post-state";
13
+
14
+ const post = {
15
+ slug: "comment-cache",
16
+ title: "Comment cache",
17
+ description: "Feedback",
18
+ type: "Feature Request" as const,
19
+ status: "Planned" as const,
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ };
23
+ const author = { kind: "participant" as const, displayName: "Ama" };
24
+ afterEach(() => {
25
+ cleanup();
26
+ localStorage.clear();
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ it.each(["edit", "delete"] as const)(
31
+ "preserves a confirmed Comment %s when another page arrives",
32
+ async (action) => {
33
+ saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
34
+ localStorage.setItem(
35
+ "feedbax:comment-capabilities",
36
+ JSON.stringify({
37
+ "comment-1": {
38
+ capability: "capability",
39
+ expiresAt: Date.now() + 60000,
40
+ },
41
+ }),
42
+ );
43
+ let state: "original" | "edit" | "delete" = "original";
44
+ const loadComments = vi.fn(async (_slug: string, cursor?: string) =>
45
+ cursor
46
+ ? {
47
+ items: [
48
+ {
49
+ id: "thread-2",
50
+ comments: [
51
+ {
52
+ id: "comment-2",
53
+ body: "Second page",
54
+ author,
55
+ createdAt: new Date(),
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ }
61
+ : {
62
+ items:
63
+ state === "delete"
64
+ ? []
65
+ : [
66
+ {
67
+ id: "thread-1",
68
+ comments: [
69
+ {
70
+ id: "comment-1",
71
+ body:
72
+ state === "edit"
73
+ ? "Corrected message"
74
+ : "Original message",
75
+ author,
76
+ createdAt: new Date(),
77
+ },
78
+ ],
79
+ },
80
+ ],
81
+ nextCursor: "next",
82
+ },
83
+ );
84
+ const mutateComment = vi.fn(async () => {
85
+ state = action;
86
+ });
87
+ vi.spyOn(window, "confirm").mockReturnValue(true);
88
+ const client = new QueryClient({
89
+ defaultOptions: { queries: { retry: false } },
90
+ });
91
+ render(
92
+ <QueryClientProvider client={client}>
93
+ <PostDetailContent
94
+ post={post}
95
+ loadComments={loadComments}
96
+ mutateComment={mutateComment}
97
+ />
98
+ </QueryClientProvider>,
99
+ );
100
+ expect(await screen.findByText("Original message")).toBeTruthy();
101
+ fireEvent.click(
102
+ await screen.findByRole("button", {
103
+ name: action === "edit" ? "Edit" : "Delete",
104
+ }),
105
+ );
106
+ if (action === "edit") {
107
+ fireEvent.change(screen.getByRole("textbox", { name: "Comment text" }), {
108
+ target: { value: "Corrected message" },
109
+ });
110
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
111
+ }
112
+ await waitFor(() => expect(mutateComment).toHaveBeenCalledOnce());
113
+ await waitFor(() =>
114
+ expect(screen.queryByText("Original message")).toBeNull(),
115
+ );
116
+ const more = await screen.findByRole("button", {
117
+ name: "Load more",
118
+ });
119
+ await waitFor(() => expect(more.hasAttribute("disabled")).toBe(false));
120
+ fireEvent.click(more);
121
+ expect(await screen.findByText("Second page")).toBeTruthy();
122
+ expect(screen.queryByText("Original message")).toBeNull();
123
+ expect(screen.queryAllByText("Corrected message")).toHaveLength(
124
+ action === "edit" ? 1 : 0,
125
+ );
126
+ },
127
+ );
128
+
129
+ it("removes a Comment resolved in Notion when the shared query refreshes", async () => {
130
+ let visible = true;
131
+ const loadComments = vi.fn(async () => ({
132
+ items: visible
133
+ ? [
134
+ {
135
+ id: "thread-1",
136
+ comments: [
137
+ {
138
+ id: "comment-1",
139
+ body: "Resolved in Notion",
140
+ author,
141
+ createdAt: new Date(),
142
+ },
143
+ ],
144
+ },
145
+ ]
146
+ : [],
147
+ }));
148
+ const client = new QueryClient({
149
+ defaultOptions: { queries: { retry: false } },
150
+ });
151
+ render(
152
+ <QueryClientProvider client={client}>
153
+ <PostDetailContent post={post} loadComments={loadComments} />
154
+ </QueryClientProvider>,
155
+ );
156
+ expect(await screen.findByText("Resolved in Notion")).toBeTruthy();
157
+ visible = false;
158
+ void client.invalidateQueries({
159
+ queryKey: ["public-post-comments", post.slug],
160
+ });
161
+ await waitFor(() =>
162
+ expect(screen.queryByText("Resolved in Notion")).toBeNull(),
163
+ );
164
+ expect(screen.getByText("No comments yet")).toBeTruthy();
165
+ });
166
+
167
+ it("keeps private edit capabilities out of the shared public Comment cache", async () => {
168
+ saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
169
+ const confirmed = {
170
+ id: "comment-1",
171
+ discussionId: "thread-1",
172
+ body: "Public message",
173
+ author,
174
+ createdAt: new Date(),
175
+ commentCapability: "private-edit-capability",
176
+ commentCapabilityExpiresAt: Date.now() + 60000,
177
+ };
178
+ let submitted = false;
179
+ const loadComments = vi.fn(async () => ({
180
+ items: submitted
181
+ ? [
182
+ {
183
+ id: "thread-1",
184
+ comments: [
185
+ {
186
+ id: confirmed.id,
187
+ body: confirmed.body,
188
+ author,
189
+ createdAt: confirmed.createdAt,
190
+ },
191
+ ],
192
+ },
193
+ ]
194
+ : [],
195
+ }));
196
+ const client = new QueryClient({
197
+ defaultOptions: { queries: { retry: false } },
198
+ });
199
+ const snapshots: string[] = [];
200
+ const unsubscribe = client.getQueryCache().subscribe((event) => {
201
+ snapshots.push(JSON.stringify(event.query.state.data));
202
+ });
203
+ render(
204
+ <QueryClientProvider client={client}>
205
+ <PostDetailContent
206
+ post={post}
207
+ loadComments={loadComments}
208
+ submitComment={async () => {
209
+ submitted = true;
210
+ return confirmed;
211
+ }}
212
+ />
213
+ </QueryClientProvider>,
214
+ );
215
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
216
+ target: { value: confirmed.body },
217
+ });
218
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
219
+ expect(await screen.findByText(confirmed.body)).toBeTruthy();
220
+ await waitFor(() =>
221
+ expect(localStorage.getItem("feedbax:comment-capabilities")).toContain(
222
+ confirmed.commentCapability,
223
+ ),
224
+ );
225
+ const cached = JSON.stringify(
226
+ client.getQueryData(["public-post-comments", post.slug]),
227
+ );
228
+ expect(cached).toContain(confirmed.body);
229
+ expect(cached).not.toContain("commentCapability");
230
+ expect(cached).not.toContain(confirmed.commentCapability);
231
+ expect(snapshots.join("\n")).not.toContain(confirmed.commentCapability);
232
+ unsubscribe();
233
+ });
@@ -0,0 +1,162 @@
1
+ import type { CommentThreadPage, CreatedComment } from "@feedbax/feedback";
2
+ import { type ComponentProps, useMemo } from "react";
3
+ import {
4
+ type InfiniteData,
5
+ useInfiniteQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
8
+ import { mergeCommentThreads } from "./comment-thread-page";
9
+ import { PublicPostDetail } from "./public-post-detail";
10
+ import {
11
+ publicPostCommentsQuery,
12
+ type FetchPostComments,
13
+ } from "./post-detail-query";
14
+ import feedbax from "./feedbax";
15
+
16
+ type Props = Omit<
17
+ ComponentProps<typeof PublicPostDetail>,
18
+ "comments" | "loadMore"
19
+ > & { loadComments?: FetchPostComments };
20
+ type Pages = InfiniteData<CommentThreadPage, string | undefined>;
21
+ const emptyComments: FetchPostComments = async () => ({ items: [] });
22
+
23
+ export function PostDetailContent(props: Props) {
24
+ return <PostDiscussion key={props.post.slug} {...props} />;
25
+ }
26
+
27
+ function PostDiscussion({ loadComments, ...props }: Props) {
28
+ const client = useQueryClient();
29
+ const options = publicPostCommentsQuery(
30
+ props.post.slug,
31
+ loadComments ?? emptyComments,
32
+ );
33
+ const query = useInfiniteQuery({
34
+ ...options,
35
+ enabled:
36
+ (props.features ?? feedbax.features).comments && Boolean(loadComments),
37
+ });
38
+ const comments = useMemo(
39
+ () => ({
40
+ items:
41
+ query.data?.pages.reduce(
42
+ (current, page) => mergeCommentThreads(current, page.items),
43
+ [] as CommentThreadPage["items"],
44
+ ) ?? [],
45
+ }),
46
+ [query.data],
47
+ );
48
+ function updatePages(update: (pages: Pages) => Pages) {
49
+ client.setQueryData<Pages>(options.queryKey, (current) =>
50
+ update(current ?? { pages: [{ items: [] }], pageParams: [undefined] }),
51
+ );
52
+ }
53
+ return (
54
+ <PublicPostDetail
55
+ {...props}
56
+ comments={comments}
57
+ commentsLoading={query.isFetching}
58
+ commentsLoadError={query.isError}
59
+ retryComments={() => {
60
+ if (query.isFetchNextPageError) void query.fetchNextPage();
61
+ else void query.refetch();
62
+ }}
63
+ loadMore={
64
+ query.hasNextPage && !query.isError
65
+ ? async () => {
66
+ await query.fetchNextPage();
67
+ }
68
+ : undefined
69
+ }
70
+ submitComment={
71
+ props.submitComment
72
+ ? async (input) => {
73
+ await client.cancelQueries({ queryKey: options.queryKey });
74
+ try {
75
+ const confirmed = await props.submitComment!(input);
76
+ updatePages((current) => upsertComment(current, confirmed));
77
+ return confirmed;
78
+ } finally {
79
+ void client.invalidateQueries({ queryKey: options.queryKey });
80
+ }
81
+ }
82
+ : undefined
83
+ }
84
+ mutateComment={
85
+ props.mutateComment
86
+ ? async (input) => {
87
+ await client.cancelQueries({ queryKey: options.queryKey });
88
+ try {
89
+ const result = await props.mutateComment!(input);
90
+ updatePages((current) => ({
91
+ ...current,
92
+ pages: current.pages.map((page) => ({
93
+ ...page,
94
+ items: page.items
95
+ .map((thread) => ({
96
+ ...thread,
97
+ comments: thread.comments.flatMap((comment) =>
98
+ comment.id !== input.commentId
99
+ ? [comment]
100
+ : input.action === "delete"
101
+ ? []
102
+ : [{ ...comment, body: input.body! }],
103
+ ),
104
+ }))
105
+ .filter((thread) => thread.comments.length > 0),
106
+ })),
107
+ }));
108
+ return result;
109
+ } finally {
110
+ void client.invalidateQueries({ queryKey: options.queryKey });
111
+ }
112
+ }
113
+ : undefined
114
+ }
115
+ />
116
+ );
117
+ }
118
+
119
+ function upsertComment(current: Pages, comment: CreatedComment): Pages {
120
+ // Cache only the public projection; browser edit capabilities stay outside it.
121
+ const publicComment = {
122
+ id: comment.id,
123
+ body: comment.body,
124
+ ...(comment.images?.length
125
+ ? {
126
+ images: comment.images.map((image) => ({
127
+ id: image.id,
128
+ src: image.src,
129
+ contentType: image.contentType,
130
+ bytes: image.bytes,
131
+ })),
132
+ }
133
+ : {}),
134
+ createdAt: comment.createdAt,
135
+ author:
136
+ comment.author.kind === "participant"
137
+ ? {
138
+ kind: "participant" as const,
139
+ displayName: comment.author.displayName,
140
+ ...(comment.author.verified ? { verified: true } : {}),
141
+ }
142
+ : {
143
+ kind: "product-team" as const,
144
+ displayName: comment.author.displayName,
145
+ },
146
+ };
147
+ const thread = { id: comment.discussionId, comments: [publicComment] };
148
+ const present = current.pages.some((page) =>
149
+ page.items.some((item) => item.id === thread.id),
150
+ );
151
+ return {
152
+ ...current,
153
+ pages: current.pages.map((page, index) => ({
154
+ ...page,
155
+ items:
156
+ (index === 0 && !present) ||
157
+ page.items.some((item) => item.id === thread.id)
158
+ ? mergeCommentThreads(page.items, [thread])
159
+ : page.items,
160
+ })),
161
+ };
162
+ }
@@ -0,0 +1,34 @@
1
+ import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
2
+ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
3
+
4
+ export const publicPostQueryKey = ["public-post"] as const;
5
+ export type FetchPostComments = (
6
+ slug: string,
7
+ cursor?: string,
8
+ ) => Promise<CommentThreadPage>;
9
+
10
+ export function publicPostQuery(
11
+ slug: string,
12
+ loadPost: (slug: string) => Promise<PublicPost | undefined>,
13
+ ) {
14
+ return queryOptions({
15
+ queryKey: [...publicPostQueryKey, slug],
16
+ queryFn: async () => (await loadPost(slug)) ?? null,
17
+ staleTime: 30_000,
18
+ retry: false,
19
+ });
20
+ }
21
+
22
+ export function publicPostCommentsQuery(
23
+ slug: string,
24
+ loadComments: FetchPostComments,
25
+ ) {
26
+ return infiniteQueryOptions({
27
+ queryKey: ["public-post-comments", slug],
28
+ initialPageParam: undefined as string | undefined,
29
+ queryFn: ({ pageParam }) => loadComments(slug, pageParam),
30
+ getNextPageParam: (page) => page.nextCursor,
31
+ staleTime: 0,
32
+ retry: false,
33
+ });
34
+ }
@@ -142,7 +142,7 @@ function PostResultRow({
142
142
  <span className="block text-base font-medium text-foreground sm:text-lg">
143
143
  {post.title}
144
144
  </span>
145
- <span className="mt-1 block text-sm leading-relaxed text-muted-foreground">
145
+ <span className="mt-1 line-clamp-2 break-words text-sm leading-relaxed text-muted-foreground">
146
146
  {post.description}
147
147
  </span>
148
148
  <span
@@ -75,7 +75,7 @@ describe("public portal cache policy", () => {
75
75
  );
76
76
 
77
77
  expect(first?.url).toBe(
78
- "https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=1",
78
+ "https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=2",
79
79
  );
80
80
  expect(second?.url).toBe(first?.url);
81
81
  });
@@ -94,12 +94,12 @@ describe("public portal cache policy", () => {
94
94
  new Request("https://feedback.example.com/roadmap"),
95
95
  )?.url,
96
96
  ).toBe(
97
- "https://feedback.example.com/roadmap?sort=updated-at-desc&schema=1",
97
+ "https://feedback.example.com/roadmap?sort=updated-at-desc&schema=2",
98
98
  );
99
99
  expect(
100
100
  canonicalPublicRequest(new Request("https://feedback.example.com/p/slug"))
101
101
  ?.url,
102
- ).toBe("https://feedback.example.com/p/slug?schema=1");
102
+ ).toBe("https://feedback.example.com/p/slug?schema=2");
103
103
  });
104
104
  });
105
105
 
@@ -108,7 +108,7 @@ describe("feed URL normalization", () => {
108
108
  expect(
109
109
  canonicalPublicRequest(
110
110
  new Request(
111
- "https://feedback.example.com/?pageSize=25&sort=new&schema=1",
111
+ "https://feedback.example.com/?pageSize=25&sort=new&schema=2",
112
112
  ),
113
113
  ),
114
114
  ).toBeUndefined();
@@ -139,3 +139,10 @@ describe("feed URL normalization", () => {
139
139
  expect(canonicalPublicRequest(first!)).toBeUndefined();
140
140
  });
141
141
  });
142
+
143
+ it("moves pre-0.2.1 cached projections to a new schema identity", () => {
144
+ const request = canonicalPublicRequest(
145
+ new Request("https://feedback.example.com/?pageSize=25&sort=top&schema=1"),
146
+ );
147
+ expect(new URL(request!.url).searchParams.get("schema")).toBe("2");
148
+ });
@@ -15,7 +15,7 @@ const BROWSER_CACHE_CONTROL = "public, max-age=30";
15
15
  const EDGE_CACHE_CONTROL =
16
16
  "public, max-age=120, stale-while-revalidate=600, stale-if-error=86400";
17
17
  const CHANGELOG_EDGE_CACHE_CONTROL = "public, max-age=30";
18
- const PUBLIC_PROJECTION_SCHEMA_VERSION = "1";
18
+ const PUBLIC_PROJECTION_SCHEMA_VERSION = "2";
19
19
  const PUBLIC_FEEDBACK_PAGE_SIZE = "25";
20
20
  const PUBLIC_ROADMAP_SORT = "updated-at-desc";
21
21
 
@@ -1,6 +1,6 @@
1
1
  import type { PublicPostQuery, PublicPostPage } from "@feedbax/feedback";
2
2
  import type { PortalFeatures } from "@feedbax/config";
3
- import { Skeleton } from "@feedbax/ui/components/skeleton";
3
+ import Loader from "./components/loader";
4
4
  import { useAuthorizedDraftPosts } from "./draft-post-list";
5
5
  import {
6
6
  BoardNavigation,
@@ -39,11 +39,11 @@ export function PublicPostIndex({
39
39
  <section aria-labelledby="feedback-heading">
40
40
  <h1
41
41
  id="feedback-heading"
42
- className="text-3xl font-semibold tracking-tight"
42
+ className="text-2xl font-semibold tracking-tight"
43
43
  >
44
44
  Feedback
45
45
  </h1>
46
- <p className="mt-2 text-base text-muted-foreground">
46
+ <p className="mt-2 text-sm text-muted-foreground">
47
47
  {features.voting
48
48
  ? "Share ideas and vote on what matters."
49
49
  : "Share ideas and follow public progress."}
@@ -58,10 +58,11 @@ export function PublicPostIndex({
58
58
  features={features}
59
59
  />
60
60
  </div>
61
- <section className="mt-8" aria-live="polite">
62
- <div className="mb-4">
63
- <h2 className="text-lg font-medium">Posts</h2>
64
- </div>
61
+ <section
62
+ className="mt-8"
63
+ aria-live="polite"
64
+ aria-label="Feedback posts"
65
+ >
65
66
  <PostResults
66
67
  drafts={drafts}
67
68
  initialItems={page.items}
@@ -89,36 +90,5 @@ export function PublicPostIndex({
89
90
  }
90
91
 
91
92
  export function PublicPostFeedSkeleton() {
92
- return (
93
- <main
94
- className="feedback-index feedback-feed-skeleton"
95
- aria-label="Loading Posts"
96
- aria-busy="true"
97
- >
98
- <section className="feedback-intro" aria-hidden="true">
99
- <Skeleton className="feedback-skeleton-heading" />
100
- <Skeleton className="feedback-skeleton-deck" />
101
- </section>
102
- <div className="post-controls" aria-hidden="true">
103
- <Skeleton className="feedback-skeleton-control" />
104
- <Skeleton className="feedback-skeleton-control" />
105
- <Skeleton className="feedback-skeleton-control" />
106
- </div>
107
- <section className="feedback-results" aria-hidden="true">
108
- <Skeleton className="feedback-skeleton-section-heading" />
109
- <ol className="feedback-list">
110
- {Array.from({ length: 3 }, (_, index) => (
111
- <li key={index} data-post-skeleton-row>
112
- <div className="feedback-skeleton-row">
113
- <Skeleton className="feedback-skeleton-meta" />
114
- <Skeleton className="feedback-skeleton-title" />
115
- <Skeleton className="feedback-skeleton-copy" />
116
- </div>
117
- </li>
118
- ))}
119
- </ol>
120
- </section>
121
- <span className="sr-only">Loading Posts…</span>
122
- </main>
123
- );
93
+ return <Loader label="Loading Posts" />;
124
94
  }
@@ -16,6 +16,71 @@ import { authorizedDraftPostsQueryKey } from "./authorized-draft-query";
16
16
  afterEach(cleanup);
17
17
 
18
18
  describe("public feedback home page", () => {
19
+ it("keeps Shipped Posts out of default Top before pagination while preserving explicit discovery", async () => {
20
+ const feedback = createFeedbackModule({
21
+ initialItems: [
22
+ ...Array.from({ length: 26 }, (_, index) => ({
23
+ id: `active-${index}`,
24
+ slug: `active-${index}`,
25
+ title: `Active ${index}`,
26
+ description: "Feedback",
27
+ type: "Feature Request" as const,
28
+ status: "Planned" as const,
29
+ published: true,
30
+ voteCount: index,
31
+ createdAt: new Date(index * 1000),
32
+ updatedAt: new Date(index * 1000),
33
+ })),
34
+ ...Array.from({ length: 27 }, (_, index) => ({
35
+ id: `shipped-${index}`,
36
+ slug: `shipped-${index}`,
37
+ title: `Shipped ${index}`,
38
+ description: "Feedback",
39
+ type: "Feature Request" as const,
40
+ status: "Shipped" as const,
41
+ published: true,
42
+ voteCount: 1000,
43
+ createdAt: new Date(100000 + index * 1000),
44
+ updatedAt: new Date(100000 + index * 1000),
45
+ })),
46
+ ],
47
+ });
48
+ const first = await loadPublicPostPage({
49
+ feedback,
50
+ search: { sort: "top" },
51
+ });
52
+ expect(first.items).toHaveLength(25);
53
+ expect(first.items.every((post) => post.status !== "Shipped")).toBe(true);
54
+ const second = await loadPublicPostPage({
55
+ feedback,
56
+ search: { sort: "top", cursor: first.nextCursor },
57
+ });
58
+ expect(second.items).toHaveLength(1);
59
+ const legacy = await loadPublicPostPage({
60
+ feedback,
61
+ search: { sort: "trending" },
62
+ });
63
+ expect(legacy.items.map((post) => post.slug)).toEqual(
64
+ first.items.map((post) => post.slug),
65
+ );
66
+ const shipped = await loadPublicPostPage({
67
+ feedback,
68
+ search: { sort: "top", statuses: ["Shipped"] },
69
+ });
70
+ expect(shipped.items).toHaveLength(25);
71
+ expect(shipped.items.every((post) => post.status === "Shipped")).toBe(true);
72
+ const search = await loadPublicPostPage({
73
+ feedback,
74
+ search: { sort: "top", search: "Shipped" },
75
+ });
76
+ expect(search.items[0]?.status).toBe("Shipped");
77
+ const newest = await loadPublicPostPage({
78
+ feedback,
79
+ search: { sort: "new" },
80
+ });
81
+ expect(newest.items[0]?.status).toBe("Shipped");
82
+ });
83
+
19
84
  it("loads 25 Published Posts and an opaque next-page cursor", async () => {
20
85
  const feedback = createFeedbackModule({
21
86
  initialItems: Array.from({ length: 26 }, (_, index) => ({
@@ -30,7 +30,18 @@ export async function loadPublicPostPage({
30
30
  feedback: Pick<FeedbackModule, "listPublicPosts">;
31
31
  search: Record<string, unknown>;
32
32
  }): Promise<PublicPostPage> {
33
- return feedback.listPublicPosts(publicPostSearch(search));
33
+ const query = publicPostSearch(search);
34
+ const defaultTop =
35
+ query.sort !== "new" &&
36
+ !query.search &&
37
+ !query.status &&
38
+ !query.statuses?.length;
39
+ return feedback.listPublicPosts({
40
+ ...query,
41
+ ...(defaultTop
42
+ ? { statuses: postStatuses.filter((status) => status !== "Shipped") }
43
+ : {}),
44
+ });
34
45
  }
35
46
 
36
47
  export function publicPostSearch(
@@ -3,7 +3,7 @@ import { Button } from "@feedbax/ui/components/button";
3
3
  export function PublicPortalError({
4
4
  reset,
5
5
  }: {
6
- error: Error;
6
+ error: unknown;
7
7
  reset: () => void;
8
8
  }) {
9
9
  return (