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,204 @@
1
+ import type { DraftPost } from "@feedbax/feedback";
2
+ import { MoreHorizontal } from "lucide-react";
3
+ import { useEffect, useState } from "react";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
6
+ import {
7
+ AlertDialog,
8
+ AlertDialogAction,
9
+ AlertDialogCancel,
10
+ AlertDialogContent,
11
+ AlertDialogDescription,
12
+ AlertDialogFooter,
13
+ AlertDialogHeader,
14
+ AlertDialogTitle,
15
+ } from "@feedbax/ui/components/alert-dialog";
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuTrigger,
21
+ } from "@feedbax/ui/components/dropdown-menu";
22
+ import { Badge } from "@feedbax/ui/components/badge";
23
+ import { Button } from "@feedbax/ui/components/button";
24
+ import { toast } from "sonner";
25
+
26
+ import {
27
+ PortalFeedbackForm,
28
+ type PortalFeedbackMutations,
29
+ } from "./portal-feedback-form";
30
+ import { readCapabilities, removeCapability } from "./browser-post-state";
31
+ import {
32
+ editPortalFeedbackDraft,
33
+ getPortalDraftPost,
34
+ submitPortalPost,
35
+ withdrawPortalFeedbackDraft,
36
+ } from "./portal-feedback-server-function";
37
+ import { PublicPostUnavailable } from "./public-post-unavailable";
38
+ import { isCapabilityAuthorizationFailure } from "./capability-authorization-error";
39
+ import { PublicPostDetail } from "./public-post-detail";
40
+
41
+ export function AuthorizedDraftPost({ slug }: { slug: string }) {
42
+ const [post, setPost] = useState<DraftPost | null>();
43
+ const [editing, setEditing] = useState(false);
44
+ const [confirmingWithdrawal, setConfirmingWithdrawal] = useState(false);
45
+ const [withdrawalError, setWithdrawalError] = useState<string>();
46
+ const queryClient = useQueryClient();
47
+ const router = useRouter();
48
+ const navigate = useNavigate();
49
+ const overlaid = useRouterState({
50
+ select: (state) => Boolean(state.location.state.postDetailOverlay),
51
+ });
52
+ const capability = Object.values(readCapabilities(localStorage)).find(
53
+ (item) => item.slug === slug,
54
+ );
55
+
56
+ useEffect(() => {
57
+ setPost(undefined);
58
+ if (!capability) {
59
+ setPost(null);
60
+ return;
61
+ }
62
+ getPortalDraftPost({ data: capability })
63
+ .then(setPost)
64
+ .catch((error: unknown) => {
65
+ if (isCapabilityAuthorizationFailure(error)) {
66
+ removeCapability(localStorage, capability.id);
67
+ }
68
+ setPost(null);
69
+ });
70
+ }, [capability?.browserCapability, capability?.id, slug]);
71
+
72
+ if (post === undefined)
73
+ return (
74
+ <main className="feedback-detail">
75
+ <p>Loading Draft Post…</p>
76
+ </main>
77
+ );
78
+ if (post === null || !capability) return <PublicPostUnavailable />;
79
+
80
+ const updateVisibleDraft = (
81
+ edited: Awaited<ReturnType<PortalFeedbackMutations["editDraftPost"]>>,
82
+ ) => {
83
+ const next = { ...post, ...edited, slug: post.slug };
84
+ setPost(next);
85
+ setEditing(false);
86
+ queryClient.setQueriesData<DraftPost[]>(
87
+ { queryKey: ["authorized-draft-posts"] },
88
+ (current) => current?.map((item) => (item.id === next.id ? next : item)),
89
+ );
90
+ toast.success("Draft updated.");
91
+ };
92
+
93
+ const withdraw = async () => {
94
+ setWithdrawalError(undefined);
95
+ try {
96
+ await withdrawPortalFeedbackDraft({ data: capability });
97
+ removeCapability(localStorage, post.id);
98
+ queryClient.setQueriesData<DraftPost[]>(
99
+ { queryKey: ["authorized-draft-posts"] },
100
+ (current) => current?.filter((item) => item.id !== post.id),
101
+ );
102
+ await queryClient.invalidateQueries({ queryKey: ["public-posts"] });
103
+ if (overlaid) router.history.back();
104
+ else await navigate({ to: "/" });
105
+ } catch (error) {
106
+ if (isCapabilityAuthorizationFailure(error)) {
107
+ removeCapability(localStorage, post.id);
108
+ setPost(null);
109
+ } else {
110
+ setWithdrawalError(
111
+ "The draft could not be withdrawn. Please try again.",
112
+ );
113
+ }
114
+ } finally {
115
+ setConfirmingWithdrawal(false);
116
+ }
117
+ };
118
+
119
+ return (
120
+ <>
121
+ {editing ? (
122
+ <main className="feedback-detail">
123
+ <PortalFeedbackForm
124
+ display="overlay"
125
+ onCancel={() => setEditing(false)}
126
+ onEdited={updateVisibleDraft}
127
+ onAuthorizationLost={() => {
128
+ removeCapability(localStorage, post.id);
129
+ setPost(null);
130
+ }}
131
+ showWithdrawal={false}
132
+ mutations={{
133
+ submitPost: submitPortalPost,
134
+ editDraftPost: editPortalFeedbackDraft,
135
+ withdrawDraftPost: withdrawPortalFeedbackDraft,
136
+ }}
137
+ initialDraft={{
138
+ id: post.id,
139
+ browserCapability: capability.browserCapability,
140
+ title: post.title,
141
+ description: post.description,
142
+ type: post.type,
143
+ }}
144
+ />
145
+ </main>
146
+ ) : (
147
+ <PublicPostDetail
148
+ post={post}
149
+ display={overlaid ? "overlay" : "page"}
150
+ features={{ voting: false, comments: false, changelog: true }}
151
+ summaryMarker={<Badge variant="outline">Draft</Badge>}
152
+ summaryActions={
153
+ <>
154
+ <div className="draft-detail-actions">
155
+ <Button type="button" onClick={() => setEditing(true)}>
156
+ Edit
157
+ </Button>
158
+ <DropdownMenu>
159
+ <DropdownMenuTrigger
160
+ aria-label="Draft actions"
161
+ render={
162
+ <Button type="button" variant="ghost" size="icon" />
163
+ }
164
+ >
165
+ <MoreHorizontal aria-hidden="true" />
166
+ </DropdownMenuTrigger>
167
+ <DropdownMenuContent align="end">
168
+ <DropdownMenuItem
169
+ onClick={() => setConfirmingWithdrawal(true)}
170
+ >
171
+ Withdraw draft
172
+ </DropdownMenuItem>
173
+ </DropdownMenuContent>
174
+ </DropdownMenu>
175
+ </div>
176
+ {withdrawalError ? <p role="alert">{withdrawalError}</p> : null}
177
+ </>
178
+ }
179
+ />
180
+ )}
181
+ <AlertDialog
182
+ open={confirmingWithdrawal}
183
+ onOpenChange={setConfirmingWithdrawal}
184
+ >
185
+ <AlertDialogContent>
186
+ <AlertDialogHeader>
187
+ <AlertDialogTitle>
188
+ Withdraw this Draft Post permanently?
189
+ </AlertDialogTitle>
190
+ <AlertDialogDescription>
191
+ This cannot be undone.
192
+ </AlertDialogDescription>
193
+ </AlertDialogHeader>
194
+ <AlertDialogFooter>
195
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
196
+ <AlertDialogAction onClick={withdraw}>
197
+ Withdraw Draft Post
198
+ </AlertDialogAction>
199
+ </AlertDialogFooter>
200
+ </AlertDialogContent>
201
+ </AlertDialog>
202
+ </>
203
+ );
204
+ }
@@ -0,0 +1,74 @@
1
+ import { queryOptions } from "@tanstack/react-query";
2
+ import type { DraftPost } from "@feedbax/feedback";
3
+
4
+ import {
5
+ removeCapability,
6
+ type StoredPostCapability,
7
+ } from "./browser-post-state";
8
+
9
+ type FetchAuthorizedDraft = (input: {
10
+ data: StoredPostCapability;
11
+ }) => Promise<DraftPost>;
12
+
13
+ type CapabilityStorage = Pick<Storage, "getItem" | "setItem">;
14
+
15
+ export const authorizedDraftPostsQueryRoot = [
16
+ "authorized-draft-posts",
17
+ ] as const;
18
+
19
+ export function authorizedDraftPostsQueryKey(
20
+ capabilities: Record<string, StoredPostCapability>,
21
+ ) {
22
+ return [
23
+ ...authorizedDraftPostsQueryRoot,
24
+ Object.keys(capabilities).sort(),
25
+ ] as const;
26
+ }
27
+
28
+ export function authorizedDraftPostsQuery(
29
+ capabilities: Record<string, StoredPostCapability>,
30
+ fetchDraft: FetchAuthorizedDraft,
31
+ storage?: CapabilityStorage,
32
+ ) {
33
+ const entries = Object.values(capabilities);
34
+ return queryOptions({
35
+ queryKey: authorizedDraftPostsQueryKey(capabilities),
36
+ queryFn: async () => {
37
+ const results = await Promise.allSettled(
38
+ entries.map(async (capability) => ({
39
+ post: await fetchDraft({ data: capability }),
40
+ })),
41
+ );
42
+ const drafts: DraftPost[] = [];
43
+ let transientFailure: unknown;
44
+ for (const [index, result] of results.entries()) {
45
+ if (result.status === "fulfilled") {
46
+ drafts.push(result.value.post);
47
+ continue;
48
+ }
49
+ const capability = entries[index];
50
+ if (storage && capability && isAuthorizationFailure(result.reason)) {
51
+ removeCapability(storage, capability.id);
52
+ } else {
53
+ transientFailure = result.reason;
54
+ }
55
+ }
56
+ if (transientFailure) throw transientFailure;
57
+ return drafts.sort(
58
+ (left, right) =>
59
+ new Date(right.updatedAt).getTime() -
60
+ new Date(left.updatedAt).getTime() ||
61
+ right.id.localeCompare(left.id),
62
+ );
63
+ },
64
+ staleTime: 30_000,
65
+ });
66
+ }
67
+
68
+ function isAuthorizationFailure(error: unknown): boolean {
69
+ return (
70
+ error instanceof Error &&
71
+ (error.name === "BrowserCapabilityAuthorizationError" ||
72
+ error.message.includes("did not authorize"))
73
+ );
74
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ capabilitiesKey,
5
+ clearDeviceProfile,
6
+ deriveDeviceProfileInitials,
7
+ deviceProfileKey,
8
+ isCompleteDeviceProfile,
9
+ readCapabilities,
10
+ readDeviceProfile,
11
+ retainCapability,
12
+ saveDeviceProfile,
13
+ } from "./browser-post-state";
14
+
15
+ describe("device-local Participant state", () => {
16
+ it("persists a valid Device Profile and derives its initials", () => {
17
+ const storage = memoryStorage();
18
+
19
+ saveDeviceProfile(storage, {
20
+ name: " Ama Mensah ",
21
+ email: " ama@example.com ",
22
+ });
23
+
24
+ expect(readDeviceProfile(storage)).toEqual({
25
+ name: "Ama Mensah",
26
+ email: "ama@example.com",
27
+ });
28
+ expect(deriveDeviceProfileInitials(readDeviceProfile(storage)!)).toBe("AM");
29
+ expect(isCompleteDeviceProfile(readDeviceProfile(storage))).toBe(true);
30
+ });
31
+
32
+ it("preserves a legacy name-only profile but treats it as incomplete", () => {
33
+ const storage = memoryStorage({
34
+ [deviceProfileKey]: JSON.stringify({ name: "Ama" }),
35
+ });
36
+
37
+ expect(readDeviceProfile(storage)).toEqual({ name: "Ama" });
38
+ expect(isCompleteDeviceProfile(readDeviceProfile(storage))).toBe(false);
39
+ });
40
+
41
+ it("treats malformed email addresses as incomplete", () => {
42
+ const storage = memoryStorage({
43
+ [deviceProfileKey]: JSON.stringify({
44
+ name: "Ama",
45
+ email: "not-an-email",
46
+ }),
47
+ });
48
+
49
+ expect(isCompleteDeviceProfile(readDeviceProfile(storage))).toBe(false);
50
+ });
51
+
52
+ it("rejects malformed saved profiles", () => {
53
+ const malformedProfiles = [
54
+ null,
55
+ {},
56
+ { name: "" },
57
+ { name: "Ama", email: 42 },
58
+ { name: "Ama", extra: "unexpected" },
59
+ ];
60
+
61
+ for (const profile of malformedProfiles) {
62
+ const storage = memoryStorage({
63
+ [deviceProfileKey]: JSON.stringify(profile),
64
+ });
65
+ expect(readDeviceProfile(storage)).toBeUndefined();
66
+ }
67
+ });
68
+
69
+ it("clears only the Device Profile and preserves Browser Capabilities", () => {
70
+ const capability = {
71
+ id: "post-id",
72
+ slug: "keyboard-navigation",
73
+ browserCapability: "secret-capability",
74
+ };
75
+ const storage = memoryStorage({
76
+ [deviceProfileKey]: JSON.stringify({ name: "Ama" }),
77
+ [capabilitiesKey]: JSON.stringify({ [capability.id]: capability }),
78
+ });
79
+
80
+ clearDeviceProfile(storage);
81
+
82
+ expect(readDeviceProfile(storage)).toBeUndefined();
83
+ expect(readCapabilities(storage)).toEqual({ [capability.id]: capability });
84
+ });
85
+
86
+ it("retains several valid Browser Capabilities and ignores malformed saved entries", () => {
87
+ const storage = memoryStorage({
88
+ [capabilitiesKey]: JSON.stringify({
89
+ malformed: { id: "malformed", slug: "missing-secret" },
90
+ }),
91
+ });
92
+ const first = {
93
+ id: "post-1",
94
+ slug: "keyboard-navigation",
95
+ browserCapability: "first-secret",
96
+ };
97
+ const second = {
98
+ id: "post-2",
99
+ slug: "dark-mode",
100
+ browserCapability: "second-secret",
101
+ };
102
+
103
+ retainCapability(storage, first);
104
+ retainCapability(storage, second);
105
+
106
+ expect(readCapabilities(storage)).toEqual({
107
+ [first.id]: first,
108
+ [second.id]: second,
109
+ });
110
+ });
111
+ });
112
+
113
+ function memoryStorage(initial: Record<string, string> = {}) {
114
+ const values = new Map(Object.entries(initial));
115
+ return {
116
+ getItem: (key: string) => values.get(key) ?? null,
117
+ setItem: (key: string, value: string) => values.set(key, value),
118
+ removeItem: (key: string) => values.delete(key),
119
+ };
120
+ }
@@ -0,0 +1,134 @@
1
+ import { isValidCommentEmail } from "./comment-profile";
2
+
3
+ export const capabilitiesKey = "feedbax:post-capabilities";
4
+ export const deviceProfileKey = "feedbax:device-profile";
5
+ export interface DeviceProfile {
6
+ name: string;
7
+ email?: string;
8
+ }
9
+
10
+ export interface CompleteDeviceProfile extends DeviceProfile {
11
+ email: string;
12
+ }
13
+
14
+ type DeviceProfileStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
15
+ export interface StoredPostCapability {
16
+ id: string;
17
+ slug: string;
18
+ browserCapability: string;
19
+ }
20
+
21
+ export function readDeviceProfile(
22
+ storage: Pick<Storage, "getItem">,
23
+ ): DeviceProfile | undefined {
24
+ try {
25
+ const value = JSON.parse(
26
+ storage.getItem(deviceProfileKey) ?? "null",
27
+ ) as unknown;
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
29
+ return undefined;
30
+ }
31
+ const entries = Object.keys(value);
32
+ const name = Reflect.get(value, "name");
33
+ const email = Reflect.get(value, "email");
34
+ if (
35
+ entries.some((key) => key !== "name" && key !== "email") ||
36
+ typeof name !== "string" ||
37
+ name.trim().length === 0 ||
38
+ (email !== undefined && typeof email !== "string")
39
+ ) {
40
+ return undefined;
41
+ }
42
+ return {
43
+ name: name.trim(),
44
+ ...(typeof email === "string" && email.trim()
45
+ ? { email: email.trim() }
46
+ : {}),
47
+ };
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ export function saveDeviceProfile(
54
+ storage: Pick<DeviceProfileStorage, "setItem">,
55
+ profile: DeviceProfile,
56
+ ) {
57
+ const name = profile.name.trim();
58
+ if (!name) throw new Error("Device Profile display name is required.");
59
+ const email = profile.email?.trim();
60
+ storage.setItem(
61
+ deviceProfileKey,
62
+ JSON.stringify({ name, ...(email ? { email } : {}) }),
63
+ );
64
+ }
65
+
66
+ export function isCompleteDeviceProfile(
67
+ profile: DeviceProfile | undefined,
68
+ ): profile is CompleteDeviceProfile {
69
+ return Boolean(profile?.name.trim() && isValidCommentEmail(profile.email));
70
+ }
71
+
72
+ export function clearDeviceProfile(
73
+ storage: Pick<DeviceProfileStorage, "removeItem">,
74
+ ) {
75
+ storage.removeItem(deviceProfileKey);
76
+ }
77
+
78
+ export function deriveDeviceProfileInitials(profile: DeviceProfile): string {
79
+ return profile.name
80
+ .trim()
81
+ .split(/\s+/)
82
+ .slice(0, 2)
83
+ .map((part) => part[0]?.toLocaleUpperCase())
84
+ .join("");
85
+ }
86
+ export function readCapabilities(
87
+ storage: Pick<Storage, "getItem">,
88
+ ): Record<string, StoredPostCapability> {
89
+ try {
90
+ const value = JSON.parse(
91
+ storage.getItem(capabilitiesKey) ?? "{}",
92
+ ) as unknown;
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
94
+ return Object.fromEntries(
95
+ Object.entries(value).filter(
96
+ (entry): entry is [string, StoredPostCapability] => {
97
+ const [key, capability] = entry;
98
+ return (
99
+ capability !== null &&
100
+ typeof capability === "object" &&
101
+ !Array.isArray(capability) &&
102
+ Reflect.get(capability, "id") === key &&
103
+ typeof Reflect.get(capability, "slug") === "string" &&
104
+ Reflect.get(capability, "slug").length > 0 &&
105
+ typeof Reflect.get(capability, "browserCapability") === "string" &&
106
+ Reflect.get(capability, "browserCapability").length > 0
107
+ );
108
+ },
109
+ ),
110
+ );
111
+ } catch {
112
+ return {};
113
+ }
114
+ }
115
+ export function retainCapability(
116
+ storage: Pick<Storage, "getItem" | "setItem">,
117
+ capability: StoredPostCapability,
118
+ ) {
119
+ storage.setItem(
120
+ capabilitiesKey,
121
+ JSON.stringify({
122
+ ...readCapabilities(storage),
123
+ [capability.id]: capability,
124
+ }),
125
+ );
126
+ }
127
+ export function removeCapability(
128
+ storage: Pick<Storage, "getItem" | "setItem">,
129
+ id: string,
130
+ ) {
131
+ const capabilities = readCapabilities(storage);
132
+ delete capabilities[id];
133
+ storage.setItem(capabilitiesKey, JSON.stringify(capabilities));
134
+ }
@@ -0,0 +1,14 @@
1
+ import { expect, it } from "vitest";
2
+ import { persistVotedPost, readVotedPostSlugs } from "./browser-vote-state";
3
+
4
+ it("remembers and removes voted Post identifiers in browser storage", () => {
5
+ const values = new Map<string, string>();
6
+ const storage = {
7
+ getItem: (key: string) => values.get(key) ?? null,
8
+ setItem: (key: string, value: string) => void values.set(key, value),
9
+ };
10
+ persistVotedPost(storage, "keyboard-navigation", true);
11
+ expect(readVotedPostSlugs(storage)).toEqual(new Set(["keyboard-navigation"]));
12
+ persistVotedPost(storage, "keyboard-navigation", false);
13
+ expect(readVotedPostSlugs(storage)).toEqual(new Set());
14
+ });
@@ -0,0 +1,27 @@
1
+ const votedPostsKey = "feedbax:voted-posts";
2
+
3
+ export function readVotedPostSlugs(
4
+ storage: Pick<Storage, "getItem">,
5
+ ): Set<string> {
6
+ try {
7
+ const value: unknown = JSON.parse(storage.getItem(votedPostsKey) ?? "[]");
8
+ return new Set(
9
+ Array.isArray(value)
10
+ ? value.filter((item): item is string => typeof item === "string")
11
+ : [],
12
+ );
13
+ } catch {
14
+ return new Set();
15
+ }
16
+ }
17
+
18
+ export function persistVotedPost(
19
+ storage: Pick<Storage, "getItem" | "setItem">,
20
+ slug: string,
21
+ voted: boolean,
22
+ ): void {
23
+ const slugs = readVotedPostSlugs(storage);
24
+ if (voted) slugs.add(slug);
25
+ else slugs.delete(slug);
26
+ storage.setItem(votedPostsKey, JSON.stringify([...slugs]));
27
+ }
@@ -0,0 +1,3 @@
1
+ export function isCapabilityAuthorizationFailure(error: unknown) {
2
+ return error instanceof Error && error.message.includes("did not authorize");
3
+ }