@betterreviews/react-native 1.0.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 (41) hide show
  1. package/LICENSE +145 -0
  2. package/README.md +189 -0
  3. package/SECURITY.md +238 -0
  4. package/dist/index.d.mts +581 -0
  5. package/dist/index.d.ts +581 -0
  6. package/dist/index.js +2384 -0
  7. package/dist/index.mjs +2346 -0
  8. package/package.json +78 -0
  9. package/src/BetterReviewsProvider.tsx +62 -0
  10. package/src/ProductContentBlock.tsx +143 -0
  11. package/src/StarRating.tsx +85 -0
  12. package/src/WebViewHost.tsx +164 -0
  13. package/src/bridge.ts +48 -0
  14. package/src/client/createBetterReviewsClient.ts +211 -0
  15. package/src/client/types.ts +101 -0
  16. package/src/icons/BRIcons.tsx +176 -0
  17. package/src/index.ts +74 -0
  18. package/src/minSdkVersion.ts +52 -0
  19. package/src/sections/FeaturesSection.tsx +69 -0
  20. package/src/sections/ReviewsSummarySection.tsx +47 -0
  21. package/src/telemetry.ts +52 -0
  22. package/src/theme/applyTheme.ts +72 -0
  23. package/src/theme/widgetTheme.ts +67 -0
  24. package/src/webviewMessage.ts +23 -0
  25. package/src/widget/ReviewWidget.tsx +230 -0
  26. package/src/widget/WidgetContext.tsx +43 -0
  27. package/src/widget/components/FilterToolbar.tsx +146 -0
  28. package/src/widget/components/MediaGallery.tsx +53 -0
  29. package/src/widget/components/PulseSection.tsx +69 -0
  30. package/src/widget/components/RatingStars.tsx +40 -0
  31. package/src/widget/components/ReviewCard.tsx +114 -0
  32. package/src/widget/components/SortDrawer.tsx +49 -0
  33. package/src/widget/components/StaleListOverlay.tsx +51 -0
  34. package/src/widget/components/VoteButtons.tsx +55 -0
  35. package/src/widget/hooks/useReviewDetail.ts +55 -0
  36. package/src/widget/hooks/useReviewList.ts +136 -0
  37. package/src/widget/hooks/useReviewSummary.ts +24 -0
  38. package/src/widget/hooks/useVote.ts +68 -0
  39. package/src/widget/styles.ts +393 -0
  40. package/src/widget/util.ts +21 -0
  41. package/src/widget/viewer/MediaReviewViewer.tsx +350 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Instagram/Loox-style two-axis media viewer.
