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
+ // @vitest-environment jsdom
2
+ import type { ChangelogPage as PageData } from "@feedbax/changelog";
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import {
11
+ Outlet,
12
+ RouterProvider,
13
+ createMemoryHistory,
14
+ createRootRoute,
15
+ createRoute,
16
+ createRouter,
17
+ } from "@tanstack/react-router";
18
+ import { afterEach, describe, expect, it, vi } from "vitest";
19
+ import { ChangelogPage } from "./changelog-page";
20
+ import { CommandPaletteProvider } from "./components/command-palette";
21
+
22
+ afterEach(cleanup);
23
+
24
+ describe("Changelog page", () => {
25
+ it("renders loader data, filters by Label, and loads the next cursor", async () => {
26
+ const loadPage = vi.fn().mockImplementation(async ({ cursor, label }) => {
27
+ if (label === "Fixed") return page([entry("fixed", "Fixed")]);
28
+ if (cursor === "next") return page([entry("older", "Improved")]);
29
+ return page([]);
30
+ });
31
+ renderChangelog({
32
+ initialPage: page([entry("new", "Improved")], "next"),
33
+ loadPage,
34
+ });
35
+
36
+ expect(
37
+ await screen.findByRole("heading", { level: 2, name: "new" }),
38
+ ).toBeTruthy();
39
+ fireEvent.click(screen.getByRole("button", { name: "Load more updates" }));
40
+ expect(
41
+ await screen.findByRole("heading", { level: 2, name: "older" }),
42
+ ).toBeTruthy();
43
+ expect(loadPage).toHaveBeenCalledWith({ cursor: "next" });
44
+
45
+ fireEvent.click(screen.getByRole("button", { name: "Improved" }));
46
+ await waitFor(() =>
47
+ expect(loadPage).toHaveBeenCalledWith({ label: "Improved" }),
48
+ );
49
+ });
50
+
51
+ it("preserves hash links and renders rich-text properties without page-body reads", async () => {
52
+ const loadPage = vi
53
+ .fn()
54
+ .mockResolvedValue(page([entry("anchored", "Fixed")]));
55
+ const { container } = renderChangelog({
56
+ initialPage: page([entry("first", "Improved")], "next"),
57
+ loadPage,
58
+ initialEntry: "/changelog#anchored",
59
+ });
60
+ expect(await screen.findByText("Body anchored")).toBeTruthy();
61
+ expect(loadPage).toHaveBeenCalledWith({ cursor: "next" });
62
+ expect(
63
+ screen
64
+ .getByRole("link", { name: "Link to anchored" })
65
+ .getAttribute("href"),
66
+ ).toBe("#anchored");
67
+ expect(container.querySelector("article#anchored")).toBeTruthy();
68
+ });
69
+
70
+ it("renders an accessible image and preserves text when an image is absent or expired", async () => {
71
+ const valid = entry("illustrated", "Improved");
72
+ valid.image = {
73
+ src: "https://files.notion.example/illustrated.png",
74
+ alt: "Illustrated image",
75
+ expiresAt: new Date(Date.now() + 60_000),
76
+ };
77
+ const expired = entry("expired", "Fixed");
78
+ expired.image = {
79
+ src: "https://files.notion.example/expired.png",
80
+ alt: "Expired image",
81
+ expiresAt: new Date(Date.now() - 1),
82
+ };
83
+
84
+ renderChangelog({ initialPage: page([valid, expired]), loadPage: vi.fn() });
85
+
86
+ expect(
87
+ await screen.findByRole("img", { name: "Illustrated image" }),
88
+ ).toBeTruthy();
89
+ expect(screen.queryByRole("img", { name: "Expired image" })).toBeNull();
90
+ expect(screen.getByText("Body expired")).toBeTruthy();
91
+ });
92
+ });
93
+
94
+ function renderChangelog({
95
+ initialPage,
96
+ loadPage,
97
+ initialEntry = "/changelog",
98
+ }: {
99
+ initialPage: PageData;
100
+ loadPage: (input: { cursor?: string; label?: string }) => Promise<PageData>;
101
+ initialEntry?: string;
102
+ }) {
103
+ const history = createMemoryHistory({ initialEntries: [initialEntry] });
104
+ const root = createRootRoute({
105
+ component: () => (
106
+ <CommandPaletteProvider>
107
+ <Outlet />
108
+ </CommandPaletteProvider>
109
+ ),
110
+ });
111
+ const changelog = createRoute({
112
+ getParentRoute: () => root,
113
+ path: "/changelog",
114
+ component: () => (
115
+ <ChangelogPage initialPage={initialPage} loadPage={loadPage} />
116
+ ),
117
+ });
118
+ const router = createRouter({
119
+ history,
120
+ routeTree: root.addChildren([changelog]),
121
+ });
122
+ return render(<RouterProvider router={router} />);
123
+ }
124
+
125
+ function page(items: PageData["items"], nextCursor?: string): PageData {
126
+ return { items, ...(nextCursor ? { nextCursor } : {}) };
127
+ }
128
+
129
+ function entry(slug: string, label: string): PageData["items"][number] {
130
+ return {
131
+ slug,
132
+ date: "2026-08-01",
133
+ title: slug,
134
+ summary: `Summary ${slug}`,
135
+ body: `Body ${slug}`,
136
+ labels: [label],
137
+ updatedAt: new Date("2026-08-01T00:00:00Z"),
138
+ };
139
+ }
@@ -0,0 +1,219 @@
1
+ import type {
2
+ ChangelogPage as PageData,
3
+ PublicChangelogEntry,
4
+ } from "@feedbax/changelog";
5
+ import { Badge } from "@feedbax/ui/components/badge";
6
+ import { Button } from "@feedbax/ui/components/button";
7
+ import { ButtonGroup } from "@feedbax/ui/components/button-group";
8
+ import { cn } from "@feedbax/ui/lib/utils";
9
+ import { useRouterState } from "@tanstack/react-router";
10
+ import { ChevronDown, Link2 } from "lucide-react";
11
+ import { useEffect, useMemo, useState } from "react";
12
+ import { CommandPaletteTrigger } from "./components/command-palette-context";
13
+
14
+ export type LoadChangelogPage = (input: {
15
+ cursor?: string;
16
+ label?: string;
17
+ }) => Promise<PageData>;
18
+
19
+ const emptyPage: PageData = { items: [] };
20
+
21
+ export function ChangelogPage({
22
+ initialPage = emptyPage,
23
+ loadPage = async () => emptyPage,
24
+ }: { initialPage?: PageData; loadPage?: LoadChangelogPage } = {}) {
25
+ const locationHash = useRouterState({
26
+ select: (state) => state.location.hash,
27
+ });
28
+ const [selectedLabel, setSelectedLabel] = useState<string>();
29
+ const [page, setPage] = useState(initialPage);
30
+ const [loading, setLoading] = useState(false);
31
+ const labels = useMemo(
32
+ () => [...new Set(initialPage.items.flatMap((entry) => entry.labels))],
33
+ [initialPage.items],
34
+ );
35
+
36
+ async function replaceForLabel(label?: string) {
37
+ setSelectedLabel(label);
38
+ setLoading(true);
39
+ try {
40
+ setPage(await loadPage(label ? { label } : {}));
41
+ } finally {
42
+ setLoading(false);
43
+ }
44
+ }
45
+
46
+ async function loadMore() {
47
+ if (!page.nextCursor) return;
48
+ setLoading(true);
49
+ try {
50
+ const next = await loadPage({
51
+ cursor: page.nextCursor,
52
+ ...(selectedLabel ? { label: selectedLabel } : {}),
53
+ });
54
+ setPage({
55
+ items: [...page.items, ...next.items],
56
+ ...(next.nextCursor ? { nextCursor: next.nextCursor } : {}),
57
+ });
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ }
62
+
63
+ useEffect(() => {
64
+ const slug = locationHash.startsWith("#")
65
+ ? locationHash.slice(1)
66
+ : locationHash;
67
+ if (!slug) return;
68
+ const target = document.getElementById(slug);
69
+ if (target) {
70
+ target.scrollIntoView?.({ block: "start" });
71
+ } else if (page.nextCursor && !loading && !selectedLabel) {
72
+ void loadMore();
73
+ }
74
+ }, [
75
+ locationHash,
76
+ loading,
77
+ page.items.length,
78
+ page.nextCursor,
79
+ selectedLabel,
80
+ ]);
81
+
82
+ return (
83
+ <main className="changelog-page">
84
+ <header className="changelog-heading">
85
+ <div>
86
+ <h1>Changelog</h1>
87
+ <p>Product updates, fixes, and improvements.</p>
88
+ </div>
89
+ </header>
90
+ <div className="changelog-toolbar">
91
+ <ButtonGroup className="changelog-filters" aria-label="Filter updates">
92
+ {[undefined, ...labels].map((label) => (
93
+ <Button
94
+ className="h-11 md:h-8"
95
+ key={label ?? "all"}
96
+ type="button"
97
+ variant="outline"
98
+ aria-pressed={selectedLabel === label}
99
+ disabled={loading}
100
+ onClick={() => void replaceForLabel(label)}
101
+ >
102
+ {label ?? "All"}
103
+ </Button>
104
+ ))}
105
+ </ButtonGroup>
106
+ <CommandPaletteTrigger className="h-11 md:h-10" />
107
+ </div>
108
+ <ol className="changelog-timeline" aria-label="Product updates">
109
+ {page.items.map((entry) => (
110
+ <TimelineEntry entry={entry} key={entry.slug} />
111
+ ))}
112
+ </ol>
113
+ {page.nextCursor ? (
114
+ <div className="changelog-load-more">
115
+ <Button
116
+ className="h-11 min-w-64 gap-2 text-sm md:h-10"
117
+ type="button"
118
+ variant="outline"
119
+ disabled={loading}
120
+ onClick={() => void loadMore()}
121
+ >
122
+ Load more updates
123
+ <ChevronDown aria-hidden="true" />
124
+ </Button>
125
+ </div>
126
+ ) : null}
127
+ </main>
128
+ );
129
+ }
130
+
131
+ function TimelineEntry({ entry }: { entry: PublicChangelogEntry }) {
132
+ const image = useUnexpiredImage(entry.image);
133
+ return (
134
+ <li className="changelog-timeline-entry">
135
+ <time dateTime={entry.date}>{formatDate(entry.date)}</time>
136
+ <span className="changelog-timeline-marker" aria-hidden="true" />
137
+ <article id={entry.slug} tabIndex={-1}>
138
+ <div className={cn("changelog-entry-layout", image && "has-image")}>
139
+ <div className="changelog-entry-copy">
140
+ <div className="changelog-labels" aria-label="Labels">
141
+ {entry.labels.map((label) => (
142
+ <Label key={label} label={label} />
143
+ ))}
144
+ </div>
145
+ <div className="changelog-entry-title">
146
+ <h2>{entry.title}</h2>
147
+ <a href={`#${entry.slug}`} aria-label={`Link to ${entry.title}`}>
148
+ <Link2 aria-hidden="true" />
149
+ </a>
150
+ </div>
151
+ <p className="changelog-entry-summary">{entry.summary}</p>
152
+ <div className="changelog-entry-body">
153
+ <p>{entry.body}</p>
154
+ </div>
155
+ </div>
156
+ {image ? (
157
+ <img src={image.src} alt={image.alt} width={760} height={420} />
158
+ ) : null}
159
+ </div>
160
+ </article>
161
+ </li>
162
+ );
163
+ }
164
+
165
+ function useUnexpiredImage(image: PublicChangelogEntry["image"]) {
166
+ const expiry = image?.expiresAt
167
+ ? new Date(image.expiresAt).getTime()
168
+ : undefined;
169
+ const [available, setAvailable] = useState(
170
+ () => expiry === undefined || expiry > Date.now(),
171
+ );
172
+
173
+ useEffect(() => {
174
+ if (!image || expiry === undefined) {
175
+ setAvailable(Boolean(image));
176
+ return;
177
+ }
178
+ const remaining = expiry - Date.now();
179
+ if (remaining <= 0) {
180
+ setAvailable(false);
181
+ return;
182
+ }
183
+ setAvailable(true);
184
+ const timeout = window.setTimeout(() => setAvailable(false), remaining);
185
+ return () => window.clearTimeout(timeout);
186
+ }, [expiry, image]);
187
+
188
+ return available && (expiry === undefined || expiry > Date.now())
189
+ ? image
190
+ : undefined;
191
+ }
192
+
193
+ function Label({ label }: { label: string }) {
194
+ const tone = label.toLowerCase();
195
+ return (
196
+ <Badge
197
+ className={cn(
198
+ "changelog-label",
199
+ tone === "new feature" && "is-new",
200
+ tone === "improved" && "is-improved",
201
+ tone === "fixed" && "is-fixed",
202
+ )}
203
+ variant="outline"
204
+ >
205
+ <span aria-hidden="true" />
206
+ {label}
207
+ </Badge>
208
+ );
209
+ }
210
+
211
+ function formatDate(date: string) {
212
+ const [year, month, day] = date.split("-").map(Number);
213
+ return new Intl.DateTimeFormat("en-US", {
214
+ day: "numeric",
215
+ month: "short",
216
+ year: "numeric",
217
+ timeZone: "UTC",
218
+ }).format(new Date(Date.UTC(year, month - 1, day)));
219
+ }
@@ -0,0 +1,13 @@
1
+ import type { ChangelogPage } from "@feedbax/changelog";
2
+ import { queryOptions } from "@tanstack/react-query";
3
+
4
+ export type FetchChangelogPage = (input?: {
5
+ data?: { cursor?: string; label?: string };
6
+ }) => Promise<ChangelogPage>;
7
+
8
+ export function changelogQuery(fetchPage: FetchChangelogPage) {
9
+ return queryOptions({
10
+ queryKey: ["public-changelog"],
11
+ queryFn: () => fetchPage(),
12
+ });
13
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ createChangelogModule,
3
+ createNotionChangelogStorage,
4
+ } from "@feedbax/changelog";
5
+
6
+ import feedbax from "./feedbax";
7
+ import { requiredEnvironmentValue } from "./feedback-runtime";
8
+
9
+ let configuredChangelog: ReturnType<typeof createChangelogModule> | undefined;
10
+
11
+ export function createConfiguredChangelogModule() {
12
+ configuredChangelog ??= buildConfiguredChangelogModule();
13
+ return configuredChangelog;
14
+ }
15
+
16
+ function buildConfiguredChangelogModule() {
17
+ if (!feedbax.changelog) {
18
+ throw new Error("Changelog configuration is missing.");
19
+ }
20
+ return createChangelogModule({
21
+ storage: createNotionChangelogStorage({
22
+ token: requiredEnvironmentValue("NOTION_TOKEN"),
23
+ dataSourceId: feedbax.changelog.dataSourceId,
24
+ propertyIds: feedbax.changelog.properties,
25
+ }),
26
+ });
27
+ }
@@ -0,0 +1,31 @@
1
+ export interface TurnstileVerifier {
2
+ verify(options: { token: string; remoteIp?: string }): Promise<boolean>;
3
+ }
4
+
5
+ export function createCloudflareTurnstileVerifier({
6
+ secretKey,
7
+ request = fetch,
8
+ }: {
9
+ secretKey: string;
10
+ request?: typeof fetch;
11
+ }): TurnstileVerifier {
12
+ return {
13
+ async verify({ token, remoteIp }) {
14
+ try {
15
+ const body = new URLSearchParams({
16
+ secret: secretKey,
17
+ response: token,
18
+ ...(remoteIp ? { remoteip: remoteIp } : {}),
19
+ });
20
+ const response = await request(
21
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
22
+ { method: "POST", body },
23
+ );
24
+ const result = (await response.json()) as { success?: unknown };
25
+ return response.ok && result.success === true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,3 @@
1
+ export const cache = {
2
+ delete: async () => false,
3
+ };
@@ -0,0 +1,18 @@
1
+ declare module "cloudflare:workers" {
2
+ export const cache: {
3
+ purge(options: { tags: string[] }): Promise<{
4
+ success: boolean;
5
+ errors: Array<{ code: number; message: string }>;
6
+ }>;
7
+ };
8
+ export const env: {
9
+ TRUSTED_SUBMISSIONS_RATE_LIMITER: {
10
+ limit(options: { key: string }): Promise<{ success: boolean }>;
11
+ };
12
+ PORTAL_SUBMISSIONS_RATE_LIMITER: {
13
+ limit(options: { key: string }): Promise<{ success: boolean }>;
14
+ };
15
+ TURNSTILE_SECRET_KEY?: string;
16
+ PARTICIPATION_SIGNING_SECRET?: string;
17
+ };
18
+ }