convex-feedback-ui 0.1.9 → 0.2.0-beta.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 (128) hide show
  1. package/README.md +19 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/FeedbackRoutes.d.ts.map +1 -1
  4. package/dist/native/expo/FeedbackRoutes.js +12 -6
  5. package/dist/native/expo/FeedbackRoutes.js.map +1 -1
  6. package/dist/native/expo/FeedbackScreen.d.ts +1 -1
  7. package/dist/native/expo/FeedbackScreen.d.ts.map +1 -1
  8. package/dist/native/expo/FeedbackScreen.js +2 -2
  9. package/dist/native/expo/FeedbackScreen.js.map +1 -1
  10. package/dist/native/expo/FeedbackStack.d.ts.map +1 -1
  11. package/dist/native/expo/FeedbackStack.js +9 -4
  12. package/dist/native/expo/FeedbackStack.js.map +1 -1
  13. package/dist/native/expo/FeedbackStackLayout.d.ts +1 -1
  14. package/dist/native/expo/FeedbackStackLayout.d.ts.map +1 -1
  15. package/dist/native/expo/FeedbackStackLayout.js +2 -2
  16. package/dist/native/expo/FeedbackStackLayout.js.map +1 -1
  17. package/dist/native/expo/index.d.ts +1 -0
  18. package/dist/native/expo/index.d.ts.map +1 -1
  19. package/dist/native/expo/index.js +1 -0
  20. package/dist/native/expo/index.js.map +1 -1
  21. package/dist/native/native/FeedbackScreen.d.ts +1 -1
  22. package/dist/native/native/FeedbackScreen.d.ts.map +1 -1
  23. package/dist/native/native/FeedbackScreen.js +2 -2
  24. package/dist/native/native/FeedbackScreen.js.map +1 -1
  25. package/dist/native/native/index.d.ts +1 -0
  26. package/dist/native/native/index.d.ts.map +1 -1
  27. package/dist/native/native/index.js +1 -0
  28. package/dist/native/native/index.js.map +1 -1
  29. package/dist/native/shared/helpers.d.ts +6 -1
  30. package/dist/native/shared/helpers.d.ts.map +1 -1
  31. package/dist/native/shared/helpers.js +33 -0
  32. package/dist/native/shared/helpers.js.map +1 -1
  33. package/dist/native/shared/ui/BodyContent.d.ts.map +1 -1
  34. package/dist/native/shared/ui/BodyContent.js +8 -2
  35. package/dist/native/shared/ui/BodyContent.js.map +1 -1
  36. package/dist/native/shared/ui/Button.d.ts +2 -1
  37. package/dist/native/shared/ui/Button.d.ts.map +1 -1
  38. package/dist/native/shared/ui/Button.js +3 -2
  39. package/dist/native/shared/ui/Button.js.map +1 -1
  40. package/dist/native/shared/ui/CommentBranch.d.ts.map +1 -1
  41. package/dist/native/shared/ui/CommentBranch.js +33 -11
  42. package/dist/native/shared/ui/CommentBranch.js.map +1 -1
  43. package/dist/native/shared/ui/EntryCard.d.ts.map +1 -1
  44. package/dist/native/shared/ui/EntryCard.js +13 -1
  45. package/dist/native/shared/ui/EntryCard.js.map +1 -1
  46. package/dist/native/shared/ui/EntryDetail.d.ts.map +1 -1
  47. package/dist/native/shared/ui/EntryDetail.js +23 -5
  48. package/dist/native/shared/ui/EntryDetail.js.map +1 -1
  49. package/dist/native/shared/ui/FeedbackScreenHeader.d.ts.map +1 -1
  50. package/dist/native/shared/ui/FeedbackScreenHeader.js +7 -2
  51. package/dist/native/shared/ui/FeedbackScreenHeader.js.map +1 -1
  52. package/dist/native/shared/ui/FeedbackScreenList.d.ts.map +1 -1
  53. package/dist/native/shared/ui/FeedbackScreenList.js +16 -8
  54. package/dist/native/shared/ui/FeedbackScreenList.js.map +1 -1
  55. package/dist/native/shared/ui/NewEntry.d.ts.map +1 -1
  56. package/dist/native/shared/ui/NewEntry.js +13 -11
  57. package/dist/native/shared/ui/NewEntry.js.map +1 -1
  58. package/dist/native/shared/ui/RoadmapScreen.d.ts +3 -0
  59. package/dist/native/shared/ui/RoadmapScreen.d.ts.map +1 -0
  60. package/dist/native/shared/ui/RoadmapScreen.js +73 -0
  61. package/dist/native/shared/ui/RoadmapScreen.js.map +1 -0
  62. package/dist/react/action.d.ts +14 -0
  63. package/dist/react/action.d.ts.map +1 -0
  64. package/dist/react/action.js +38 -0
  65. package/dist/react/action.js.map +1 -0
  66. package/dist/react/index.d.ts +1 -0
  67. package/dist/react/index.d.ts.map +1 -1
  68. package/dist/react/index.js +1 -0
  69. package/dist/react/index.js.map +1 -1
  70. package/dist/react/roadmap.d.ts +3 -0
  71. package/dist/react/roadmap.d.ts.map +1 -0
  72. package/dist/react/roadmap.js +59 -0
  73. package/dist/react/roadmap.js.map +1 -0
  74. package/dist/react/screen.d.ts +1 -1
  75. package/dist/react/screen.d.ts.map +1 -1
  76. package/dist/react/screen.js +99 -42
  77. package/dist/react/screen.js.map +1 -1
  78. package/dist/react/styles.css +42 -0
  79. package/dist/shared/context/FeedbackBodyProvider.d.ts +1 -1
  80. package/dist/shared/context/FeedbackBodyProvider.d.ts.map +1 -1
  81. package/dist/shared/context/FeedbackBodyProvider.js +8 -2
  82. package/dist/shared/context/FeedbackBodyProvider.js.map +1 -1
  83. package/dist/shared/helpers.d.ts +6 -0
  84. package/dist/shared/helpers.d.ts.map +1 -1
  85. package/dist/shared/helpers.js +12 -0
  86. package/dist/shared/helpers.js.map +1 -1
  87. package/dist/shared/types/index.d.ts +1 -0
  88. package/dist/shared/types/index.d.ts.map +1 -1
  89. package/dist/shared/types/messages.d.ts +1 -1
  90. package/dist/shared/types/messages.d.ts.map +1 -1
  91. package/dist/shared/types/roadmap.d.ts +17 -0
  92. package/dist/shared/types/roadmap.d.ts.map +1 -0
  93. package/dist/shared/types/roadmap.js +2 -0
  94. package/dist/shared/types/roadmap.js.map +1 -0
  95. package/dist/shared/types/screen.d.ts +10 -1
  96. package/dist/shared/types/screen.d.ts.map +1 -1
  97. package/package.json +2 -2
  98. package/src/native/expo/FeedbackRoutes.tsx +20 -5
  99. package/src/native/expo/FeedbackScreen.tsx +2 -0
  100. package/src/native/expo/FeedbackStack.tsx +17 -3
  101. package/src/native/expo/FeedbackStackLayout.tsx +2 -0
  102. package/src/native/expo/README.md +4 -0
  103. package/src/native/expo/index.ts +1 -0
  104. package/src/native/native/FeedbackScreen.tsx +2 -0
  105. package/src/native/native/README.md +4 -0
  106. package/src/native/native/index.ts +1 -0
  107. package/src/native/shared/helpers.ts +38 -1
  108. package/src/native/shared/ui/BodyContent.tsx +15 -2
  109. package/src/native/shared/ui/Button.tsx +4 -0
  110. package/src/native/shared/ui/CommentBranch.tsx +58 -15
  111. package/src/native/shared/ui/EntryCard.tsx +21 -3
  112. package/src/native/shared/ui/EntryDetail.tsx +42 -8
  113. package/src/native/shared/ui/FeedbackScreenHeader.tsx +9 -1
  114. package/src/native/shared/ui/FeedbackScreenList.tsx +28 -13
  115. package/src/native/shared/ui/NewEntry.tsx +25 -12
  116. package/src/native/shared/ui/RoadmapScreen.tsx +345 -0
  117. package/src/react/README.md +1 -1
  118. package/src/react/action.tsx +56 -0
  119. package/src/react/index.ts +1 -0
  120. package/src/react/roadmap.tsx +303 -0
  121. package/src/react/screen.tsx +199 -75
  122. package/src/react/styles.css +42 -0
  123. package/src/shared/context/FeedbackBodyProvider.tsx +9 -1
  124. package/src/shared/helpers.ts +16 -0
  125. package/src/shared/types/index.ts +1 -0
  126. package/src/shared/types/messages.ts +1 -1
  127. package/src/shared/types/roadmap.ts +25 -0
  128. package/src/shared/types/screen.ts +12 -1
