create-feedbax 0.2.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 (99) hide show
  1. package/dist/cli.js +2042 -16
  2. package/dist/index.js +1997 -5
  3. package/package.json +1 -1
  4. package/template/.feedbax-template.sha256 +1 -1
  5. package/template/AGENTS.md +13 -0
  6. package/template/CONNECTED.md +404 -0
  7. package/template/README.md +58 -0
  8. package/template/components.json +1 -1
  9. package/template/eslint.config.mjs +23 -0
  10. package/template/migrations/0001_connected.sql +6 -0
  11. package/template/migrations/0002_participation.sql +8 -0
  12. package/template/migrations/0003_images.sql +3 -0
  13. package/template/migrations/0004_followed_status.sql +9 -0
  14. package/template/package.json +9 -5
  15. package/template/src/authorized-draft-post.tsx +1 -0
  16. package/template/src/changelog-page.tsx +63 -54
  17. package/template/src/cloudflare-workers.d.ts +6 -0
  18. package/template/src/comment-creation.test.tsx +42 -1
  19. package/template/src/comment-thread-page.ts +11 -1
  20. package/template/src/components/command-palette.tsx +59 -30
  21. package/template/src/components/connected-identity-provider.tsx +108 -0
  22. package/template/src/components/device-profile-provider.tsx +41 -26
  23. package/template/src/components/follow-post-button.tsx +89 -0
  24. package/template/src/components/header.tsx +2 -0
  25. package/template/src/components/image-attachments.tsx +187 -0
  26. package/template/src/components/loader.tsx +48 -5
  27. package/template/src/components/notification-inbox.tsx +232 -0
  28. package/template/src/components/portal-footer.tsx +14 -0
  29. package/template/src/components/profile-menu.tsx +30 -0
  30. package/template/src/components/ui/components/command.tsx +30 -29
  31. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  32. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  33. package/template/src/components/ui/components/switch.tsx +29 -0
  34. package/template/src/components/ui/styles/globals.css +1 -1
  35. package/template/src/connected-identity.test.ts +144 -0
  36. package/template/src/connected-identity.ts +122 -0
  37. package/template/src/connected-participation.test.tsx +67 -0
  38. package/template/src/connected-runtime.test.ts +241 -0
  39. package/template/src/connected-runtime.ts +176 -0
  40. package/template/src/connected-social.test.tsx +158 -0
  41. package/template/src/feedback-cache-invalidation.ts +1 -0
  42. package/template/src/feedback-runtime.ts +79 -12
  43. package/template/src/feedbax/config/index.test.ts +28 -0
  44. package/template/src/feedbax/config/index.ts +93 -0
  45. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  46. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  47. package/template/src/feedbax/feedback/connected.ts +215 -0
  48. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  49. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  50. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  51. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  52. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  53. package/template/src/feedbax/feedback/images.test.ts +223 -0
  54. package/template/src/feedbax/feedback/images.ts +324 -0
  55. package/template/src/feedbax/feedback/index.ts +55 -1
  56. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  57. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  58. package/template/src/feedbax/feedback/participation.ts +502 -0
  59. package/template/src/images-runtime.test.ts +133 -0
  60. package/template/src/images-runtime.ts +125 -0
  61. package/template/src/index.css +97 -440
  62. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  63. package/template/src/overlay-post-detail.test.tsx +59 -8
  64. package/template/src/overlay-post-detail.tsx +20 -69
  65. package/template/src/participation-client.ts +63 -0
  66. package/template/src/participation-runtime.test.ts +35 -0
  67. package/template/src/participation-runtime.ts +255 -0
  68. package/template/src/portal-comments.ts +24 -5
  69. package/template/src/portal-feedback-form.tsx +18 -3
  70. package/template/src/portal-feedback-runtime.ts +3 -0
  71. package/template/src/portal-feedback-server-function.ts +6 -3
  72. package/template/src/portal-feedback-submission.ts +10 -0
  73. package/template/src/portal-server-entry.ts +4 -0
  74. package/template/src/post-creation-flow.tsx +2 -2
  75. package/template/src/post-detail-content.test.tsx +233 -0
  76. package/template/src/post-detail-content.tsx +162 -0
  77. package/template/src/post-detail-query.ts +34 -0
  78. package/template/src/post-results.tsx +1 -1
  79. package/template/src/public-cache-policy.test.ts +11 -4
  80. package/template/src/public-cache-policy.ts +1 -1
  81. package/template/src/public-feedback-index.tsx +9 -39
  82. package/template/src/public-feedback-page.test.tsx +65 -0
  83. package/template/src/public-feedback-page.ts +12 -1
  84. package/template/src/public-post-detail-skeleton.tsx +54 -0
  85. package/template/src/public-post-detail.tsx +298 -208
  86. package/template/src/public-post-route.test.tsx +39 -3
  87. package/template/src/public-post-route.tsx +15 -28
  88. package/template/src/public-roadmap-route.test.tsx +38 -2
  89. package/template/src/public-roadmap-view.tsx +56 -23
  90. package/template/src/public-shell.test.tsx +26 -0
  91. package/template/src/routeTree.gen.ts +49 -3
  92. package/template/src/router.tsx +2 -0
  93. package/template/src/routes/__root.tsx +3 -5
  94. package/template/src/routes/email-preferences.tsx +77 -0
  95. package/template/src/routes/submit.tsx +1 -1
  96. package/template/src/safe-public-failure.ts +3 -1
  97. package/template/src/server.ts +41 -1
  98. package/template/src/trusted-feedback-handler.test.ts +1 -1
  99. package/template/src/vote-toggle.tsx +42 -9
