@heedkit/sdk-react-native 0.1.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.
package/src/index.tsx ADDED
@@ -0,0 +1,526 @@
1
+ import * as React from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ Appearance,
5
+ FlatList,
6
+ Modal,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ TextInput,
12
+ View,
13
+ } from "react-native";
14
+
15
+ import {
16
+ HeedKitClient,
17
+ type Comment,
18
+ type EndUser,
19
+ type Feature,
20
+ type FeatureKind,
21
+ type HeedKitConfig,
22
+ type GroupMode,
23
+ type InitResult,
24
+ type Interaction,
25
+ type KindInteractions,
26
+ type ShowCounts,
27
+ type Theme,
28
+ type Visibility,
29
+ } from "./client";
30
+
31
+ export {
32
+ HeedKitClient,
33
+ type Comment,
34
+ type EndUser,
35
+ type Feature,
36
+ type FeatureKind,
37
+ type HeedKitConfig,
38
+ type GroupMode,
39
+ type InitResult,
40
+ type Interaction,
41
+ type KindInteractions,
42
+ type ShowCounts,
43
+ type Theme,
44
+ type Visibility,
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Per-kind metadata. Kept here (not the client) because it's view-layer copy.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const KIND_META: Record<FeatureKind, { label: string; placeholder: string; tabIcon: string }> = {
52
+ feature_request: { label: "Features", placeholder: "What should we build?", tabIcon: "💡" },
53
+ bug_report: { label: "Bugs", placeholder: "What's broken?", tabIcon: "🐞" },
54
+ improvement: { label: "Improvements", placeholder: "What could be better?", tabIcon: "✨" },
55
+ appreciation: { label: "Appreciation", placeholder: "What did you love?", tabIcon: "❤️" },
56
+ other: { label: "Other", placeholder: "Tell us anything", tabIcon: "💬" },
57
+ };
58
+
59
+ const INTERACTION_META: Record<Interaction, { icon: string; label: string }> = {
60
+ upvote: { icon: "▲", label: "Upvote" },
61
+ downvote: { icon: "▼", label: "Downvote" },
62
+ plus_one: { icon: "+1", label: "+1" },
63
+ like: { icon: "♥", label: "Like" },
64
+ };
65
+
66
+ const FONT_SIZES: Record<NonNullable<Theme["font_size"]>, number> = {
67
+ sm: 13,
68
+ md: 14,
69
+ lg: 16,
70
+ };
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Context
74
+ // ---------------------------------------------------------------------------
75
+
76
+ type Ctx = { client: HeedKitClient; ready: boolean; theme: Theme };
77
+
78
+ const HeedKitContext = React.createContext<Ctx | null>(null);
79
+
80
+ export function HeedKitProvider({
81
+ projectKey,
82
+ apiUrl,
83
+ user,
84
+ children,
85
+ }: { children: React.ReactNode } & HeedKitConfig) {
86
+ const [client] = React.useState(() => new HeedKitClient({ projectKey, apiUrl, user }));
87
+ const [ready, setReady] = React.useState(false);
88
+ const [theme, setTheme] = React.useState<Theme>({});
89
+
90
+ React.useEffect(() => {
91
+ client.init({ ...user, platform: user?.platform || "react-native" }).then(() => {
92
+ setTheme(client.getTheme());
93
+ setReady(true);
94
+ });
95
+ }, [client]); // eslint-disable-line react-hooks/exhaustive-deps
96
+
97
+ return (
98
+ <HeedKitContext.Provider value={{ client, ready, theme }}>
99
+ {children}
100
+ </HeedKitContext.Provider>
101
+ );
102
+ }
103
+
104
+ export function useHeedKit() {
105
+ const ctx = React.useContext(HeedKitContext);
106
+ if (!ctx) throw new Error("useHeedKit must be used inside <HeedKitProvider>");
107
+ return ctx;
108
+ }
109
+
110
+ // Mobile-friendly singleton for users who don't want a provider.
111
+ export const HeedKit = {
112
+ _instance: null as HeedKitClient | null,
113
+ async init(config: HeedKitConfig) {
114
+ this._instance = new HeedKitClient(config);
115
+ return this._instance.init({ ...config.user, platform: config.user?.platform || "react-native" });
116
+ },
117
+ client(): HeedKitClient {
118
+ if (!this._instance) throw new Error("HeedKit.init not called");
119
+ return this._instance;
120
+ },
121
+ };
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Theme palette derived from the server theme + the device color scheme when
125
+ // the user picked "system".
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function usePalette(theme: Theme) {
129
+ const [systemScheme, setSystemScheme] = React.useState(Appearance.getColorScheme());
130
+ React.useEffect(() => {
131
+ const sub = Appearance.addChangeListener(({ colorScheme }) => setSystemScheme(colorScheme));
132
+ return () => sub.remove();
133
+ }, []);
134
+
135
+ const mode = theme.mode === "system" ? (systemScheme === "dark" ? "dark" : "light") : theme.mode || "light";
136
+ const dark = mode === "dark";
137
+
138
+ return {
139
+ primary: theme.primary || "#0D9488",
140
+ radius: theme.radius ?? 12,
141
+ fs: FONT_SIZES[theme.font_size ?? "md"] ?? 14,
142
+ bg: dark ? "#0F172A" : "#FFFFFF",
143
+ fg: dark ? "#F1F5F9" : "#0F172A",
144
+ muted: dark ? "#94A3B8" : "#64748B",
145
+ row: dark ? "#1E293B" : "#F8FAFC",
146
+ border: dark ? "#1E293B" : "#E2E8F0",
147
+ inputBorder: dark ? "#334155" : "#CBD5E1",
148
+ inputBg: dark ? "#0F172A" : "#FFFFFF",
149
+ dark,
150
+ };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Public components
155
+ // ---------------------------------------------------------------------------
156
+
157
+ export function FeedbackButton({ label = "Feedback" }: { label?: string }) {
158
+ const ctx = React.useContext(HeedKitContext);
159
+ const [open, setOpen] = React.useState(false);
160
+ const p = usePalette(ctx?.theme || {});
161
+ if (!ctx?.ready) return null;
162
+ return (
163
+ <>
164
+ <Pressable
165
+ onPress={() => setOpen(true)}
166
+ style={[styles.fab, { backgroundColor: p.primary }]}
167
+ >
168
+ <Text style={[styles.fabLabel, { fontSize: p.fs }]}>{label}</Text>
169
+ </Pressable>
170
+ <Modal visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
171
+ <FeedbackScreen onClose={() => setOpen(false)} />
172
+ </Modal>
173
+ </>
174
+ );
175
+ }
176
+
177
+ type Mode = "browse" | "suggest";
178
+
179
+ export function FeedbackScreen({ onClose }: { onClose?: () => void }) {
180
+ const ctx = React.useContext(HeedKitContext);
181
+ const p = usePalette(ctx?.theme || {});
182
+
183
+ if (!ctx?.ready) {
184
+ return (
185
+ <View style={[styles.center, { backgroundColor: p.bg, flex: 1 }]}>
186
+ <ActivityIndicator color={p.primary} />
187
+ </View>
188
+ );
189
+ }
190
+
191
+ const enabledKinds = ctx.client.getEnabledKinds();
192
+ const groupMode: GroupMode = (ctx.theme.group_mode as GroupMode) || "tabs";
193
+ const showCounts = ctx.theme.show_counts || {};
194
+
195
+ const [mode, setMode] = React.useState<Mode>("browse");
196
+ const [activeKind, setActiveKind] = React.useState<FeatureKind | "all">(
197
+ groupMode === "tabs" && enabledKinds.length > 0 ? enabledKinds[0] : "all",
198
+ );
199
+ const [features, setFeatures] = React.useState<Feature[]>([]);
200
+ const [loading, setLoading] = React.useState(true);
201
+
202
+ async function refresh() {
203
+ setLoading(true);
204
+ try {
205
+ const opts: { sort: "top" | "new"; kind?: FeatureKind } = { sort: "top" };
206
+ if (activeKind !== "all") opts.kind = activeKind;
207
+ setFeatures(await ctx!.client.list(opts));
208
+ } finally {
209
+ setLoading(false);
210
+ }
211
+ }
212
+
213
+ React.useEffect(() => {
214
+ refresh();
215
+ // eslint-disable-next-line react-hooks/exhaustive-deps
216
+ }, [activeKind]);
217
+
218
+ async function performInteraction(f: Feature, _i: Interaction) {
219
+ // Backend currently has one vote toggle endpoint per feature; richer
220
+ // per-interaction storage can be wired here later.
221
+ const r = await ctx!.client.vote(f.id);
222
+ setFeatures((arr) =>
223
+ arr.map((x) => (x.id === f.id ? { ...x, voted: r.voted, vote_count: r.vote_count } : x)),
224
+ );
225
+ }
226
+
227
+ return (
228
+ <View style={[styles.container, { backgroundColor: p.bg }]}>
229
+ <View style={[styles.header, { borderColor: p.border }]}>
230
+ <Text style={[styles.title, { color: p.fg, fontSize: p.fs + 6 }]}>
231
+ {ctx.client.getProjectName() || "Feedback"}
232
+ </Text>
233
+ {onClose && (
234
+ <Pressable onPress={onClose}>
235
+ <Text style={{ color: p.primary, fontWeight: "600", fontSize: p.fs }}>Close</Text>
236
+ </Pressable>
237
+ )}
238
+ </View>
239
+
240
+ <View style={styles.modeRow}>
241
+ {(["browse", "suggest"] as Mode[]).map((m) => (
242
+ <Pressable
243
+ key={m}
244
+ onPress={() => setMode(m)}
245
+ style={[
246
+ styles.modeBtn,
247
+ { backgroundColor: mode === m ? p.primary : "transparent" },
248
+ ]}
249
+ >
250
+ <Text style={{ color: mode === m ? "#fff" : p.muted, fontWeight: "600", fontSize: p.fs - 1 }}>
251
+ {m === "browse" ? "Browse" : "Suggest"}
252
+ </Text>
253
+ </Pressable>
254
+ ))}
255
+ </View>
256
+
257
+ {mode === "browse" && groupMode === "tabs" && enabledKinds.length > 0 && (
258
+ <ScrollView
259
+ horizontal
260
+ showsHorizontalScrollIndicator={false}
261
+ style={[styles.tabRow, { borderColor: p.border }]}
262
+ contentContainerStyle={{ gap: 6, paddingHorizontal: 16, paddingVertical: 10 }}
263
+ >
264
+ {(["all", ...enabledKinds] as Array<FeatureKind | "all">).map((k) => {
265
+ const active = activeKind === k;
266
+ return (
267
+ <Pressable
268
+ key={k}
269
+ onPress={() => setActiveKind(k)}
270
+ style={[
271
+ styles.tab,
272
+ { backgroundColor: active ? p.primary : p.row },
273
+ ]}
274
+ >
275
+ <Text style={{ color: active ? "#fff" : p.fg, fontSize: p.fs - 1, fontWeight: "500" }}>
276
+ {k === "all" ? "All" : `${KIND_META[k].tabIcon} ${KIND_META[k].label}`}
277
+ </Text>
278
+ </Pressable>
279
+ );
280
+ })}
281
+ </ScrollView>
282
+ )}
283
+
284
+ {mode === "browse" ? (
285
+ loading ? (
286
+ <View style={[styles.center, { flex: 1 }]}><ActivityIndicator color={p.primary} /></View>
287
+ ) : features.length === 0 ? (
288
+ <View style={[styles.center, { flex: 1 }]}>
289
+ <Text style={{ color: p.muted, fontSize: p.fs }}>No items yet — be the first!</Text>
290
+ </View>
291
+ ) : (
292
+ <FlatList
293
+ data={features}
294
+ keyExtractor={(f) => f.id}
295
+ contentContainerStyle={{ padding: 16, gap: 8 }}
296
+ renderItem={({ item }) => (
297
+ <Row
298
+ feature={item}
299
+ palette={p}
300
+ interactions={ctx!.client.getInteractionsFor(item.kind)}
301
+ showCount={showCounts[item.kind] !== false}
302
+ onInteraction={(i) => performInteraction(item, i)}
303
+ />
304
+ )}
305
+ />
306
+ )
307
+ ) : (
308
+ <SubmitForm
309
+ palette={p}
310
+ enabledKinds={enabledKinds.length > 0 ? enabledKinds : ["other"]}
311
+ onSubmitted={async () => {
312
+ setMode("browse");
313
+ await refresh();
314
+ }}
315
+ />
316
+ )}
317
+ </View>
318
+ );
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Row + form (private)
323
+ // ---------------------------------------------------------------------------
324
+
325
+ function Row({
326
+ feature: f, palette: p, interactions, showCount, onInteraction,
327
+ }: {
328
+ feature: Feature;
329
+ palette: ReturnType<typeof usePalette>;
330
+ interactions: Interaction[];
331
+ showCount: boolean;
332
+ onInteraction: (i: Interaction) => void;
333
+ }) {
334
+ return (
335
+ <View style={[styles.row, { backgroundColor: p.row, borderRadius: p.radius }]}>
336
+ <View style={styles.actionCol}>
337
+ {interactions.length === 0 ? (
338
+ showCount && (
339
+ <View style={[styles.actBtn, { borderColor: p.border, borderRadius: p.radius - 4 }]}>
340
+ <Text style={{ color: p.fg, fontWeight: "600", fontSize: p.fs - 1 }}>{f.vote_count}</Text>
341
+ </View>
342
+ )
343
+ ) : (
344
+ interactions.map((i) => (
345
+ <Pressable
346
+ key={i}
347
+ onPress={() => onInteraction(i)}
348
+ style={[
349
+ styles.actBtn,
350
+ {
351
+ borderRadius: p.radius - 4,
352
+ borderColor: f.voted ? p.primary : p.border,
353
+ borderWidth: f.voted ? 2 : 1,
354
+ backgroundColor: f.voted ? p.primary + "22" : "transparent",
355
+ },
356
+ ]}
357
+ >
358
+ <Text style={{ color: f.voted ? p.primary : p.fg, fontSize: p.fs + 1 }}>
359
+ {INTERACTION_META[i].icon}
360
+ </Text>
361
+ {showCount && (
362
+ <Text style={{ color: f.voted ? p.primary : p.fg, fontSize: p.fs - 2, fontWeight: "600" }}>
363
+ {f.vote_count}
364
+ </Text>
365
+ )}
366
+ </Pressable>
367
+ ))
368
+ )}
369
+ </View>
370
+ <View style={{ flex: 1 }}>
371
+ <Text style={{ color: p.fg, fontWeight: "600", fontSize: p.fs }}>{f.title}</Text>
372
+ {!!f.description && (
373
+ <Text
374
+ style={{ color: p.muted, fontSize: p.fs - 1, marginTop: 4 }}
375
+ numberOfLines={3}
376
+ >{f.description}</Text>
377
+ )}
378
+ {(f.status !== "open" || f.tag) && (
379
+ <View style={styles.badges}>
380
+ {f.status !== "open" && (
381
+ <Text style={[styles.badge, { backgroundColor: p.border, color: p.muted, fontSize: p.fs - 3 }]}>
382
+ {f.status.replace("_", " ")}
383
+ </Text>
384
+ )}
385
+ {!!f.tag && (
386
+ <Text style={[styles.badge, { backgroundColor: p.border, color: p.muted, fontSize: p.fs - 3 }]}>
387
+ {f.tag}
388
+ </Text>
389
+ )}
390
+ </View>
391
+ )}
392
+ </View>
393
+ </View>
394
+ );
395
+ }
396
+
397
+ function SubmitForm({
398
+ palette: p, enabledKinds, onSubmitted,
399
+ }: {
400
+ palette: ReturnType<typeof usePalette>;
401
+ enabledKinds: FeatureKind[];
402
+ onSubmitted: () => void;
403
+ }) {
404
+ const ctx = React.useContext(HeedKitContext)!;
405
+ const [kind, setKind] = React.useState<FeatureKind>(enabledKinds[0]);
406
+ const [title, setTitle] = React.useState("");
407
+ const [description, setDescription] = React.useState("");
408
+ const [submitting, setSubmitting] = React.useState(false);
409
+
410
+ async function submit() {
411
+ if (!title.trim()) return;
412
+ setSubmitting(true);
413
+ try {
414
+ await ctx.client.submit({ title, description, kind });
415
+ setTitle("");
416
+ setDescription("");
417
+ onSubmitted();
418
+ } finally {
419
+ setSubmitting(false);
420
+ }
421
+ }
422
+
423
+ return (
424
+ <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
425
+ <Text style={{ color: p.fg, fontWeight: "500", fontSize: p.fs - 1 }}>What's this about?</Text>
426
+ <View style={[styles.segmented, { backgroundColor: p.row }]}>
427
+ {enabledKinds.map((k) => {
428
+ const active = k === kind;
429
+ return (
430
+ <Pressable
431
+ key={k}
432
+ onPress={() => setKind(k)}
433
+ style={[
434
+ styles.seg,
435
+ {
436
+ backgroundColor: active ? p.bg : "transparent",
437
+ borderRadius: 999,
438
+ },
439
+ ]}
440
+ >
441
+ <Text style={{ color: active ? p.fg : p.muted, fontWeight: "500", fontSize: p.fs - 2 }}>
442
+ {KIND_META[k].label}
443
+ </Text>
444
+ </Pressable>
445
+ );
446
+ })}
447
+ </View>
448
+
449
+ <Text style={{ color: p.fg, fontWeight: "500", fontSize: p.fs - 1 }}>Title</Text>
450
+ <TextInput
451
+ value={title}
452
+ onChangeText={setTitle}
453
+ placeholder={KIND_META[kind].placeholder}
454
+ placeholderTextColor={p.muted}
455
+ style={[styles.input, { borderColor: p.inputBorder, color: p.fg, backgroundColor: p.inputBg, borderRadius: p.radius - 2 }]}
456
+ />
457
+
458
+ <Text style={{ color: p.fg, fontWeight: "500", fontSize: p.fs - 1 }}>Description</Text>
459
+ <TextInput
460
+ value={description}
461
+ onChangeText={setDescription}
462
+ placeholder="Any extra context helps."
463
+ placeholderTextColor={p.muted}
464
+ multiline
465
+ numberOfLines={4}
466
+ style={[
467
+ styles.input,
468
+ { borderColor: p.inputBorder, color: p.fg, backgroundColor: p.inputBg, borderRadius: p.radius - 2, height: 100 },
469
+ ]}
470
+ />
471
+
472
+ <Pressable
473
+ disabled={!title || submitting}
474
+ onPress={submit}
475
+ style={[
476
+ styles.submit,
477
+ { backgroundColor: p.primary, opacity: !title || submitting ? 0.6 : 1, borderRadius: p.radius },
478
+ ]}
479
+ >
480
+ <Text style={{ color: "#fff", fontWeight: "600", fontSize: p.fs }}>
481
+ {submitting ? "Submitting…" : "Submit"}
482
+ </Text>
483
+ </Pressable>
484
+ </ScrollView>
485
+ );
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Styles
490
+ // ---------------------------------------------------------------------------
491
+
492
+ const styles = StyleSheet.create({
493
+ container: { flex: 1 },
494
+ center: { alignItems: "center", justifyContent: "center" },
495
+ header: {
496
+ flexDirection: "row", justifyContent: "space-between", alignItems: "center",
497
+ paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1,
498
+ },
499
+ title: { fontWeight: "700" },
500
+ modeRow: { flexDirection: "row", gap: 6, paddingHorizontal: 16, paddingTop: 10 },
501
+ modeBtn: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 999 },
502
+ tabRow: { maxHeight: 48, borderBottomWidth: 1 },
503
+ tab: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999 },
504
+ row: { flexDirection: "row", gap: 12, padding: 12, alignItems: "flex-start" },
505
+ actionCol: { gap: 4 },
506
+ actBtn: {
507
+ minWidth: 44, paddingHorizontal: 8, paddingVertical: 6,
508
+ alignItems: "center", justifyContent: "center", borderWidth: 1,
509
+ },
510
+ badges: { flexDirection: "row", gap: 6, marginTop: 6, flexWrap: "wrap" },
511
+ badge: {
512
+ paddingHorizontal: 8, paddingVertical: 2, borderRadius: 999,
513
+ textTransform: "uppercase", letterSpacing: 0.4,
514
+ },
515
+ segmented: { flexDirection: "row", padding: 4, borderRadius: 999, gap: 4, alignSelf: "flex-start", flexWrap: "wrap" },
516
+ seg: { paddingHorizontal: 12, paddingVertical: 6 },
517
+ input: { borderWidth: 1, padding: 12, fontSize: 14 },
518
+ submit: { padding: 14, alignItems: "center", marginTop: 8 },
519
+ fab: {
520
+ position: "absolute", bottom: 24, right: 24,
521
+ paddingHorizontal: 18, paddingVertical: 12, borderRadius: 999,
522
+ shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 8, shadowOffset: { width: 0, height: 4 },
523
+ elevation: 6,
524
+ },
525
+ fabLabel: { color: "#fff", fontWeight: "600" },
526
+ });