@@ -3,18 +3,26 @@ import { ActivityIndicator, Text, View } from "react-native";
3
3
 
4
4
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
5
5
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
6
+ import { allowAuthenticatedAction } from "../../../shared/helpers.js";
6
7
  import type { FeedbackScreenEntryDetailProps } from "../../../shared/types";
7
8
  import { Button } from "./Button";
8
9
  import { CommentBranch } from "./CommentBranch";
9
10
  import { FeedbackEntry, FeedbackForm } from "./primitives";
10
11
  import { MetadataModal } from "./MetadataModal";
12
+ import { useNativeAction } from "../helpers.js";
11
13
 
12
14
  export function EntryDetail({
13
15
  entryId,
14
16
  onBack,
15
17
  hideBackButton,
16
18
  }: FeedbackScreenEntryDetailProps) {
17
- const { hooks, commentSort, transformComments } = useFeedbackBody();
19
+ const {
20
+ hooks,
21
+ commentSort,
22
+ transformComments,
23
+ isAuthenticated,
24
+ onUnauthenticated,
25
+ } = useFeedbackBody();
18
26
 
19
27
  const { messages, theme } = useFeedbackUi();
20
28
  const entry = hooks.useEntry(entryId);
@@ -23,6 +31,21 @@ export function EntryDetail({
23
31
  const createComment = hooks.useCreateComment();
24
32
  const [body, setBody] = useState("");
25
33
  const [showMetadata, setShowMetadata] = useState(false);
34
+ const upvoteAction = useNativeAction();
35
+ const commentAction = useNativeAction();
36
+
37
+ const toggleUpvote = (desiredState: boolean) => {
38
+ if (
39
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
40
+ upvoteAction.pending
41
+ ) {
42
+ return;
43
+ }
44
+ void upvoteAction.run(
45
+ () => setUpvote({ entryId, desiredState }),
46
+ "Could not update vote",
47
+ );
48
+ };
26
49
  const visible = useMemo(
27
50
  () => transformComments?.(comments.results) ?? comments.results,
28
51
  [comments.results, transformComments],
@@ -57,9 +80,8 @@ export function EntryDetail({
57
80
  )}
58
81
  <FeedbackEntry.Root entry={entry}>
59
82
  <FeedbackEntry.Upvote
60
- onToggle={(active) =>
61
- void setUpvote({ entryId, desiredState: active })
62
- }
83
+ disabled={isAuthenticated === undefined || upvoteAction.pending}
84
+ onToggle={toggleUpvote}
63
85
  />
64
86
  <FeedbackEntry.Content style={{ gap: 5 }}>
65
87
  <FeedbackEntry.Status />
@@ -92,10 +114,20 @@ export function EntryDetail({
92
114
  placeholder={messages.comments.placeholder}
93
115
  />
94
116
  <FeedbackForm.Submit
95
- onPress={async () => {
117
+ disabled={isAuthenticated === undefined || commentAction.pending}
118
+ submitting={commentAction.pending}
119
+ onPress={() => {
96
120
  if (body.trim().length === 0) return;
97
- await createComment({ entryId, body });
98
- setBody("");
121
+ if (
122
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
123
+ commentAction.pending
124
+ ) {
125
+ return;
126
+ }
127
+ void commentAction.run(async () => {
128
+ await createComment({ entryId, body });
129
+ setBody("");
130
+ }, "Could not add comment");
99
131
  }}
100
132
  >
101
133
  {messages.comments.submit}
@@ -106,9 +138,11 @@ export function EntryDetail({
106
138
  <CommentBranch key={comment.id} comment={comment} entryId={entryId} />
107
139
  ))}
108
140
  </View>
109
- {comments.status === "CanLoadMore" && (
141
+ {(comments.status === "CanLoadMore" ||
142
+ comments.status === "LoadingMore") && (
110
143
  <Button
111
144
  label={messages.comments.loadMore}
145
+ disabled={comments.status === "LoadingMore"}
112
146
  onPress={() => comments.loadMore(hooks.pageSizes.comments)}
113
147
  />
114
148
  )}
@@ -3,6 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
3
3
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
4
4
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
5
5
  import { createEntryLabel, entryStatusChoices } from "../../../shared/helpers";
6
+ import { allowAuthenticatedAction } from "../../../shared/helpers";
6
7
  import { ChoiceChips, FeedbackBoard } from "./primitives";
7
8
 
8
9
  export function FeedbackScreenHeader() {
@@ -14,6 +15,8 @@ export function FeedbackScreenHeader() {
14
15
  enabledKinds,
15
16
  statusFilter,
16
17
  setStatusFilter,
18
+ isAuthenticated,
19
+ onUnauthenticated,
17
20
  } = useFeedbackBody();
18
21
  const { messages, theme } = useFeedbackUi();
19
22
  const { top } = useSafeAreaInsets();
@@ -36,7 +39,12 @@ export function FeedbackScreenHeader() {
36
39
  onValueChange={setStatusFilter}
37
40
  />
38
41
  <Pressable
39
- onPress={() => setShowForm(true)}
42
+ disabled={isAuthenticated === undefined}
43
+ onPress={() => {
44
+ if (allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) {
45
+ setShowForm(true);
46
+ }
47
+ }}
40
48
  style={{
41
49
  alignSelf: "flex-start",
42
50
  paddingVertical: 9,
@@ -2,7 +2,10 @@ import { ActivityIndicator, Text } from "react-native";
2
2
 
3
3
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider.js";
4
4
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js";
5
- import { createEntryLabel } from "../../../shared/helpers.js";
5
+ import {
6
+ allowAuthenticatedAction,
7
+ createEntryLabel,
8
+ } from "../../../shared/helpers.js";
6
9
  import type { FeedbackScreenListProps } from "../../../shared/types/index.js";
7
10
  import { Button } from "./Button.js";
8
11
  import { EntryCard } from "./EntryCard.js";
@@ -18,15 +21,17 @@ export function FeedbackScreenList({
18
21
  const {
19
22
  hooks,
20
23
  entrySort,
21
- isSearching,
22
24
  enabledKinds,
23
25
  statusFilter,
24
26
  selectedEntryId,
27
+ query,
25
28
  debouncedQuery,
26
29
  setSelectedEntryId,
27
30
  setShowForm,
28
31
  emptyState,
29
32
  loading: loadingIndicator,
33
+ isAuthenticated,
34
+ onUnauthenticated,
30
35
  } = useFeedbackBody();
31
36
  const { messages, theme } = useFeedbackUi();
32
37
 
@@ -41,11 +46,18 @@ export function FeedbackScreenList({
41
46
  statusFilter,
42
47
  });
43
48
 
44
- const entries = isSearching ? search : list.results;
45
- const createEntry = onCreateEntry ?? (() => setShowForm(true));
49
+ const normalizedQuery = query.trim();
50
+ const normalizedDebouncedQuery = debouncedQuery.trim();
51
+ const searching = normalizedQuery.length > 0;
52
+ const entries = searching ? search : list.results;
53
+ const createEntry = () => {
54
+ if (allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) {
55
+ (onCreateEntry ?? (() => setShowForm(true)))();
56
+ }
57
+ };
46
58
 
47
- const loading = isSearching
48
- ? search === undefined
59
+ const loading = searching
60
+ ? normalizedQuery !== normalizedDebouncedQuery || search === undefined
49
61
  : list.status === "LoadingFirstPage";
50
62
 
51
63
  return (
@@ -68,7 +80,7 @@ export function FeedbackScreenList({
68
80
  <FeedbackBoard.State>
69
81
  {emptyState}
70
82
  <Text style={{ color: theme.colors.mutedText, textAlign: "center" }}>
71
- {isSearching
83
+ {searching
72
84
  ? messages.board.noSearchResults
73
85
  : messages.board.noEntries}
74
86
  </Text>
@@ -76,6 +88,7 @@ export function FeedbackScreenList({
76
88
  label={createEntryLabel(enabledKinds, messages)}
77
89
  onPress={createEntry}
78
90
  variant="primary"
91
+ disabled={isAuthenticated === undefined}
79
92
  />
80
93
  </FeedbackBoard.State>
81
94
  ) : (
@@ -91,12 +104,14 @@ export function FeedbackScreenList({
91
104
  </FeedbackBoard.List>
92
105
  )}
93
106
 
94
- {!isSearching && list.status === "CanLoadMore" && (
95
- <Button
96
- label={messages.board.loadMore}
97
- onPress={() => list.loadMore(hooks.pageSizes.entries)}
98
- />
99
- )}
107
+ {!searching &&
108
+ (list.status === "CanLoadMore" || list.status === "LoadingMore") && (
109
+ <Button
110
+ label={messages.board.loadMore}
111
+ disabled={list.status === "LoadingMore"}
112
+ onPress={() => list.loadMore(hooks.pageSizes.entries)}
113
+ />
114
+ )}
100
115
  </>
101
116
  );
102
117
  }
@@ -12,10 +12,12 @@ import {
12
12
 
13
13
  import { useFeedbackBody } from "../../../shared/context/FeedbackBodyProvider";
14
14
  import { useFeedbackUi } from "../../../shared/context/FeedbackProvider";
15
+ import { allowAuthenticatedAction } from "../../../shared/helpers.js";
15
16
  import type { FeedbackScreenEntryModalProps } from "../../../shared/types";
16
17
  import { collectEntryMetadata } from "../../../shared/metadata";
17
18
  import { EntryDetail } from "./EntryDetail";
18
19
  import { ChoiceChips, FeedbackForm } from "./primitives";
20
+ import { useNativeAction } from "../helpers.js";
19
21
 
20
22
  export function CreateEntryModal({
21
23
  onRequestClose,
@@ -167,11 +169,17 @@ export function CreateEntryForm({
167
169
  onBodyChange: (body: string) => void;
168
170
  onOpenSuggestion: (id: string) => void;
169
171
  }) {
170
- const { hooks, enabledKinds, collectMetadata, collectStandardMetadata } =
171
- useFeedbackBody();
172
+ const {
173
+ hooks,
174
+ enabledKinds,
175
+ collectMetadata,
176
+ collectStandardMetadata,
177
+ isAuthenticated,
178
+ onUnauthenticated,
179
+ } = useFeedbackBody();
172
180
  const { messages, theme } = useFeedbackUi();
173
- const [submitting, setSubmitting] = useState(false);
174
181
  const create = hooks.useCreateEntry();
182
+ const action = useNativeAction();
175
183
  const similar = hooks.useSimilarEntries({ title, body, kind, limit: 3 });
176
184
 
177
185
  const suggestions = useMemo(() => {
@@ -180,13 +188,16 @@ export function CreateEntryForm({
180
188
  }, [similar]);
181
189
 
182
190
  const submit = async () => {
183
- if (submitting || title.trim().length === 0 || body.trim().length === 0) {
191
+ if (
192
+ action.pending ||
193
+ title.trim().length === 0 ||
194
+ body.trim().length === 0
195
+ ) {
184
196
  return;
185
197
  }
198
+ if (!allowAuthenticatedAction(isAuthenticated, onUnauthenticated)) return;
186
199
 
187
- setSubmitting(true);
188
-
189
- try {
200
+ await action.run(async () => {
190
201
  const metadata = await collectEntryMetadata(
191
202
  collectMetadata,
192
203
  kind,
@@ -200,14 +211,12 @@ export function CreateEntryForm({
200
211
  ...(metadata === undefined ? {} : { metadata }),
201
212
  }),
202
213
  );
203
- } finally {
204
- setSubmitting(false);
205
- }
214
+ }, "Could not submit feedback");
206
215
  };
207
216
 
208
217
  const requestSubmit = () => {
209
218
  if (
210
- submitting ||
219
+ action.pending ||
211
220
  similar === undefined ||
212
221
  title.trim().length === 0 ||
213
222
  body.trim().length === 0
@@ -365,7 +374,11 @@ export function CreateEntryForm({
365
374
  ))}
366
375
  </View>
367
376
  ) : null}
368
- <FeedbackForm.Submit submitting={submitting} onPress={requestSubmit} />
377
+ <FeedbackForm.Submit
378
+ submitting={action.pending}
379
+ disabled={isAuthenticated === undefined || action.pending}
380
+ onPress={requestSubmit}
381
+ />
369
382
  </FeedbackForm.Root>
370
383
  );
371
384
  }
@@ -0,0 +1,345 @@
1
+ import type {
2
+ FeedbackEntry as FeedbackEntryData,
3
+ RoadmapItem,
4
+ } from "convex-feedback";
5
+ import { useState } from "react";
6
+ import { Pressable, ScrollView, Text, View } from "react-native";
7
+
8
+ import {
9
+ FeedbackProvider,
10
+ useFeedbackUi,
11
+ } from "../../../shared/context/FeedbackProvider.js";
12
+ import { allowAuthenticatedAction } from "../../../shared/helpers.js";
13
+ import type { RoadmapScreenProps } from "../../../shared/types/index.js";
14
+ import { useNativeAction } from "../helpers.js";
15
+ import { Button } from "./Button.js";
16
+ import { FeedbackBoard, FeedbackEntry } from "./primitives.js";
17
+
18
+ export function RoadmapScreen({
19
+ hooks,
20
+ messages,
21
+ theme,
22
+ unstyled,
23
+ onEntryOpen,
24
+ onUnauthenticated,
25
+ pageSize,
26
+ entryPageSize,
27
+ ...colors
28
+ }: RoadmapScreenProps) {
29
+ return (
30
+ <FeedbackProvider messages={messages} theme={theme} unstyled={unstyled}>
31
+ <RoadmapScreenInner
32
+ hooks={hooks}
33
+ onEntryOpen={onEntryOpen}
34
+ onUnauthenticated={onUnauthenticated}
35
+ pageSize={pageSize ?? hooks.pageSizes.roadmap}
36
+ entryPageSize={entryPageSize ?? hooks.pageSizes.entries}
37
+ {...colors}
38
+ />
39
+ </FeedbackProvider>
40
+ );
41
+ }
42
+
43
+ function RoadmapScreenInner({
44
+ hooks,
45
+ onEntryOpen,
46
+ onUnauthenticated,
47
+ pageSize,
48
+ entryPageSize,
49
+ ...colors
50
+ }: RoadmapScreenProps & {
51
+ pageSize: number;
52
+ entryPageSize: number;
53
+ }) {
54
+ const { messages, theme } = useFeedbackUi();
55
+ const [query, setQuery] = useState("");
56
+ const [selectedId, setSelectedId] = useState<string | null>(null);
57
+ const roadmap = hooks.useRoadmap();
58
+ const search = hooks.useSearchRoadmap(query);
59
+ const searching = query.trim().length > 0;
60
+ const items = searching ? (search ?? []) : roadmap.results;
61
+ const selected = items.find((item) => item.id === selectedId) ?? null;
62
+
63
+ if (selected !== null) {
64
+ return (
65
+ <FeedbackBoard.Root {...colors}>
66
+ <ScrollView
67
+ contentContainerStyle={{ gap: theme.spacing, padding: theme.spacing }}
68
+ >
69
+ <RoadmapDetail
70
+ item={selected}
71
+ hooks={hooks}
72
+ entryPageSize={entryPageSize}
73
+ onEntryOpen={onEntryOpen}
74
+ onUnauthenticated={onUnauthenticated}
75
+ onBack={() => setSelectedId(null)}
76
+ />
77
+ </ScrollView>
78
+ </FeedbackBoard.Root>
79
+ );
80
+ }
81
+
82
+ const loading = searching
83
+ ? search === undefined
84
+ : roadmap.status === "LoadingFirstPage";
85
+
86
+ return (
87
+ <FeedbackBoard.Root {...colors}>
88
+ <FeedbackBoard.Header
89
+ style={{ gap: theme.spacing, padding: theme.spacing }}
90
+ >
91
+ <FeedbackBoard.Title>Roadmap</FeedbackBoard.Title>
92
+ <Text style={{ color: theme.colors.mutedText }}>
93
+ See what is planned, in progress, and shipped.
94
+ </Text>
95
+ <FeedbackBoard.Search
96
+ value={query}
97
+ onValueChange={setQuery}
98
+ placeholder="Search roadmap"
99
+ />
100
+ </FeedbackBoard.Header>
101
+ {loading ? (
102
+ <FeedbackBoard.State>
103
+ <Text style={{ color: theme.colors.mutedText }}>
104
+ {messages.board.loading}
105
+ </Text>
106
+ </FeedbackBoard.State>
107
+ ) : items.length === 0 ? (
108
+ <FeedbackBoard.State>
109
+ <Text style={{ color: theme.colors.mutedText }}>
110
+ {searching ? "No roadmap items found." : "No roadmap items yet."}
111
+ </Text>
112
+ </FeedbackBoard.State>
113
+ ) : (
114
+ <FeedbackBoard.List contentContainerStyle={{ padding: theme.spacing }}>
115
+ {items.map((item) => (
116
+ <RoadmapCard
117
+ key={item.id}
118
+ item={item}
119
+ onOpen={() => setSelectedId(item.id)}
120
+ />
121
+ ))}
122
+ </FeedbackBoard.List>
123
+ )}
124
+ {!searching &&
125
+ (roadmap.status === "CanLoadMore" ||
126
+ roadmap.status === "LoadingMore") && (
127
+ <Button
128
+ label={messages.board.loadMore}
129
+ disabled={roadmap.status === "LoadingMore"}
130
+ onPress={() => roadmap.loadMore(pageSize)}
131
+ />
132
+ )}
133
+ </FeedbackBoard.Root>
134
+ );
135
+ }
136
+
137
+ function RoadmapCard({
138
+ item,
139
+ onOpen,
140
+ }: {
141
+ item: RoadmapItem;
142
+ onOpen: () => void;
143
+ }) {
144
+ const { theme } = useFeedbackUi();
145
+ return (
146
+ <Pressable onPress={onOpen} style={{ gap: 5, padding: 12 }}>
147
+ <View
148
+ style={{
149
+ flexDirection: "row",
150
+ justifyContent: "space-between",
151
+ gap: 8,
152
+ }}
153
+ >
154
+ <Text
155
+ style={{
156
+ color: theme.colors.mutedText,
157
+ fontSize: 12,
158
+ fontWeight: "600",
159
+ }}
160
+ >
161
+ {item.status.replaceAll("_", " ")}
162
+ </Text>
163
+ <Text style={{ color: theme.colors.mutedText, fontSize: 12 }}>
164
+ {item.feedbackCount} linked
165
+ </Text>
166
+ </View>
167
+ <Text
168
+ style={{ color: theme.colors.text, fontSize: 16, fontWeight: "700" }}
169
+ >
170
+ {item.title}
171
+ </Text>
172
+ {item.description && (
173
+ <Text style={{ color: theme.colors.mutedText, lineHeight: 20 }}>
174
+ {item.description}
175
+ </Text>
176
+ )}
177
+ </Pressable>
178
+ );
179
+ }
180
+
181
+ function RoadmapDetail({
182
+ item,
183
+ hooks,
184
+ entryPageSize,
185
+ onEntryOpen,
186
+ onUnauthenticated,
187
+ onBack,
188
+ }: {
189
+ item: RoadmapItem;
190
+ hooks: RoadmapScreenProps["hooks"];
191
+ entryPageSize: number;
192
+ onEntryOpen: RoadmapScreenProps["onEntryOpen"];
193
+ onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
194
+ onBack: () => void;
195
+ }) {
196
+ const { messages, theme } = useFeedbackUi();
197
+ const feedback = hooks.useRoadmapFeedback(item.id);
198
+ return (
199
+ <View style={{ gap: theme.spacing }}>
200
+ <Button label={messages.entry.back} onPress={onBack} />
201
+ <View style={{ gap: 6 }}>
202
+ <Text
203
+ style={{
204
+ color: theme.colors.mutedText,
205
+ fontSize: 12,
206
+ fontWeight: "600",
207
+ }}
208
+ >
209
+ {item.status.replaceAll("_", " ")} · Position {item.position}
210
+ </Text>
211
+ <Text
212
+ style={{ color: theme.colors.text, fontSize: 23, fontWeight: "700" }}
213
+ >
214
+ {item.title}
215
+ </Text>
216
+ <Text style={{ color: theme.colors.mutedText, lineHeight: 21 }}>
217
+ {item.description ?? "No description."}
218
+ </Text>
219
+ </View>
220
+ <View style={{ gap: 4 }}>
221
+ <RoadmapField label="ID" value={item.id} />
222
+ <RoadmapField
223
+ label="Linked feedback"
224
+ value={String(item.feedbackCount)}
225
+ />
226
+ <RoadmapField
227
+ label="Created"
228
+ value={new Date(item.createdAt).toLocaleString()}
229
+ />
230
+ <RoadmapField
231
+ label="Updated"
232
+ value={new Date(item.updatedAt).toLocaleString()}
233
+ />
234
+ <RoadmapField
235
+ label="Document created"
236
+ value={new Date(item.creationTime).toLocaleString()}
237
+ />
238
+ </View>
239
+ <Text
240
+ style={{ color: theme.colors.text, fontSize: 18, fontWeight: "700" }}
241
+ >
242
+ Attached feedback
243
+ </Text>
244
+ {feedback.status === "LoadingFirstPage" ? (
245
+ <Text style={{ color: theme.colors.mutedText }}>
246
+ {messages.board.loading}
247
+ </Text>
248
+ ) : feedback.results.length === 0 ? (
249
+ <Text style={{ color: theme.colors.mutedText }}>
250
+ No feedback attached.
251
+ </Text>
252
+ ) : (
253
+ <View style={{ gap: 8 }}>
254
+ {feedback.results.map((entry) => (
255
+ <RoadmapEntry
256
+ key={entry.id}
257
+ entry={entry}
258
+ hooks={hooks}
259
+ onOpen={onEntryOpen}
260
+ onUnauthenticated={onUnauthenticated}
261
+ />
262
+ ))}
263
+ </View>
264
+ )}
265
+ {(feedback.status === "CanLoadMore" ||
266
+ feedback.status === "LoadingMore") && (
267
+ <Button
268
+ label={messages.comments.loadMore}
269
+ disabled={feedback.status === "LoadingMore"}
270
+ onPress={() => feedback.loadMore(entryPageSize)}
271
+ />
272
+ )}
273
+ </View>
274
+ );
275
+ }
276
+
277
+ function RoadmapField({ label, value }: { label: string; value: string }) {
278
+ const { theme } = useFeedbackUi();
279
+ return (
280
+ <View style={{ flexDirection: "row", gap: 8 }}>
281
+ <Text style={{ color: theme.colors.mutedText, fontWeight: "600" }}>
282
+ {label}:
283
+ </Text>
284
+ <Text style={{ flex: 1, color: theme.colors.text }}>{value}</Text>
285
+ </View>
286
+ );
287
+ }
288
+
289
+ function RoadmapEntry({
290
+ entry,
291
+ hooks,
292
+ onOpen,
293
+ onUnauthenticated,
294
+ }: {
295
+ entry: FeedbackEntryData;
296
+ hooks: RoadmapScreenProps["hooks"];
297
+ onOpen: RoadmapScreenProps["onEntryOpen"];
298
+ onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
299
+ }) {
300
+ const { isAuthenticated } = useFeedbackAuth(hooks);
301
+ const setUpvote = hooks.useSetEntryUpvote();
302
+ const action = useNativeAction();
303
+ const toggle = (desiredState: boolean) => {
304
+ if (
305
+ !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
306
+ action.pending
307
+ ) {
308
+ return;
309
+ }
310
+ void action.run(
311
+ () => setUpvote({ entryId: entry.id, desiredState }),
312
+ "Could not update vote",
313
+ );
314
+ };
315
+
316
+ return (
317
+ <FeedbackEntry.Root entry={entry}>
318
+ <FeedbackEntry.Upvote
319
+ disabled={isAuthenticated === undefined || action.pending}
320
+ onToggle={toggle}
321
+ />
322
+ <Pressable
323
+ onPress={onOpen === undefined ? undefined : () => onOpen(entry.id)}
324
+ accessibilityRole={onOpen === undefined ? undefined : "button"}
325
+ style={{ flex: 1, minWidth: 0 }}
326
+ >
327
+ <FeedbackEntry.Content>
328
+ <View style={{ gap: 5 }}>
329
+ <View style={{ flexDirection: "row", gap: 8 }}>
330
+ <FeedbackEntry.Kind />
331
+ <FeedbackEntry.Status />
332
+ </View>
333
+ <FeedbackEntry.Title />
334
+ <FeedbackEntry.Body />
335
+ <FeedbackEntry.CommentCount />
336
+ </View>
337
+ </FeedbackEntry.Content>
338
+ </Pressable>
339
+ </FeedbackEntry.Root>
340
+ );
341
+ }
342
+
343
+ function useFeedbackAuth(hooks: RoadmapScreenProps["hooks"]) {
344
+ return { isAuthenticated: hooks.useIsAuthenticated() };
345
+ }
@@ -4,4 +4,4 @@ The web layer is CSS-framework agnostic. Import `convex-feedback-ui/styles.css`
4
4
 
5
5
  Default selectors are scoped beneath `.cf-board` and use low-specificity `:where(...)` selectors so host CSS, CSS Modules, Tailwind classes, or inline styles can override them without a specificity fight.
6
6
 
7
- Use compound primitives for custom layouts or `FeedbackScreen` for the prebuilt experience. Interactive leaf primitives accept render-function children to replace their visual implementation while retaining typed state and actions.
7
+ Use compound primitives for custom layouts, `FeedbackScreen` for the feedback board, or `RoadmapScreen` for public roadmap browsing. Interactive leaf primitives accept render-function children to replace their visual implementation while retaining typed state and actions. Pass `onUnauthenticated` to let the host open its auth UI when an anonymous visitor selects an authenticated-only action.