@@ -13,7 +13,7 @@ import {
13
13
  } from "@testing-library/react";
14
14
  import {
15
15
  createMemoryHistory,
16
- createRootRoute,
16
+ createRootRouteWithContext,
17
17
  createRouter,
18
18
  Outlet,
19
19
  RouterProvider,
@@ -28,6 +28,8 @@ import {
28
28
  vi,
29
29
  } from "vitest";
30
30
 
31
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
32
+
31
33
  import { loadPublicPost } from "./public-post-page";
32
34
  import { createPublicPostRoute } from "./public-post-route";
33
35
  import { PublicPostUnavailable } from "./public-post-unavailable";
@@ -76,7 +78,7 @@ describe("/p/$slug", () => {
76
78
  expect(details.getByText("Submitted")).toBeTruthy();
77
79
  expect(details.getByText("Updated")).toBeTruthy();
78
80
  expect(history.location.pathname).toBe("/p/keyboard-first-search");
79
- expect(screen.getByText("Please add Vim bindings.")).toBeTruthy();
81
+ expect(await screen.findByText("Please add Vim bindings.")).toBeTruthy();
80
82
  expect(screen.getByText("Ari")).toBeTruthy();
81
83
  expect(document.body.textContent).not.toContain("secret-id");
82
84
  });
@@ -161,6 +163,30 @@ describe("/p/$slug", () => {
161
163
  "native-cursor",
162
164
  );
163
165
  });
166
+
167
+ it("renders the full-page Post before a slow Comment request completes", async () => {
168
+ const feedback = createFeedbackModule({ initialItems: [publishedPost] });
169
+ let finish!: (page: CommentThreadPage) => void;
170
+ const loadComments = vi.fn(
171
+ () =>
172
+ new Promise<CommentThreadPage>((resolve) => {
173
+ finish = resolve;
174
+ }),
175
+ );
176
+ const history = createMemoryHistory({
177
+ initialEntries: ["/p/keyboard-first-search"],
178
+ });
179
+ const router = createPostRouter({ feedback, history, loadComments });
180
+ render(<RouterProvider router={router} />);
181
+ expect(
182
+ await screen.findByRole("heading", { name: "Keyboard-first search" }),
183
+ ).toBeTruthy();
184
+ expect(
185
+ screen.getByRole("status", { name: "Loading Comments" }),
186
+ ).toBeTruthy();
187
+ finish({ items: [] });
188
+ expect(await screen.findByText("No comments yet")).toBeTruthy();
189
+ });
164
190
  });
165
191
 