3
+ * - Vertical swipe → next review (text-only reviews are pre-filtered out by
4
+ * the container).
5
+ * - Horizontal swipe → next photo within the current review.
6
+ *
7
+ * Uses `react-native-gesture-handler`'s `GHFlatList` (vertical pager) +
8
+ * `GHScrollView` (card body) so the inner long-body scroll chains with the
9
+ * outer pager — stock RN's `PanResponder` steals that gesture.
10
+ *
11
+ * DELIBERATE double-wrap: a `GestureHandlerRootView` is mounted INSIDE this
12
+ * Modal *and* the host wraps their app root in one. On Android a Modal renders
13
+ * into a separate native view hierarchy that the root view does not reach, so
14
+ * the gestures inside the Modal need their own root. This is NOT the
15
+ * prototype's "fragile modal-only wrap" (that was the ONLY wrap) — it's
16
+ * root (host) + modal-internal (here), the documented RN-Modal requirement.
17
+ */
18
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
19
+ import {
20
+ Animated,
21
+ Easing,
22
+ FlatList,
23
+ Image,
24
+ Modal,
25
+ Platform,
26
+ Pressable,
27
+ Text,
28
+ View,
29
+ } from 'react-native';
30
+ import {
31
+ GestureHandlerRootView,
32
+ FlatList as GHFlatList,
33
+ ScrollView as GHScrollView,
34
+ } from 'react-native-gesture-handler';
35
+ import type { WidgetReview } from '../../client/types.js';
36
+ import { IconShieldCheck, IconXClose } from '../../icons/BRIcons.js';
37
+ import { useWidget } from '../WidgetContext.js';
38
+ import { useReviewDetail } from '../hooks/useReviewDetail.js';
39
+ import { getInitials } from '../util.js';
40
+ import { RatingStars } from '../components/RatingStars.js';
41
+ import { VoteButtons } from '../components/VoteButtons.js';
42
+ import { SCREEN_H, SCREEN_W } from '../styles.js';
43
+
44
+ export interface MediaReviewViewerProps {
45
+ reviews: WidgetReview[];
46
+ initialReviewId: number;
47
+ initialMediaIndex: number;
48
+ onClose: () => void;
49
+ }
50
+
51
+ export function MediaReviewViewer({
52
+ reviews,
53
+ initialReviewId,
54
+ initialMediaIndex,
55
+ onClose,
56
+ }: MediaReviewViewerProps) {
57
+ const { styles, client } = useWidget();
58
+ const startIndex = Math.max(
59
+ 0,
60
+ reviews.findIndex((r) => r.id === initialReviewId),
61
+ );
62
+ const [reviewIndex, setReviewIndex] = useState(startIndex);
63
+ const [mediaIndexByReview, setMediaIndexByReview] = useState<Record<number, number>>(() => ({
64
+ [reviews[startIndex]?.id ?? -1]: initialMediaIndex || 0,
65
+ }));
66
+ const [fullBodies, setFullBodies] = useState<Record<number, string>>({});
67
+
68
+ const vListRef = useRef<FlatList<WidgetReview>>(null);
69
+ const fade = useRef(new Animated.Value(0)).current;
70
+
71
+ useEffect(() => {
72
+ Animated.timing(fade, {
73
+ toValue: 1,
74
+ duration: 200,
75
+ easing: Easing.out(Easing.quad),
76
+ useNativeDriver: true,
77
+ }).start();
78
+ }, [fade]);
79
+
80
+ const close = useCallback(() => {
81
+ Animated.timing(fade, {
82
+ toValue: 0,
83
+ duration: 180,
84
+ easing: Easing.in(Easing.quad),
85
+ useNativeDriver: true,
86
+ }).start(() => onClose());
87
+ }, [fade, onClose]);
88
+
89
+ // Eager-prefetch the NEXT review's full body (flat detail.body) so a vertical
90
+ // swipe never stalls on "Read more".
91
+ useEffect(() => {
92
+ const next = reviews[reviewIndex + 1];
93
+ if (!next || !next.body_truncated || fullBodies[next.id]) return;
94
+ let cancelled = false;
95
+ client
96
+ .getReviewDetail(next.id)
97
+ .then((detail) => {
98
+ if (!cancelled) setFullBodies((m) => ({ ...m, [next.id]: detail.body }));
99
+ })
100
+ .catch(() => {});
101
+ return () => {
102
+ cancelled = true;
103
+ };
104
+ }, [reviewIndex, reviews, fullBodies, client]);
105
+
106
+ const onMomentumScrollEnd = useCallback(
107
+ (e: { nativeEvent: { contentOffset: { y: number } } }) => {
108
+ const next = Math.round(e.nativeEvent.contentOffset.y / SCREEN_H);
109
+ if (next !== reviewIndex) setReviewIndex(next);
110
+ },
111
+ [reviewIndex],
112
+ );
113
+
114
+ const setMediaIndex = useCallback((reviewId: number, i: number) => {
115
+ setMediaIndexByReview((m) => ({ ...m, [reviewId]: i }));
116
+ }, []);
117
+
118
+ if (!reviews.length) return null;
119
+
120
+ return (
121
+ <Modal
122
+ visible
123
+ transparent
124
+ animationType="none"
125
+ presentationStyle="overFullScreen"
126
+ onRequestClose={close}
127
+ statusBarTranslucent
128
+ >
129
+ <GestureHandlerRootView style={styles.viewerRoot}>
130
+ <Animated.View style={[styles.viewerRoot, { opacity: fade }]} accessibilityViewIsModal>
131
+ <GHFlatList
132
+ ref={vListRef as never}
133
+ data={reviews}
134
+ keyExtractor={(r: WidgetReview) => `vr-${r.id}`}
135
+ pagingEnabled
136
+ showsVerticalScrollIndicator={false}
137
+ initialScrollIndex={reviewIndex}
138
+ getItemLayout={(_: unknown, i: number) => ({ length: SCREEN_H, offset: SCREEN_H * i, index: i })}
139
+ onMomentumScrollEnd={onMomentumScrollEnd}
140
+ decelerationRate="fast"
141
+ snapToInterval={SCREEN_H}
142
+ initialNumToRender={1}
143
+ windowSize={3}
144
+ removeClippedSubviews={Platform.OS === 'android'}
145
+ renderItem={({ item, index }: { item: WidgetReview; index: number }) => (
146
+ <ViewerPage
147
+ review={item}
148
+ reviewPositionLabel={`Review ${index + 1} / ${reviews.length}`}
149
+ mediaIndex={mediaIndexByReview[item.id] || 0}
150
+ onMediaIndexChange={(i) => setMediaIndex(item.id, i)}
151
+ onSwipeNextReview={() => {
152
+ if (index < reviews.length - 1) {
153
+ vListRef.current?.scrollToIndex({ index: index + 1, animated: true });
154
+ }
155
+ }}
156
+ preFetchedBody={fullBodies[item.id]}
157
+ onRequestClose={close}
158
+ />
159
+ )}
160
+ />
161
+
162
+ <Pressable
163
+ style={({ pressed }: { pressed: boolean }) => [styles.viewerClose, pressed && { opacity: 0.7 }]}
164
+ onPress={close}
165
+ accessibilityLabel="Close review viewer"
166
+ hitSlop={12}
167
+ >
168
+ <IconXClose size={20} color="#fff" />
169
+ </Pressable>
170
+ </Animated.View>
171
+ </GestureHandlerRootView>
172
+ </Modal>
173
+ );
174
+ }
175
+
176
+ interface ViewerPageProps {
177
+ review: WidgetReview;
178
+ reviewPositionLabel: string;
179
+ mediaIndex: number;
180
+ onMediaIndexChange: (i: number) => void;
181
+ onSwipeNextReview: () => void;
182
+ preFetchedBody?: string;
183
+ onRequestClose: () => void;
184
+ }
185
+
186
+ function ViewerPage({
187
+ review,
188
+ reviewPositionLabel,
189
+ mediaIndex,
190
+ onMediaIndexChange,
191
+ onSwipeNextReview,
192
+ preFetchedBody,
193
+ onRequestClose,
194
+ }: ViewerPageProps) {
195
+ const { styles, theme } = useWidget();
196
+ const { expanded, body, error, toggle } = useReviewDetail(review, preFetchedBody);
197
+ const [replyOpen, setReplyOpen] = useState(false);
198
+
199
+ const media = review.media.filter((m) => m.type === 'image');
200
+ const reply = review.merchant_reply;
201
+ const visibleTags = review.tags.slice(0, 4);
202
+ const wasTruncated = review.body_truncated === true;
203
+
204
+ return (
205
+ <View style={{ width: SCREEN_W, height: SCREEN_H }}>
206
+ <View style={styles.viewerMediaZone}>
207
+ <FlatList
208
+ data={media}
209
+ keyExtractor={(_, i) => `hm-${review.id}-${i}`}
210
+ horizontal
211
+ pagingEnabled
212
+ showsHorizontalScrollIndicator={false}
213
+ initialScrollIndex={mediaIndex}
214
+ getItemLayout={(_, i) => ({ length: SCREEN_W, offset: SCREEN_W * i, index: i })}
215
+ onMomentumScrollEnd={(e) => {
216
+ const i = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W);
217
+ // On the last (or only) photo, a further swipe advances to the next review.
218
+ if (i === mediaIndex && media.length <= 1) onSwipeNextReview();
219
+ else onMediaIndexChange(i);
220
+ }}
221
+ onScrollEndDrag={(e) => onMediaIndexChange(Math.round(e.nativeEvent.contentOffset.x / SCREEN_W))}
222
+ renderItem={({ item, index }) => (
223
+ <View
224
+ style={[styles.viewerMediaPage, { width: SCREEN_W }]}
225
+ accessibilityLabel={`Photo ${index + 1} of ${media.length} from ${review.author || 'Anonymous'}'s review`}
226
+ >
227
+ <Image
228
+ source={{ uri: item.full || undefined }}
229
+ style={styles.viewerImage}
230
+ resizeMode="contain"
231
+ accessible
232
+ accessibilityLabel={item.alt || 'Customer review photo'}
233
+ />
234
+ </View>
235
+ )}
236
+ />
237
+
238
+ <View style={styles.viewerTopOverlay} pointerEvents="none">
239
+ {media.length > 1 ? (
240
+ <View style={styles.viewerDots}>
241
+ {media.map((_, i) => (
242
+ <View key={i} style={[styles.viewerDot, i === mediaIndex && styles.viewerDotActive]} />
243
+ ))}
244
+ </View>
245
+ ) : (
246
+ <View />
247
+ )}
248
+ <View style={styles.viewerPosition}>
249
+ <Text style={styles.viewerPositionText}>{reviewPositionLabel}</Text>
250
+ </View>
251
+ </View>
252
+ </View>
253
+
254
+ <View style={styles.viewerCard} accessibilityRole="summary">
255
+ <Pressable
256
+ onPress={onRequestClose}
257
+ hitSlop={12}
258
+ accessibilityLabel="Close review viewer"
259
+ accessibilityRole="button"
260
+ style={styles.viewerHandleHit}
261
+ >
262
+ <View style={styles.viewerHandle} />
263
+ </Pressable>
264
+
265
+ <GHScrollView
266
+ style={styles.viewerCardScroll}
267
+ contentContainerStyle={styles.viewerCardContent}
268
+ showsVerticalScrollIndicator={false}
269
+ >
270
+ <View style={styles.viewerCardHeader}>
271
+ <View style={styles.viewerAvatar}>
272
+ <Text style={styles.viewerAvatarText}>{getInitials(review.author)}</Text>
273
+ </View>
274
+ <View style={{ flex: 1, minWidth: 0 }}>
275
+ <View style={styles.viewerHeaderTopRow}>
276
+ <Text style={styles.viewerAuthor} numberOfLines={1}>
277
+ {review.author || 'Anonymous'}
278
+ </Text>
279
+ <Text style={styles.viewerDate}>{review.date}</Text>
280
+ </View>
281
+ <View style={styles.viewerStarsRow}>
282
+ <RatingStars value={review.rating} size={13} uid={`v-${review.id}`} />
283
+ {review.verified && (
284
+ <View style={styles.verifiedBadge}>
285
+ <IconShieldCheck size={11} color={theme.verified} />
286
+ <Text style={styles.verifiedBadgeText}>Verified Buyer</Text>
287
+ </View>
288
+ )}
289
+ </View>
290
+ </View>
291
+ </View>
292
+
293
+ {!!review.title && <Text style={styles.viewerTitle}>{review.title}</Text>}
294
+ {!!body && (
295
+ <Text style={styles.viewerBody} numberOfLines={wasTruncated && !expanded ? 4 : undefined}>
296
+ {body}
297
+ </Text>
298
+ )}
299
+ {wasTruncated && (
300
+ <Pressable
301
+ onPress={toggle}
302
+ style={({ pressed }: { pressed: boolean }) => [styles.readMoreBtn, pressed && { opacity: 0.6 }]}
303
+ accessibilityRole="button"
304
+ accessibilityLabel={expanded ? 'Read less' : 'Read more'}
305
+ >
306
+ <Text style={styles.readMoreBtnText}>{expanded ? 'Read less' : 'Read more'}</Text>
307
+ </Pressable>
308
+ )}
309
+ {error && <Text style={styles.readMoreErr}>Could not load full review — showing preview.</Text>}
310
+
311
+ {visibleTags.length > 0 && (
312
+ <View style={styles.tagsRow}>
313
+ {visibleTags.map((tag, i) => (
314
+ <View key={i} style={styles.tag}>
315
+ <Text style={styles.tagText}>{tag}</Text>
316
+ </View>
317
+ ))}
318
+ </View>
319
+ )}
320
+
321
+ <View style={styles.viewerActionsRow}>
322
+ <VoteButtons review={review} />
323
+ </View>
324
+
325
+ {reply && (
326
+ <Pressable
327
+ onPress={() => setReplyOpen((v) => !v)}
328
+ style={({ pressed }: { pressed: boolean }) => [styles.viewerReplyToggle, pressed && { opacity: 0.7 }]}
329
+ accessibilityRole="button"
330
+ accessibilityLabel={replyOpen ? 'Hide merchant reply' : 'Show merchant reply'}
331
+ >
332
+ <Text style={styles.viewerReplyToggleText}>
333
+ {replyOpen ? `Hide reply from ${reply.author_name}` : `Reply from ${reply.author_name} →`}
334
+ </Text>
335
+ </Pressable>
336
+ )}
337
+ {reply && replyOpen && (
338
+ <View style={styles.merchantReply}>
339
+ <View style={styles.merchantReplyHeader}>
340
+ <Text style={styles.merchantReplyAuthor}>{reply.author_name}</Text>
341
+ <Text style={styles.merchantReplyDate}>{reply.date}</Text>
342
+ </View>
343
+ <Text style={styles.merchantReplyBody}>{reply.body}</Text>
344
+ </View>
345
+ )}
346
+ </GHScrollView>
347
+ </View>
348
+ </View>
349
+ );
350
+ }