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
@@ -11,6 +11,8 @@
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'
15
+ import { Route as MyFeedbackRouteImport } from './routes/my-feedback'
14
16
  import { Route as RoadmapRouteImport } from './routes/roadmap'
15
17
  import { Route as SubmitRouteImport } from './routes/submit'
16
18
  import { Route as PSlugRouteImport } from './routes/p.$slug'
@@ -25,6 +27,16 @@ const ChangelogRoute = ChangelogRouteImport.update({
25
27
  path: '/changelog',
26
28
  getParentRoute: () => rootRouteImport,
27
29
  } as any)
30
+ const EmailPreferencesRoute = EmailPreferencesRouteImport.update({
31
+ id: '/email-preferences',
32
+ path: '/email-preferences',
33
+ getParentRoute: () => rootRouteImport,
34
+ } as any)
35
+ const MyFeedbackRoute = MyFeedbackRouteImport.update({
36
+ id: '/my-feedback',
37
+ path: '/my-feedback',
38
+ getParentRoute: () => rootRouteImport,
39
+ } as any)
28
40
  const RoadmapRoute = RoadmapRouteImport.update({
29
41
  id: '/roadmap',
30
42
  path: '/roadmap',
@@ -44,6 +56,8 @@ const PSlugRoute = PSlugRouteImport.update({
44
56
  export interface FileRoutesByFullPath {
45
57
  '/': typeof IndexRoute
46
58
  '/changelog': typeof ChangelogRoute
59
+ '/email-preferences': typeof EmailPreferencesRoute
60
+ '/my-feedback': typeof MyFeedbackRoute
47
61
  '/roadmap': typeof RoadmapRoute
48
62
  '/submit': typeof SubmitRoute
49
63
  '/p/$slug': typeof PSlugRoute
@@ -51,6 +65,8 @@ export interface FileRoutesByFullPath {
51
65
  export interface FileRoutesByTo {
52
66
  '/': typeof IndexRoute
53
67
  '/changelog': typeof ChangelogRoute
68
+ '/email-preferences': typeof EmailPreferencesRoute
69
+ '/my-feedback': typeof MyFeedbackRoute
54
70
  '/roadmap': typeof RoadmapRoute
55
71
  '/submit': typeof SubmitRoute
56
72
  '/p/$slug': typeof PSlugRoute
@@ -59,21 +75,47 @@ export interface FileRoutesById {
59
75
  __root__: typeof rootRouteImport
60
76
  '/': typeof IndexRoute
61
77
  '/changelog': typeof ChangelogRoute
78
+ '/email-preferences': typeof EmailPreferencesRoute
79
+ '/my-feedback': typeof MyFeedbackRoute
62
80
  '/roadmap': typeof RoadmapRoute
63
81
  '/submit': typeof SubmitRoute
64
82
  '/p/$slug': typeof PSlugRoute
65
83
  }
66
84
  export interface FileRouteTypes {
67
85
  fileRoutesByFullPath: FileRoutesByFullPath
68
- fullPaths: '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
86
+ fullPaths:
87
+ | '/'
88
+ | '/changelog'
89
+ | '/email-preferences'
90
+ | '/my-feedback'
91
+ | '/roadmap'
92
+ | '/submit'
93
+ | '/p/$slug'
69
94
  fileRoutesByTo: FileRoutesByTo
70
- to: '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
71
- id: '__root__' | '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
95
+ to:
96
+ | '/'
97
+ | '/changelog'
98
+ | '/email-preferences'
99
+ | '/my-feedback'
100
+ | '/roadmap'
101
+ | '/submit'
102
+ | '/p/$slug'
103
+ id:
104
+ | '__root__'
105
+ | '/'
106
+ | '/changelog'
107
+ | '/email-preferences'
108
+ | '/my-feedback'
109
+ | '/roadmap'
110
+ | '/submit'
111
+ | '/p/$slug'
72
112
  fileRoutesById: FileRoutesById
73
113
  }
74
114
  export interface RootRouteChildren {
75
115
  IndexRoute: typeof IndexRoute
76
116
  ChangelogRoute: typeof ChangelogRoute
117
+ EmailPreferencesRoute: typeof EmailPreferencesRoute
118
+ MyFeedbackRoute: typeof MyFeedbackRoute
77
119
  RoadmapRoute: typeof RoadmapRoute
78
120
  SubmitRoute: typeof SubmitRoute
79
121
  PSlugRoute: typeof PSlugRoute
@@ -95,6 +137,20 @@ declare module '@tanstack/react-router' {
95
137
  preLoaderRoute: typeof ChangelogRouteImport
96
138
  parentRoute: typeof rootRouteImport
97
139
  }
140
+ '/email-preferences': {
141
+ id: '/email-preferences'
142
+ path: '/email-preferences'
143
+ fullPath: '/email-preferences'
144
+ preLoaderRoute: typeof EmailPreferencesRouteImport
145
+ parentRoute: typeof rootRouteImport
146
+ }
147
+ '/my-feedback': {
148
+ id: '/my-feedback'
149
+ path: '/my-feedback'
150
+ fullPath: '/my-feedback'
151
+ preLoaderRoute: typeof MyFeedbackRouteImport
152
+ parentRoute: typeof rootRouteImport
153
+ }
98
154
  '/roadmap': {
99
155
  id: '/roadmap'
100
156
  path: '/roadmap'
@@ -122,6 +178,8 @@ declare module '@tanstack/react-router' {
122
178
  const rootRouteChildren: RootRouteChildren = {
123
179
  IndexRoute: IndexRoute,
124
180
  ChangelogRoute: ChangelogRoute,
181
+ EmailPreferencesRoute: EmailPreferencesRoute,
182
+ MyFeedbackRoute: MyFeedbackRoute,
125
183
  RoadmapRoute: RoadmapRoute,
126
184
  SubmitRoute: SubmitRoute,
127
185
  PSlugRoute: PSlugRoute,
@@ -129,3 +187,12 @@ const rootRouteChildren: RootRouteChildren = {
129
187
  export const routeTree = rootRouteImport
130
188
  ._addFileChildren(rootRouteChildren)
131
189
  ._addFileTypes<FileRouteTypes>()
190
+
191
+ import type { getRouter } from './router.tsx'
192
+ import type { createStart } from '@tanstack/react-start'
193
+ declare module '@tanstack/react-start' {
194
+ interface Register {
195
+ ssr: true
196
+ router: Awaited<ReturnType<typeof getRouter>>
197
+ }
198
+ }
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ import { createFileRoute, notFound } from "@tanstack/react-router";
2
+ import feedbax from "../feedbax";
3
+ import { MyFeedbackPage } from "../my-feedback-page";
4
+ export const Route = createFileRoute("/my-feedback")({
5
+ beforeLoad: () => {
6
+ if (!feedbax.connected) throw notFound();
7
+ },
8
+ head: () => ({
9
+ meta: [
10
+ { title: "My feedback" },
11
+ { name: "robots", content: "noindex,nofollow" },
12
+ { name: "referrer", content: "no-referrer" },
13
+ ],
14
+ }),
15
+ component: MyFeedbackPage,
16
+ });
@@ -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
  }
@@ -1,8 +1,22 @@
1
+ import { createMyFeedbackRequestHandler } from "./my-feedback-runtime";
2
+ import { createPublicHistoryRequestHandler } from "./history-runtime";
3
+ import { handleImageRequest } from "./images-runtime";
4
+ import {
5
+ handleParticipationRequest,
6
+ synchronizeConnectedUpdates,
7
+ } from "./participation-runtime";
8
+ import { handleConnectedRequest } from "./connected-runtime";
1
9
  import { configuredTurnstileSecret } from "./turnstile-configuration";
2
10
  import { createPortalServerEntry } from "./portal-server-entry";
3
11
  import { createConfiguredTrustedFeedbackHandler } from "./trusted-feedback-runtime";
4
12
  import { env } from "cloudflare:workers";
5
- import { createConfiguredFeedbackModule } from "./feedback-runtime";
13
+ import {
14
+ configuredIntegration,
15
+ configuredHistory,
16
+ createConfiguredContentFeedbackModule,
17
+ createConfiguredFeedbackModule,
18
+ requestParticipant,
19
+ } from "./feedback-runtime";
6
20
  import { createPortalVoteHandler } from "./portal-voting";
7
21
  import { createCloudflareTurnstileVerifier } from "./cloudflare-turnstile";
8
22
  import feedbax from "./feedbax";
@@ -13,7 +27,43 @@ import {
13
27
  createPortalCommentRequestHandler,
14
28
  } from "./portal-comments";
15
29
 
16
- export default createPortalServerEntry({
30
+ const application = createPortalServerEntry({
31
+ cachePublicReads: feedbax.provider !== "github" && !feedbax.history,
32
+ connectedHandler: async (request) =>
33
+ (await createPublicHistoryRequestHandler({
34
+ enabled: Boolean(feedbax.history),
35
+ history: feedbax.history ? configuredHistory() : undefined,
36
+ rateLimit: async (input) =>
37
+ (
38
+ await env.TRUSTED_SUBMISSIONS_RATE_LIMITER.limit({
39
+ key: `history:${input.headers.get("CF-Connecting-IP") ?? "unknown"}`,
40
+ })
41
+ ).success,
42
+ })(request)) ??
43
+ (feedbax.connected &&
44
+ env.FEEDBAX_DB &&
45
+ request.url.includes("/internal/my-feedback")
46
+ ? await createMyFeedbackRequestHandler({
47
+ enabled: true,
48
+ siteUrl: feedbax.product.siteUrl!,
49
+ database: env.FEEDBAX_DB,
50
+ getParticipant: requestParticipant,
51
+ getFeedback: createConfiguredFeedbackModule,
52
+ rateLimit: async (input) =>
53
+ (
54
+ await env.TRUSTED_SUBMISSIONS_RATE_LIMITER.limit({
55
+ key: `my-feedback:${input.headers.get("CF-Connecting-IP") ?? "unknown"}`,
56
+ })
57
+ ).success,
58
+ limits: feedbax.submissions,
59
+ })(request)
60
+ : undefined) ??
61
+ (feedbax.provider === "github"
62
+ ? await configuredIntegration().handle(request)
63
+ : undefined) ??
64
+ (await handleImageRequest(request)) ??
65
+ (await handleParticipationRequest(request)) ??
66
+ handleConnectedRequest(request),
17
67
  commentHandler: (request) => {
18
68
  const turnstileSecret = configuredTurnstileSecret(env.TURNSTILE_SECRET_KEY);
19
69
  return createPortalCommentRequestHandler({
@@ -109,3 +159,38 @@ export default createPortalServerEntry({
109
159
  }
110
160
  },
111
161
  });
162
+
163
+ export default {
164
+ ...application,
165
+ async scheduled() {
166
+ if (feedbax.history) await configuredHistory().synchronize();
167
+ if (feedbax.provider === "github")
168
+ await configuredIntegration().reconcile(
169
+ createConfiguredContentFeedbackModule(),
170
+ );
171
+ if (!feedbax.connected) return;
172
+ if (feedbax.connected.processing === "standard") {
173
+ await synchronizeConnectedUpdates();
174
+ return;
175
+ }
176
+ if (!env.FEEDBAX_UPDATES)
177
+ throw new Error(
178
+ "Bind the Connected processing queue as FEEDBAX_UPDATES.",
179
+ );
180
+ await env.FEEDBAX_UPDATES.send("feedbax-connected-tick-v1");
181
+ },
182
+ async queue(
183
+ batch: import("@cloudflare/workers-types").MessageBatch<unknown>,
184
+ ) {
185
+ if (!feedbax.connected || feedbax.connected.processing === "standard")
186
+ throw new Error("Queue delivery requires small Connected processing.");
187
+ for (const message of batch.messages) {
188
+ if (message.body !== "feedbax-connected-tick-v1") {
189
+ message.ack();
190
+ continue;
191
+ }
192
+ await synchronizeConnectedUpdates();
193
+ message.ack();
194
+ }
195
+ },
196
+ };
@@ -0,0 +1,26 @@
1
+ // Session storage is tab-scoped. A failed request reuses the same secret intent
2
+ // across a retry/reload; successful completion makes a later submission distinct.
3
+ export async function submissionIntent(
4
+ storage: Pick<Storage, "getItem" | "setItem" | "removeItem">,
5
+ input: unknown,
6
+ ) {
7
+ const bytes = await crypto.subtle.digest(
8
+ "SHA-256",
9
+ new TextEncoder().encode(JSON.stringify(input)),
10
+ );
11
+ const digest = Array.from(new Uint8Array(bytes), (value) =>
12
+ value.toString(16).padStart(2, "0"),
13
+ ).join("");
14
+ const slot = `feedbax:pending-submission:${digest}`;
15
+ let key = storage.getItem(slot);
16
+ if (!key || !/^[A-Za-z0-9_-]{43}$/.test(key)) {
17
+ key = btoa(
18
+ String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))),
19
+ )
20
+ .replaceAll("+", "-")
21
+ .replaceAll("/", "_")
22
+ .replace(/=+$/, "");
23
+ storage.setItem(slot, key);
24
+ }
25
+ return { key, complete: () => storage.removeItem(slot) };
26
+ }
@@ -43,7 +43,7 @@ describe("trusted feedback Worker handler", () => {
43
43
 
44
44
  expect(response.status).toBe(308);
45
45
  expect(response.headers.get("location")).toBe(
46
- "https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=1",
46
+ "https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=2",
47
47
  );
48
48
  expect(applicationHandler).not.toHaveBeenCalled();
49
49
  });
@@ -1,3 +1,5 @@
1
+ import { publicPostQueryKey } from "./post-detail-query";
2
+ import { useConnectedIdentity } from "./components/connected-identity-provider";
1
3
  import { configuredTurnstileSiteKey } from "./turnstile-configuration";
2
4
  import type { PublicPost } from "@feedbax/feedback";
3
5
  import { ChevronUp } from "lucide-react";
@@ -27,10 +29,12 @@ export function VoteToggle({
27
29
  turnstileSiteKey?: string;
28
30
  requestTimeoutMs?: number;
29
31
  }) {
32
+ const identity = useConnectedIdentity();
30
33
  const queryClient = useContext(QueryClientContext);
31
34
  const [voted, setVoted] = useState(false);
32
35
  const [count, setCount] = useState(post.voteCount ?? 0);
33
36
  const [pending, setPending] = useState(false);
37
+ const [voteStateReady, setVoteStateReady] = useState(!identity.enabled);
34
38
  const [error, setError] = useState<string>();
35
39
  const [participationPass, setParticipationPass] = useState<string>();
36
40
  const form = useRef<HTMLFormElement>(null);
@@ -38,12 +42,34 @@ export function VoteToggle({
38
42
  const turnstileWidget = useRef<string | undefined>(undefined);
39
43
 
40
44
  useEffect(() => {
41
- setVoted(readVotedPostSlugs(localStorage).has(post.slug));
45
+ if (!identity.enabled)
46
+ setVoted(readVotedPostSlugs(localStorage).has(post.slug));
42
47
  setCount(post.voteCount ?? 0);
43
48
  setParticipationPass(
44
49
  sessionStorage.getItem("feedbax:participation-pass") ?? undefined,
45
50
  );
46
- }, [post.slug, post.voteCount]);
51
+ }, [post.slug, post.voteCount, identity.enabled]);
52
+ useEffect(() => {
53
+ if (!identity.enabled || !identity.displayName) return;
54
+ const controller = new AbortController();
55
+ setVoteStateReady(false);
56
+ fetch(`/internal/connected/votes?slug=${encodeURIComponent(post.slug)}`, {
57
+ signal: controller.signal,
58
+ cache: "no-store",
59
+ })
60
+ .then(async (response) => {
61
+ if (!response.ok)
62
+ throw new Error("Could not load your Vote. Refresh and try again.");
63
+ const state = await response.json();
64
+ setVoteStateReady(true);
65
+ setVoted(state.voted);
66
+ setCount(state.voteCount);
67
+ })
68
+ .catch((cause) => {
69
+ if (!controller.signal.aborted) setError(cause.message);
70
+ });
71
+ return () => controller.abort();
72
+ }, [identity.enabled, identity.displayName, post.slug]);
47
73
  useEffect(() => {
48
74
  const reconcile = (event: Event) => {
49
75
  const detail = (
@@ -95,6 +121,10 @@ export function VoteToggle({
95
121
  }, [participationPass, turnstileSiteKey]);
96
122
 
97
123
  async function toggle() {
124
+ if (identity.enabled && !identity.displayName) {
125
+ window.location.href = "/internal/identity/start";
126
+ return;
127
+ }
98
128
  const previous = { voted, count };
99
129
  const nextVoted = !voted;
100
130
  setVoted(nextVoted);
@@ -138,7 +168,8 @@ export function VoteToggle({
138
168
  throw new Error(result.error ?? "Vote failed. Try again.");
139
169
  }
140
170
  setCount(result.voteCount);
141
- persistVotedPost(localStorage, post.slug, nextVoted);
171
+ if (!identity.enabled)
172
+ persistVotedPost(localStorage, post.slug, nextVoted);
142
173
  window.dispatchEvent(
143
174
  new CustomEvent(voteConfirmedEvent, {
144
175
  detail: {
@@ -151,6 +182,7 @@ export function VoteToggle({
151
182
  if (queryClient) {
152
183
  void Promise.all([
153
184
  queryClient.invalidateQueries({ queryKey: publicPostsQueryKey }),
185
+ queryClient.invalidateQueries({ queryKey: publicPostQueryKey }),
154
186
  queryClient.invalidateQueries({ queryKey: publicRoadmapQueryKey }),
155
187
  ]);
156
188
  }
@@ -177,17 +209,18 @@ export function VoteToggle({
177
209
  }
178
210
 
179
211
  return (
180
- <form
181
- ref={form}
182
- className="vote-toggle"
183
- onSubmit={(event) => event.preventDefault()}
184
- >
212
+ <form ref={form} onSubmit={(event) => event.preventDefault()}>
185
213
  <button
186
214
  className="vote-toggle-button"
187
215
  type="button"
188
216
  aria-pressed={voted}
189
217
  aria-label={`${voted ? "Remove Vote" : "Add Vote"}, ${count} Votes`}
190
- disabled={pending}
218
+ disabled={
219
+ pending ||
220
+ (identity.enabled &&
221
+ (!identity.ready ||
222
+ (Boolean(identity.displayName) && !voteStateReady)))
223
+ }
191
224
  onClick={toggle}
192
225
  >
193
226
  <ChevronUp aria-hidden="true" /> {count}
@@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
3
3
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
4
4
  import viteReact from "@vitejs/plugin-react";
5
5
  import { defineConfig } from "vite";
6
+ import { publicFeedbaxConfiguration } from "./feedbax-vite";
6
7
 
7
8
  export default defineConfig({
8
9
  server: {
@@ -12,6 +13,7 @@ export default defineConfig({
12
13
  tsconfigPaths: true,
13
14
  },
14
15
  plugins: [
16
+ publicFeedbaxConfiguration(),
15
17
  cloudflare({ viteEnvironment: { name: "ssr" } }),
16
18
  tailwindcss(),
17
19
  tanstackStart({ server: { entry: "./server.ts" } }),