166
192
  function createPostRouter({
@@ -172,7 +198,16 @@ function createPostRouter({
172
198
  history: ReturnType<typeof createMemoryHistory>;
173
199
  loadComments?: (slug: string, cursor?: string) => Promise<CommentThreadPage>;
174
200
  }) {
175
- const rootRoute = createRootRoute({ component: () => <Outlet /> });
201
+ const queryClient = new QueryClient({
202
+ defaultOptions: { queries: { retry: false } },
203
+ });
204
+ const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>()({
205
+ component: () => (
206
+ <QueryClientProvider client={queryClient}>
207
+ <Outlet />
208
+ </QueryClientProvider>
209
+ ),
210
+ });
176
211
  const postRoute = createPublicPostRoute({
177
212
  loadPost: (slug) => loadPublicPost({ feedback, slug }),
178
213
  loadComments:
@@ -185,6 +220,7 @@ function createPostRouter({
185
220
  getParentRoute: () => rootRoute,
186
221
  } as never);
187
222
  return createRouter({
223
+ context: { queryClient },
188
224
  history,
189
225
  routeTree: rootRoute.addChildren([postRoute]),
190
226
  });
@@ -1,15 +1,14 @@
1
1
  import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
2
2
  import { createFileRoute } from "@tanstack/react-router";
3
- import { type ReactNode, useState } from "react";
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { type ReactNode } from "react";
4
5
  import type { PortalCommentRequest } from "./portal-comments";
5
- import { mergeCommentThreads } from "./comment-thread-page";
6
+ import { PostDetailContent } from "./post-detail-content";
6
7
  import feedbax from "./feedbax";
7
8
  import { createRouteHead } from "./portal-head";
8
9
 
9
- import {
10
- PublicPostDetail,
11
- PublicPostDetailSkeleton,
12
- } from "./public-post-detail";
10
+ import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
11
+ import { publicPostQuery } from "./post-detail-query";
13
12
 
14
13
  export function createPublicPostRoute({
15
14
  loadPost,
@@ -21,11 +20,11 @@ export function createPublicPostRoute({
21
20
  renderUnavailable: (slug: string) => ReactNode;
22
21
  }) {
23
22
  const route = createFileRoute("/p/$slug")({
24
- loader: async ({ params }) => {
25
- const post = await loadPost(params.slug);
26
- const comments =
27
- post && loadComments ? await loadComments(params.slug) : { items: [] };
28
- return { post, comments };
23
+ loader: async ({ params, context }) => {
24
+ const post = await context.queryClient.ensureQueryData(
25
+ publicPostQuery(params.slug, loadPost),
26
+ );
27
+ return { post };
29
28
  },
30
29
  head: ({ loaderData, params }) =>
31
30
  loaderData?.post
@@ -45,26 +44,14 @@ export function createPublicPostRoute({
45
44
  });
46
45
 
47
46
  function PostRoute() {
48
- const data = route.useLoaderData();
49
47
  const { slug } = route.useParams();
50
- const [comments, setComments] = useState(data.comments);
51
- return data.post ? (
52
- <PublicPostDetail
53
- post={data.post}
54
- comments={comments}
48
+ const { data: post } = useSuspenseQuery(publicPostQuery(slug, loadPost));
49
+ return post ? (
50
+ <PostDetailContent
51
+ post={post}
52
+ loadComments={loadComments}
55
53
  submitComment={submitPublicComment}
56
54
  mutateComment={mutatePublicComment}
57
- loadMore={
58
- loadComments && comments.nextCursor
59
- ? async () => {
60
- const next = await loadComments(slug, comments.nextCursor);
61
- setComments({
62
- items: mergeCommentThreads(comments.items, next.items),
63
- ...(next.nextCursor ? { nextCursor: next.nextCursor } : {}),
64
- });
65
- }
66
- : undefined
67
- }
68
55
  />
69
56
  ) : (
70
57
  renderUnavailable(slug)
@@ -128,7 +128,7 @@ describe("/roadmap route", () => {
128
128
  render(<RouterProvider router={router} />);
129
129
 
130
130
  const loadMore = await screen.findByRole("button", {
131
- name: "Load more Planned Posts",
131
+ name: "View more Planned Posts",
132
132
  });
133
133
  fireEvent.click(loadMore);
134
134
 
@@ -152,7 +152,7 @@ describe("/roadmap route", () => {
152
152
  ).toBeTruthy();
153
153
  await waitFor(() =>
154
154
  expect(
155
- screen.queryByRole("button", { name: "Load more Planned Posts" }),
155
+ screen.queryByRole("button", { name: "View more Planned Posts" }),
156
156
  ).toBeNull(),
157
157
  );
158
158
  expect(
@@ -163,6 +163,42 @@ describe("/roadmap route", () => {
163
163
  ).toBe("1 more Planned Post loaded.");
164
164
  });
165
165
 
166
+ it("reveals five cards at a time before requesting another server page", async () => {
167
+ const items = Array.from({ length: 12 }, (_, index) =>
168
+ post(`planned-${index}`, "Planned"),
169
+ );
170
+ const loadMore = vi.fn().mockResolvedValue({
171
+ items: [post("planned-12", "Planned")],
172
+ totalCount: 13,
173
+ });
174
+ render(
175
+ <PublicRoadmapView
176
+ roadmap={{
177
+ Planned: { items, totalCount: 13, nextCursor: "next-page" },
178
+ "In Progress": { items: [], totalCount: 0 },
179
+ Shipped: { items: [], totalCount: 0 },
180
+ }}
181
+ loadMore={loadMore}
182
+ />,
183
+ );
184
+ expect(screen.getAllByRole("link")).toHaveLength(5);
185
+ const button = screen.getByRole("button", {
186
+ name: "View more Planned Posts",
187
+ });
188
+ fireEvent.click(button);
189
+ expect(screen.getAllByRole("link")).toHaveLength(10);
190
+ expect(loadMore).not.toHaveBeenCalled();
191
+ fireEvent.click(button);
192
+ expect(
193
+ await screen.findByRole("link", { name: /planned-12 / }),
194
+ ).toBeTruthy();
195
+ expect(screen.getAllByRole("link")).toHaveLength(13);
196
+ expect(loadMore).toHaveBeenCalledOnce();
197
+ expect(
198
+ screen.queryByRole("button", { name: "View more Planned Posts" }),
199
+ ).toBeNull();
200
+ });
201
+
166
202
  it("shows one status column at a time behind mobile status tabs", async () => {
167
203
  Object.defineProperty(window, "matchMedia", {
168
204
  configurable: true,
@@ -50,6 +50,12 @@ const roadmapGroups = [
50
50
  ] as const;
51
51
 
52
52
  const mobileMediaQuery = "(max-width: 1023px)";
53
+ const previewSize = 5;
54
+ const initialVisible = {
55
+ Planned: previewSize,
56
+ "In Progress": previewSize,
57
+ Shipped: previewSize,
58
+ };
53
59
 
54
60
  export function PublicRoadmapView({
55
61
  roadmap,
@@ -63,6 +69,7 @@ export function PublicRoadmapView({
63
69
  const mobile = useMediaQuery(mobileMediaQuery);
64
70
  const [activeStatus, setActiveStatus] = useState<RoadmapStatus>("Planned");
65
71
  const [columns, setColumns] = useState(roadmap);
72
+ const [visible, setVisible] = useState(initialVisible);
66
73
  const [loading, setLoading] = useState<Partial<Record<RoadmapStatus, true>>>(
67
74
  {},
68
75
  );
@@ -71,7 +78,10 @@ export function PublicRoadmapView({
71
78
  );
72
79
  const [loadStatusMessage, setLoadStatusMessage] = useState("");
73
80
 
74
- useEffect(() => setColumns(roadmap), [roadmap]);
81
+ useEffect(() => {
82
+ setColumns(roadmap);
83
+ setVisible(initialVisible);
84
+ }, [roadmap]);
75
85
 
76
86
  const visibleGroups = mobile
77
87
  ? roadmapGroups.filter(({ status }) => status === activeStatus)
@@ -89,13 +99,26 @@ export function PublicRoadmapView({
89
99
  return next;
90
100
  });
91
101
  try {
92
- const nextPage = await loadMore(query);
93
- setColumns((current) => ({
94
- ...current,
95
- [query.status]: appendRoadmapPage(current[query.status], nextPage),
96
- }));
102
+ const before = Math.min(
103
+ visible[query.status],
104
+ columns[query.status].items.length,
105
+ );
106
+ const target = before + previewSize;
107
+ let combined = columns[query.status];
108
+ const cursors = new Set<string>();
109
+ do {
110
+ if (!combined.nextCursor || cursors.has(combined.nextCursor)) break;
111
+ cursors.add(combined.nextCursor);
112
+ combined = appendRoadmapPage(
113
+ combined,
114
+ await loadMore({ status: query.status, cursor: combined.nextCursor }),
115
+ );
116
+ } while (combined.items.length < target && combined.nextCursor);
117
+ setColumns((current) => ({ ...current, [query.status]: combined }));
118
+ const count = Math.min(target, combined.items.length) - before;
119
+ setVisible((current) => ({ ...current, [query.status]: before + count }));
97
120
  setLoadStatusMessage(
98
- `${nextPage.items.length} more ${group.heading} ${nextPage.items.length === 1 ? "Post" : "Posts"} loaded.`,
121
+ `${count} more ${group.heading} ${count === 1 ? "Post" : "Posts"} loaded.`,
99
122
  );
100
123
  } catch {
101
124
  setErrors((current) => ({ ...current, [query.status]: true }));
@@ -168,7 +191,7 @@ export function PublicRoadmapView({
168
191
  <RoadmapEmptyState heading={heading} />
169
192
  ) : (
170
193
  <ol className="grid gap-3">
171
- {column.items.map((item) => (
194
+ {column.items.slice(0, visible[status]).map((item) => (
172
195
  <RoadmapItem
173
196
  item={item}
174
197
  key={item.slug}
@@ -200,26 +223,36 @@ export function PublicRoadmapView({
200
223
  </Button>
201
224
  </div>
202
225
  ) : null}
203
- {column.nextCursor && !errors[status] && loadMore ? (
226
+ {(column.items.length > visible[status] ||
227
+ (column.nextCursor && loadMore)) &&
228
+ !errors[status] ? (
204
229
  <Button
205
- aria-label={`Load more ${heading} Posts`}
230
+ aria-label={`View more ${heading} Posts`}
206
231
  className="mt-4 h-10 w-full gap-2"
207
232
  disabled={loading[status] === true}
208
233
  type="button"
209
234
  variant="ghost"
210
- onClick={() =>
211
- void loadNextPage({
212
- status,
213
- cursor: column.nextCursor,
214
- })
215
- }
235
+ onClick={() => {
236
+ if (
237
+ column.items.length - visible[status] >= previewSize ||
238
+ !column.nextCursor ||
239
+ !loadMore
240
+ ) {
241
+ setVisible((current) => ({
242
+ ...current,
243
+ [status]: current[status] + previewSize,
244
+ }));
245
+ } else if (column.nextCursor) {
246
+ void loadNextPage({ status, cursor: column.nextCursor });
247
+ }
248
+ }}
216
249
  >
217
250
  {loading[status] ? (
218
251
  <>
219
252
  <Spinner aria-hidden="true" /> Loading…
220
253
  </>
221
254
  ) : (
222
- "Load more"
255
+ "View more"
223
256
  )}
224
257
  </Button>
225
258
  ) : null}
@@ -306,12 +339,12 @@ function RoadmapIntro() {
306
339
  return (
307
340
  <section aria-labelledby="roadmap-heading">
308
341
  <h1
309
- className="text-3xl font-semibold tracking-tight"
342
+ className="text-2xl font-semibold tracking-tight"
310
343
  id="roadmap-heading"
311
344
  >
312
345
  Roadmap
313
346
  </h1>
314
- <p className="mt-2 text-base text-muted-foreground">
347
+ <p className="mt-2 text-sm text-muted-foreground">
315
348
  What we’re planning, building, and shipping.
316
349
  </p>
317
350
  </section>
@@ -342,15 +375,15 @@ function RoadmapItem({ item, masked }: { item: PublicPost; masked: boolean }) {
342
375
  contextual={masked}
343
376
  className="group block border bg-background/30 transition-colors duration-150 hover:bg-muted/30"
344
377
  >
345
- <article className="p-5">
346
- <h3 className="text-base font-medium group-hover:text-foreground">
378
+ <article className="flex h-40 flex-col p-4">
379
+ <h3 className="line-clamp-2 min-h-10 text-sm font-medium leading-5 group-hover:text-foreground">
347
380
  {item.title}
348
381
  </h3>
349
- <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
382
+ <p className="mt-2 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">
350
383
  {item.description}
351
384
  </p>
352
385
  <div
353
- className="mt-4 flex flex-wrap items-center gap-3 text-xs text-muted-foreground"
386
+ className="mt-auto flex items-center gap-3 pt-3 text-xs text-muted-foreground"
354
387
  role="group"
355
388
  aria-label="Post metadata"
356
389
  >
@@ -365,6 +365,32 @@ describe("command palette", () => {
365
365
  await waitFor(() => expect(history.location.pathname).toBe("/roadmap"));
366
366
  });
367
367
 
368
+ it("keeps previous search results visible while a new term is loading", async () => {
369
+ let finish: ((page: PublicPostPage) => void) | undefined;
370
+ renderShell("/", {
371
+ searchPosts: (term) =>
372
+ term === "keyboard"
373
+ ? Promise.resolve({ items: [palettePost()] })
374
+ : new Promise((resolve) => {
375
+ finish = resolve;
376
+ }),
377
+ });
378
+ await screen.findByRole("link", { name: "Feedback" });
379
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
380
+ const input = await screen.findByPlaceholderText("Search feedback…");
381
+ fireEvent.change(input, { target: { value: "keyboard" } });
382
+ await screen.findByText("Keyboard-first search");
383
+ fireEvent.change(input, { target: { value: "next search" } });
384
+ await waitFor(() => expect(finish).toBeTypeOf("function"));
385
+ expect(screen.getByText("Keyboard-first search")).toBeTruthy();
386
+ finish?.({ items: [] });
387
+ await waitFor(() =>
388
+ expect(
389
+ screen.getByRole("status", { name: "Search status" }).textContent,
390
+ ).toBe("No Posts match this search."),
391
+ );
392
+ });
393
+
368
394
  it("shows an empty state when no Posts match", async () => {
369
395
  renderShell("/");
370
396
 
@@ -11,6 +11,7 @@
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as IndexRouteImport } from './routes/index'
13
13
  import { Route as ChangelogRouteImport } from './routes/changelog'
14
+ import { Route as EmailPreferencesRouteImport } from './routes/email-preferences'
14
15
  import { Route as RoadmapRouteImport } from './routes/roadmap'
15
16
  import { Route as SubmitRouteImport } from './routes/submit'
16
17
  import { Route as PSlugRouteImport } from './routes/p.$slug'
@@ -25,6 +26,11 @@ const ChangelogRoute = ChangelogRouteImport.update({
25
26
  path: '/changelog',
26
27
  getParentRoute: () => rootRouteImport,
27
28
  } as any)
29
+ const EmailPreferencesRoute = EmailPreferencesRouteImport.update({
30
+ id: '/email-preferences',
31
+ path: '/email-preferences',
32
+ getParentRoute: () => rootRouteImport,
33
+ } as any)
28
34
  const RoadmapRoute = RoadmapRouteImport.update({
29
35
  id: '/roadmap',
30
36
  path: '/roadmap',
@@ -44,6 +50,7 @@ const PSlugRoute = PSlugRouteImport.update({
44
50
  export interface FileRoutesByFullPath {
45
51
  '/': typeof IndexRoute
46
52
  '/changelog': typeof ChangelogRoute
53
+ '/email-preferences': typeof EmailPreferencesRoute
47
54
  '/roadmap': typeof RoadmapRoute
48
55
  '/submit': typeof SubmitRoute
49
56
  '/p/$slug': typeof PSlugRoute
@@ -51,6 +58,7 @@ export interface FileRoutesByFullPath {
51
58
  export interface FileRoutesByTo {
52
59
  '/': typeof IndexRoute
53
60
  '/changelog': typeof ChangelogRoute
61
+ '/email-preferences': typeof EmailPreferencesRoute
54
62
  '/roadmap': typeof RoadmapRoute
55
63
  '/submit': typeof SubmitRoute
56
64
  '/p/$slug': typeof PSlugRoute
@@ -59,21 +67,42 @@ export interface FileRoutesById {
59
67
  __root__: typeof rootRouteImport
60
68
  '/': typeof IndexRoute
61
69
  '/changelog': typeof ChangelogRoute
70
+ '/email-preferences': typeof EmailPreferencesRoute
62
71
  '/roadmap': typeof RoadmapRoute
63
72
  '/submit': typeof SubmitRoute
64
73
  '/p/$slug': typeof PSlugRoute
65
74
  }
66
75
  export interface FileRouteTypes {
67
76
  fileRoutesByFullPath: FileRoutesByFullPath
68
- fullPaths: '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
77
+ fullPaths:
78
+ | '/'
79
+ | '/changelog'
80
+ | '/email-preferences'
81
+ | '/roadmap'
82
+ | '/submit'
83
+ | '/p/$slug'
69
84
  fileRoutesByTo: FileRoutesByTo
70
- to: '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
71
- id: '__root__' | '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
85
+ to:
86
+ | '/'
87
+ | '/changelog'
88
+ | '/email-preferences'
89
+ | '/roadmap'
90
+ | '/submit'
91
+ | '/p/$slug'
92
+ id:
93
+ | '__root__'
94
+ | '/'
95
+ | '/changelog'
96
+ | '/email-preferences'
97
+ | '/roadmap'
98
+ | '/submit'
99
+ | '/p/$slug'
72
100
  fileRoutesById: FileRoutesById
73
101
  }
74
102
  export interface RootRouteChildren {
75
103
  IndexRoute: typeof IndexRoute
76
104
  ChangelogRoute: typeof ChangelogRoute
105
+ EmailPreferencesRoute: typeof EmailPreferencesRoute
77
106
  RoadmapRoute: typeof RoadmapRoute
78
107
  SubmitRoute: typeof SubmitRoute
79
108
  PSlugRoute: typeof PSlugRoute
@@ -95,6 +124,13 @@ declare module '@tanstack/react-router' {
95
124
  preLoaderRoute: typeof ChangelogRouteImport
96
125
  parentRoute: typeof rootRouteImport
97
126
  }
127
+ '/email-preferences': {
128
+ id: '/email-preferences'
129
+ path: '/email-preferences'
130
+ fullPath: '/email-preferences'
131
+ preLoaderRoute: typeof EmailPreferencesRouteImport
132
+ parentRoute: typeof rootRouteImport
133
+ }
98
134
  '/roadmap': {
99
135
  id: '/roadmap'
100
136
  path: '/roadmap'
@@ -122,6 +158,7 @@ declare module '@tanstack/react-router' {
122
158
  const rootRouteChildren: RootRouteChildren = {
123
159
  IndexRoute: IndexRoute,
124
160
  ChangelogRoute: ChangelogRoute,
161
+ EmailPreferencesRoute: EmailPreferencesRoute,
125
162
  RoadmapRoute: RoadmapRoute,
126
163
  SubmitRoute: SubmitRoute,
127
164
  PSlugRoute: PSlugRoute,
@@ -129,3 +166,12 @@ const rootRouteChildren: RootRouteChildren = {
129
166
  export const routeTree = rootRouteImport
130
167
  ._addFileChildren(rootRouteChildren)
131
168
  ._addFileTypes<FileRouteTypes>()
169
+
170
+ import type { getRouter } from './router.tsx'
171
+ import type { createStart } from '@tanstack/react-start'
172
+ declare module '@tanstack/react-start' {
173
+ interface Register {
174
+ ssr: true
175
+ router: Awaited<ReturnType<typeof getRouter>>
176
+ }
177
+ }
@@ -11,6 +11,8 @@ export const getRouter = () => {
11
11
  scrollRestoration: true,
12
12
  defaultPreloadStaleTime: 0,
13
13
  context: { queryClient },
14
+ defaultPendingMs: 250,
15
+ defaultPendingMinMs: 200,
14
16
  defaultPendingComponent: () => <Loader />,
15
17
  defaultNotFoundComponent: () => <div>Not Found</div>,
16
18
  });
@@ -11,6 +11,8 @@ import { ThemeProvider } from "next-themes";
11
11
  import { QueryClientProvider } from "@tanstack/react-query";
12
12
 
13
13
  import Header from "../components/header";
14
+ import { PortalFooter } from "../components/portal-footer";
15
+ import { LazyPostDetailOverlay as PostDetailOverlay } from "../lazy-post-detail-overlay";
14
16
  import { DeviceProfileProvider } from "../components/device-profile-provider";
15
17
  import {
16
18
  CommandPaletteProvider,
@@ -39,11 +41,6 @@ const CreatePostOverlay = lazy(() =>
39
41
  default: module.LazyCreatePostOverlay,
40
42
  })),
41
43
  );
42
- const PostDetailOverlay = lazy(() =>
43
- import("../lazy-post-detail-overlay").then((module) => ({
44
- default: module.LazyPostDetailOverlay,
45
- })),
46
- );
47
44
 
48
45
  export type RouterAppContext = {
49
46
  queryClient: import("@tanstack/react-query").QueryClient;
@@ -97,6 +94,7 @@ function RootDocument() {
97
94
  <div id="main-content" tabIndex={-1}>
98
95
  <Outlet />
99
96
  </div>
97
+ <PortalFooter />
100
98
  <OptionalPortalSurfaces />
101
99
  </div>
102
100
  </CommandPaletteProvider>
@@ -0,0 +1,77 @@
1
+ import { createFileRoute, notFound } from "@tanstack/react-router";
2
+ import { useState } from "react";
3
+ import { Button } from "@feedbax/ui/components/button";
4
+ import feedbax from "../feedbax";
5
+ export const Route = createFileRoute("/email-preferences")({
6
+ validateSearch: (search: Record<string, unknown>) => ({
7
+ token: typeof search.token === "string" ? search.token : "",
8
+ }),
9
+ beforeLoad: () => {
10
+ if (!feedbax.connected?.email) throw notFound();
11
+ },
12
+ head: () => ({
13
+ meta: [
14
+ { name: "robots", content: "noindex,nofollow" },
15
+ { name: "referrer", content: "no-referrer" },
16
+ ],
17
+ }),
18
+ component: EmailPreferencesPage,
19
+ });
20
+ function EmailPreferencesPage() {
21
+ const { token } = Route.useSearch();
22
+ const [pending, setPending] = useState(false);
23
+ const [done, setDone] = useState(false);
24
+ const [error, setError] = useState<string>();
25
+ async function unsubscribe() {
26
+ setPending(true);
27
+ setError(undefined);
28
+ try {
29
+ const response = await fetch(
30
+ `/internal/connected/unsubscribe?token=${encodeURIComponent(token)}`,
31
+ { method: "POST" },
32
+ );
33
+ if (!response.ok)
34
+ throw new Error(
35
+ "This link could not be used. Try again or change email preferences in your notification inbox.",
36
+ );
37
+ setDone(true);
38
+ } catch (cause) {
39
+ setError(
40
+ cause instanceof Error
41
+ ? cause.message
42
+ : "Couldn’t update email preferences.",
43
+ );
44
+ } finally {
45
+ setPending(false);
46
+ }
47
+ }
48
+ return (
49
+ <main className="mx-auto max-w-lg space-y-5 px-6 py-16">
50
+ <h1 className="text-2xl font-semibold tracking-tight">
51
+ {done ? "Email notifications are off" : "Stop email notifications"}
52
+ </h1>
53
+ <p className="text-sm leading-relaxed text-muted-foreground">
54
+ {done
55
+ ? "You can turn them on again in your notification inbox."
56
+ : "You will keep receiving updates inside Feedbax."}
57
+ </p>
58
+ {done ? (
59
+ <Button nativeButton={false} render={<a href="/" />} variant="outline">
60
+ Back to feedback
61
+ </Button>
62
+ ) : (
63
+ <Button
64
+ onClick={unsubscribe}
65
+ disabled={pending || !/^[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/.test(token)}
66
+ >
67
+ {pending ? "Updating…" : "Stop email notifications"}
68
+ </Button>
69
+ )}
70
+ {error ? (
71
+ <p role="alert" className="text-sm text-destructive">
72
+ {error}
73
+ </p>
74
+ ) : null}
75
+ </main>
76
+ );
77
+ }
@@ -21,7 +21,7 @@ export const Route = createFileRoute("/submit")({
21
21
 
22
22
  function SubmitComponent() {
23
23
  return (
24
- <main className="feedback-index submission-page">
24
+ <main className="feedback-index">
25
25
  <RoutedPostCreationForm />
26
26
  </main>
27
27
  );
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  BrowserCapabilityAuthorizationError,
3
3
  VoteEligibilityError,
4
+ ImageAttachmentError,
4
5
  } from "@feedbax/feedback";
5
6
 
6
7
  export async function runSafePublicRead<Value>(
@@ -32,7 +33,8 @@ export async function runSafePortalMutation<Value>(
32
33
  if (
33
34
  error instanceof ActionablePortalFailure ||
34
35
  error instanceof BrowserCapabilityAuthorizationError ||
35
- error instanceof VoteEligibilityError
36
+ error instanceof VoteEligibilityError ||
37
+ error instanceof ImageAttachmentError
36
38
  ) {
37
39
  throw error;
38
40
  }