@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.
- package/LICENSE +145 -0
- package/README.md +189 -0
- package/SECURITY.md +238 -0
- package/dist/index.d.mts +581 -0
- package/dist/index.d.ts +581 -0
- package/dist/index.js +2384 -0
- package/dist/index.mjs +2346 -0
- package/package.json +78 -0
- package/src/BetterReviewsProvider.tsx +62 -0
- package/src/ProductContentBlock.tsx +143 -0
- package/src/StarRating.tsx +85 -0
- package/src/WebViewHost.tsx +164 -0
- package/src/bridge.ts +48 -0
- package/src/client/createBetterReviewsClient.ts +211 -0
- package/src/client/types.ts +101 -0
- package/src/icons/BRIcons.tsx +176 -0
- package/src/index.ts +74 -0
- package/src/minSdkVersion.ts +52 -0
- package/src/sections/FeaturesSection.tsx +69 -0
- package/src/sections/ReviewsSummarySection.tsx +47 -0
- package/src/telemetry.ts +52 -0
- package/src/theme/applyTheme.ts +72 -0
- package/src/theme/widgetTheme.ts +67 -0
- package/src/webviewMessage.ts +23 -0
- package/src/widget/ReviewWidget.tsx +230 -0
- package/src/widget/WidgetContext.tsx +43 -0
- package/src/widget/components/FilterToolbar.tsx +146 -0
- package/src/widget/components/MediaGallery.tsx +53 -0
- package/src/widget/components/PulseSection.tsx +69 -0
- package/src/widget/components/RatingStars.tsx +40 -0
- package/src/widget/components/ReviewCard.tsx +114 -0
- package/src/widget/components/SortDrawer.tsx +49 -0
- package/src/widget/components/StaleListOverlay.tsx +51 -0
- package/src/widget/components/VoteButtons.tsx +55 -0
- package/src/widget/hooks/useReviewDetail.ts +55 -0
- package/src/widget/hooks/useReviewList.ts +136 -0
- package/src/widget/hooks/useReviewSummary.ts +24 -0
- package/src/widget/hooks/useVote.ts +68 -0
- package/src/widget/styles.ts +393 -0
- package/src/widget/util.ts +21 -0
- 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
|
+
}
|