@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,53 @@
1
+ import React from 'react';
2
+ import { Image, Pressable, Text, View } from 'react-native';
3
+ import { useWidget } from '../WidgetContext.js';
4
+
5
+ export interface GalleryPhoto {
6
+ thumbnail: string | null;
7
+ full: string | null;
8
+ reviewId: number;
9
+ mediaIndex: number;
10
+ }
11
+
12
+ export interface MediaGalleryProps {
13
+ photos: GalleryPhoto[];
14
+ onOpen: (reviewId: number, mediaIndex: number) => void;
15
+ }
16
+
17
+ // 3-col grid of up to 6 customer photos; the 6th tile shows a +N overflow.
18
+ export function MediaGallery({ photos, onOpen }: MediaGalleryProps) {
19
+ const { styles } = useWidget();
20
+ if (!photos || photos.length === 0) return null;
21
+ const visible = photos.slice(0, 6);
22
+ const overflow = Math.max(0, photos.length - 6);
23
+ return (
24
+ <View style={styles.mediaGallery}>
25
+ <Text style={styles.mediaGalleryLabel}>Customer Photos</Text>
26
+ <View style={styles.mediaGalleryGrid}>
27
+ {visible.map((p, i) => {
28
+ const isOverflowTile = i === 5 && overflow > 0;
29
+ const uri = p.thumbnail || p.full || undefined;
30
+ return (
31
+ <Pressable
32
+ key={`g${i}`}
33
+ onPress={() => onOpen(p.reviewId, p.mediaIndex || 0)}
34
+ style={({ pressed }: { pressed: boolean }) => [
35
+ styles.galleryTile,
36
+ pressed && { opacity: 0.7 },
37
+ ]}
38
+ accessibilityRole="button"
39
+ accessibilityLabel={`Customer photo ${i + 1} of ${photos.length}, double-tap to enlarge.`}
40
+ >
41
+ <Image source={{ uri }} style={styles.galleryTileImg} resizeMode="cover" />
42
+ {isOverflowTile && (
43
+ <View style={styles.galleryOverflow}>
44
+ <Text style={styles.galleryOverflowText}>+{overflow}</Text>
45
+ </View>
46
+ )}
47
+ </Pressable>
48
+ );
49
+ })}
50
+ </View>
51
+ </View>
52
+ );
53
+ }
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import { Text, View } from 'react-native';
3
+ import type { WidgetSummary } from '../../client/types.js';
4
+ import { IconStar } from '../../icons/BRIcons.js';
5
+ import { useWidget } from '../WidgetContext.js';
6
+ import { RatingStars } from './RatingStars.js';
7
+
8
+ export function SectionTitle({ children }: { children: React.ReactNode }) {
9
+ const { styles } = useWidget();
10
+ return <Text style={styles.sectionTitle}>{children}</Text>;
11
+ }
12
+
13
+ // Rating average + 5-bar distribution (5★ → 1★ descending).
14
+ export function PulseSummary({ summary }: { summary: WidgetSummary }) {
15
+ const { styles, theme } = useWidget();
16
+ const max = Math.max(...summary.breakdown, 1);
17
+ return (
18
+ <View style={styles.pulseSummary}>
19
+ <View style={styles.pulseRating}>
20
+ <Text style={styles.pulseScore}>{summary.average.toFixed(1)}</Text>
21
+ <RatingStars value={summary.average} size={15} uid="pulse" />
22
+ <Text style={styles.pulseCount}>
23
+ Based on {summary.total} {summary.total === 1 ? 'review' : 'reviews'}
24
+ </Text>
25
+ </View>
26
+ <View style={styles.pulseBars}>
27
+ {[5, 4, 3, 2, 1].map((star) => {
28
+ const count = summary.breakdown[star - 1] ?? 0;
29
+ const pct = max > 0 ? (count / max) * 100 : 0;
30
+ return (
31
+ <View key={star} style={styles.barRow}>
32
+ <View style={styles.barLabel}>
33
+ <Text style={styles.barLabelText}>{star}</Text>
34
+ <IconStar size={12} fillPercentage={100} fillColor={theme.star} uid={`bar-${star}`} />
35
+ </View>
36
+ <View style={styles.barTrack}>
37
+ <View style={[styles.barFill, { width: `${Math.max(0, pct)}%` }]} />
38
+ </View>
39
+ <Text style={styles.barCount}>{count}</Text>
40
+ </View>
41
+ );
42
+ })}
43
+ </View>
44
+ </View>
45
+ );
46
+ }
47
+
48
+ function Metric({ value, label }: { value: string | number; label: string }) {
49
+ const { styles } = useWidget();
50
+ return (
51
+ <View style={styles.metric}>
52
+ <Text style={styles.metricValue}>{value}</Text>
53
+ <Text style={styles.metricLabel}>{label}</Text>
54
+ </View>
55
+ );
56
+ }
57
+
58
+ // 4-column reviews / positive% / photos / verified band.
59
+ export function PulseMetrics({ summary }: { summary: WidgetSummary }) {
60
+ const { styles } = useWidget();
61
+ return (
62
+ <View style={styles.metrics}>
63
+ <Metric value={summary.total} label="REVIEWS" />
64
+ <Metric value={`${summary.positivePct}%`} label="POSITIVE" />
65
+ <Metric value={summary.photoCount} label="PHOTOS" />
66
+ <Metric value={summary.verifiedCount} label="VERIFIED" />
67
+ </View>
68
+ );
69
+ }
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { IconStar } from '../../icons/BRIcons.js';
4
+ import { useWidget } from '../WidgetContext.js';
5
+
6
+ export interface RatingStarsProps {
7
+ value: number;
8
+ size?: number;
9
+ /** Stable per-instance seed for the gradient ids (review id + context). */
10
+ uid?: string;
11
+ }
12
+
13
+ // Five stars; each gets a 0/50/100 fill from the fractional rating. Rounding
14
+ // matches the storefront: <0.25 empty, 0.25–0.75 half, ≥0.75 full.
15
+ export function RatingStars({ value, size = 13, uid = 'r' }: RatingStarsProps) {
16
+ const { theme } = useWidget();
17
+ return (
18
+ <View
19
+ style={{ flexDirection: 'row', alignItems: 'center' }}
20
+ accessibilityRole="image"
21
+ accessibilityLabel={`${value} stars out of 5`}
22
+ >
23
+ {[1, 2, 3, 4, 5].map((i) => {
24
+ const diff = value - (i - 1);
25
+ const p = diff >= 0.75 ? 100 : diff >= 0.25 ? 50 : 0;
26
+ return (
27
+ <View key={i} style={{ marginRight: 1 }}>
28
+ <IconStar
29
+ size={size}
30
+ fillPercentage={p}
31
+ fillColor={theme.star}
32
+ emptyColor={theme.muted}
33
+ uid={`${uid}-${i}`}
34
+ />
35
+ </View>
36
+ );
37
+ })}
38
+ </View>
39
+ );
40
+ }
@@ -0,0 +1,114 @@
1
+ import React from 'react';
2
+ import { Image, Pressable, ScrollView, Text, View } from 'react-native';
3
+ import type { WidgetReview } from '../../client/types.js';
4
+ import { IconShieldCheck } from '../../icons/BRIcons.js';
5
+ import { useWidget } from '../WidgetContext.js';
6
+ import { useReviewDetail } from '../hooks/useReviewDetail.js';
7
+ import { getInitials } from '../util.js';
8
+ import { RatingStars } from './RatingStars.js';
9
+ import { VoteButtons } from './VoteButtons.js';
10
+
11
+ export interface ReviewCardProps {
12
+ review: WidgetReview;
13
+ onOpenPhoto: (reviewId: number, mediaIndex: number) => void;
14
+ }
15
+
16
+ export function ReviewCard({ review, onOpenPhoto }: ReviewCardProps) {
17
+ const { styles, theme } = useWidget();
18
+ const { expanded, body, error, toggle } = useReviewDetail(review);
19
+
20
+ const media = review.media.filter((m) => m.type === 'image');
21
+ const visibleTags = review.tags.slice(0, 4);
22
+ const reply = review.merchant_reply;
23
+ const wasTruncated = review.body_truncated === true;
24
+
25
+ return (
26
+ <View style={styles.card}>
27
+ <View style={styles.cardHeader}>
28
+ <View style={styles.avatar}>
29
+ <Text style={styles.avatarText}>{getInitials(review.author)}</Text>
30
+ </View>
31
+ <View style={styles.cardInfo}>
32
+ <View style={styles.cardTopRow}>
33
+ <Text style={styles.cardAuthor} numberOfLines={1}>
34
+ {review.author || 'Anonymous'}
35
+ </Text>
36
+ <Text style={styles.cardDate}>{review.date}</Text>
37
+ </View>
38
+ <View style={styles.cardRatingRow}>
39
+ <RatingStars value={review.rating} size={13} uid={`c-${review.id}`} />
40
+ {review.verified && (
41
+ <View style={styles.verifiedBadge}>
42
+ <IconShieldCheck size={11} color={theme.verified} />
43
+ <Text style={styles.verifiedBadgeText}>Verified Buyer</Text>
44
+ </View>
45
+ )}
46
+ </View>
47
+ </View>
48
+ </View>
49
+
50
+ {!!review.title && <Text style={styles.cardTitle}>{review.title}</Text>}
51
+ {!!body && (
52
+ <Text style={styles.cardBody} numberOfLines={wasTruncated && !expanded ? 3 : undefined}>
53
+ {body}
54
+ </Text>
55
+ )}
56
+ {wasTruncated && (
57
+ <Pressable
58
+ onPress={toggle}
59
+ style={({ pressed }: { pressed: boolean }) => [styles.readMoreBtn, pressed && { opacity: 0.6 }]}
60
+ accessibilityRole="button"
61
+ accessibilityLabel={expanded ? 'Read less' : 'Read more'}
62
+ >
63
+ <Text style={styles.readMoreBtnText}>{expanded ? 'Read less' : 'Read more'}</Text>
64
+ </Pressable>
65
+ )}
66
+ {error && <Text style={styles.readMoreErr}>Could not load full review — showing preview.</Text>}
67
+
68
+ {media.length > 0 && (
69
+ <ScrollView
70
+ horizontal
71
+ showsHorizontalScrollIndicator={false}
72
+ style={styles.cardMedia}
73
+ contentContainerStyle={{ gap: 8 }}
74
+ >
75
+ {media.map((m, i) => (
76
+ <Pressable
77
+ key={i}
78
+ onPress={() => onOpenPhoto(review.id, i)}
79
+ style={({ pressed }: { pressed: boolean }) => [styles.mediaThumb, pressed && { opacity: 0.7 }]}
80
+ accessibilityRole="button"
81
+ accessibilityLabel={`Customer photo ${i + 1} of ${media.length}, double-tap to enlarge.`}
82
+ >
83
+ <Image source={{ uri: m.thumbnail || undefined }} style={styles.mediaThumbImg} />
84
+ </Pressable>
85
+ ))}
86
+ </ScrollView>
87
+ )}
88
+
89
+ {visibleTags.length > 0 && (
90
+ <View style={styles.tagsRow}>
91
+ {visibleTags.map((tag, i) => (
92
+ <View key={i} style={styles.tag}>
93
+ <Text style={styles.tagText}>{tag}</Text>
94
+ </View>
95
+ ))}
96
+ </View>
97
+ )}
98
+
99
+ <View style={styles.actionsRow}>
100
+ <VoteButtons review={review} />
101
+ </View>
102
+
103
+ {reply && (
104
+ <View style={styles.merchantReply}>
105
+ <View style={styles.merchantReplyHeader}>
106
+ <Text style={styles.merchantReplyAuthor}>{reply.author_name}</Text>
107
+ <Text style={styles.merchantReplyDate}>{reply.date}</Text>
108
+ </View>
109
+ <Text style={styles.merchantReplyBody}>{reply.body}</Text>
110
+ </View>
111
+ )}
112
+ </View>
113
+ );
114
+ }
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ import { Modal, Pressable, Text, View } from 'react-native';
3
+ import type { WidgetSortValue } from '../../client/types.js';
4
+ import { useWidget } from '../WidgetContext.js';
5
+ import { SORT_OPTIONS } from '../util.js';
6
+
7
+ export interface SortDrawerProps {
8
+ visible: boolean;
9
+ current: WidgetSortValue;
10
+ onSelect: (value: WidgetSortValue) => void;
11
+ onClose: () => void;
12
+ }
13
+
14
+ // Bottom-sheet sort picker.
15
+ export function SortDrawer({ visible, current, onSelect, onClose }: SortDrawerProps) {
16
+ const { styles, theme } = useWidget();
17
+ return (
18
+ <Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
19
+ <Pressable style={styles.modalBackdrop} onPress={onClose} />
20
+ <View style={styles.drawer} accessibilityRole="menu">
21
+ <View style={styles.drawerHandle} />
22
+ <Text style={styles.drawerTitle}>Sort reviews</Text>
23
+ {SORT_OPTIONS.map((opt) => {
24
+ const active = opt.value === current;
25
+ return (
26
+ <Pressable
27
+ key={opt.value}
28
+ onPress={() => {
29
+ onSelect(opt.value);
30
+ onClose();
31
+ }}
32
+ style={({ pressed }: { pressed: boolean }) => [
33
+ styles.drawerOption,
34
+ pressed && { backgroundColor: theme.card },
35
+ ]}
36
+ accessibilityRole="menuitem"
37
+ accessibilityState={{ selected: active }}
38
+ >
39
+ <Text style={[styles.drawerOptionText, active && styles.drawerOptionTextActive]}>
40
+ {opt.label}
41
+ </Text>
42
+ {active && <View style={styles.drawerActiveDot} />}
43
+ </Pressable>
44
+ );
45
+ })}
46
+ </View>
47
+ </Modal>
48
+ );
49
+ }
@@ -0,0 +1,51 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { Animated, Easing, View } from 'react-native';
3
+ import { useWidget } from '../WidgetContext.js';
4
+ import { SCREEN_W } from '../styles.js';
5
+
6
+ // 2px animated top progress bar shown during a refetch (filter/sort/search
7
+ // change). 150ms grace before appearing so a fast response doesn't flash it;
8
+ // existing cards stay visible (dimmed) rather than blanking.
9
+ export function StaleListOverlay({ active }: { active: boolean }) {
10
+ const { styles } = useWidget();
11
+ const slide = useRef(new Animated.Value(0)).current;
12
+ const [shown, setShown] = useState(false);
13
+
14
+ useEffect(() => {
15
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
16
+ if (active) {
17
+ graceTimer = setTimeout(() => setShown(true), 150);
18
+ } else {
19
+ setShown(false);
20
+ }
21
+ return () => clearTimeout(graceTimer);
22
+ }, [active]);
23
+
24
+ useEffect(() => {
25
+ if (!shown) {
26
+ slide.setValue(0);
27
+ return;
28
+ }
29
+ const loop = Animated.loop(
30
+ Animated.timing(slide, {
31
+ toValue: 1,
32
+ duration: 1400,
33
+ easing: Easing.linear,
34
+ useNativeDriver: true,
35
+ }),
36
+ );
37
+ loop.start();
38
+ return () => loop.stop();
39
+ }, [shown, slide]);
40
+
41
+ if (!shown) return null;
42
+ const translateX = slide.interpolate({
43
+ inputRange: [0, 1],
44
+ outputRange: [-SCREEN_W * 0.4, SCREEN_W * 1.4],
45
+ });
46
+ return (
47
+ <View style={styles.progressTrack} pointerEvents="none">
48
+ <Animated.View style={[styles.progressFill, { transform: [{ translateX }] }]} />
49
+ </View>
50
+ );
51
+ }
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { Pressable, Text } from 'react-native';
3
+ import type { WidgetReview } from '../../client/types.js';
4
+ import { IconThumbsDown, IconThumbsUp } from '../../icons/BRIcons.js';
5
+ import { useWidget } from '../WidgetContext.js';
6
+ import { useVote } from '../hooks/useVote.js';
7
+
8
+ // Helpful / unhelpful pair, wired to the live vote endpoint via `useVote`.
9
+ // Shared by `ReviewCard` and the viewer's `ViewerPage`. The caller supplies
10
+ // the row wrapper (`actionsRow` / `viewerActionsRow`).
11
+ export function VoteButtons({ review }: { review: WidgetReview }) {
12
+ const { styles, theme } = useWidget();
13
+ const { vote, helpfulCount, unhelpfulCount, press } = useVote(review);
14
+ // Single-selection: once a side is chosen, the other is disabled until undone.
15
+ const helpfulDisabled = vote === 'unhelpful';
16
+ const unhelpfulDisabled = vote === 'helpful';
17
+ return (
18
+ <>
19
+ <Pressable
20
+ onPress={() => press('helpful')}
21
+ disabled={helpfulDisabled}
22
+ style={({ pressed }: { pressed: boolean }) => [
23
+ styles.actionBtn,
24
+ helpfulDisabled && { opacity: 0.4 },
25
+ pressed && { opacity: 0.6 },
26
+ ]}
27
+ accessibilityRole="button"
28
+ accessibilityState={{ selected: vote === 'helpful', disabled: helpfulDisabled }}
29
+ accessibilityLabel={`Helpful (${helpfulCount})`}
30
+ >
31
+ <IconThumbsUp size={14} color={vote === 'helpful' ? theme.accent : theme.mutedFg} />
32
+ <Text style={[styles.actionBtnText, vote === 'helpful' && styles.actionBtnTextActive]}>
33
+ Helpful ({helpfulCount})
34
+ </Text>
35
+ </Pressable>
36
+ <Pressable
37
+ onPress={() => press('unhelpful')}
38
+ disabled={unhelpfulDisabled}
39
+ style={({ pressed }: { pressed: boolean }) => [
40
+ styles.actionBtn,
41
+ unhelpfulDisabled && { opacity: 0.4 },
42
+ pressed && { opacity: 0.6 },
43
+ ]}
44
+ accessibilityRole="button"
45
+ accessibilityState={{ selected: vote === 'unhelpful', disabled: unhelpfulDisabled }}
46
+ accessibilityLabel={`Not helpful (${unhelpfulCount})`}
47
+ >
48
+ <IconThumbsDown size={14} color={vote === 'unhelpful' ? theme.accent : theme.mutedFg} />
49
+ <Text style={[styles.actionBtnText, vote === 'unhelpful' && styles.actionBtnTextActive]}>
50
+ ({unhelpfulCount})
51
+ </Text>
52
+ </Pressable>
53
+ </>
54
+ );
55
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Read-more state for one review. Centralizes the "fetch the full body on
3
+ * expand" logic so BOTH `ReviewCard` and the viewer's `ViewerPage` go through
4
+ * the client (which reads the FLAT `detail.body`) — the prototype's
5
+ * `detail.review.body` bug cannot recur because components never touch the
6
+ * raw shape.
7
+ */
8
+ import { useCallback, useEffect, useState } from 'react';
9
+ import type { WidgetReview } from '../../client/types.js';
10
+ import { useWidget } from '../WidgetContext.js';
11
+
12
+ export interface ReviewDetailState {
13
+ expanded: boolean;
14
+ body: string;
15
+ error: boolean;
16
+ toggle: () => void;
17
+ }
18
+
19
+ export function useReviewDetail(review: WidgetReview, preFetchedBody?: string): ReviewDetailState {
20
+ const { client, emit } = useWidget();
21
+ const [expanded, setExpanded] = useState(false);
22
+ const [fullBody, setFullBody] = useState<string | null>(preFetchedBody ?? null);
23
+ const [error, setError] = useState(false);
24
+
25
+ // Adopt an eagerly-prefetched body (the viewer pre-fetches the next review).
26
+ useEffect(() => {
27
+ if (preFetchedBody && !fullBody) setFullBody(preFetchedBody);
28
+ }, [preFetchedBody, fullBody]);
29
+
30
+ const toggle = useCallback(() => {
31
+ if (expanded) {
32
+ setExpanded(false);
33
+ return;
34
+ }
35
+ if (fullBody || !review.body_truncated) {
36
+ setExpanded(true);
37
+ return;
38
+ }
39
+ client
40
+ .getReviewDetail(review.id)
41
+ .then((detail) => {
42
+ setFullBody(detail.body);
43
+ setExpanded(true);
44
+ })
45
+ .catch(() => {
46
+ // Show the preview with a soft notice; never block the UI.
47
+ setExpanded(true);
48
+ setError(true);
49
+ emit({ type: 'betterreviews.fetch.failure', error_code: 'detail_failed' });
50
+ });
51
+ }, [expanded, fullBody, review.id, review.body_truncated, client, emit]);
52
+
53
+ const body = expanded && fullBody ? fullBody : review.body;
54
+ return { expanded, body, error, toggle };
55
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * List state machine: pagination, sort, rating/media/search filters (300ms
3
+ * search debounce), load-more, pull-to-refresh, and the stale-list overlay
4
+ * flag. Runs at the container level, so it takes `client`/`emit` as args
5
+ * rather than reading the widget context (which is mounted below it).
6
+ */
7
+ import { useCallback, useEffect, useRef, useState } from 'react';
8
+ import type {
9
+ BetterReviewsClient,
10
+ WidgetPagination,
11
+ WidgetReview,
12
+ WidgetSortValue,
13
+ } from '../../client/types.js';
14
+ import type { TelemetryHandler } from '../../telemetry.js';
15
+ import type { FilterPatch } from '../components/FilterToolbar.js';
16
+
17
+ const PAGE_SIZE = 10;
18
+
19
+ export interface ReviewListState {
20
+ reviews: WidgetReview[];
21
+ pagination: WidgetPagination | null;
22
+ sort: WidgetSortValue;
23
+ rating: 1 | 2 | 3 | 4 | 5 | null;
24
+ mediaOnly: boolean;
25
+ searchInput: string;
26
+ loading: boolean;
27
+ refetching: boolean;
28
+ loadingMore: boolean;
29
+ error: boolean;
30
+ setSort: (s: WidgetSortValue) => void;
31
+ onFilterChange: (patch: FilterPatch) => void;
32
+ onLoadMore: () => void;
33
+ reload: () => void;
34
+ }
35
+
36
+ export function useReviewList(
37
+ client: BetterReviewsClient,
38
+ emit: TelemetryHandler,
39
+ initialSort: WidgetSortValue = 'most_relevant',
40
+ ): ReviewListState {
41
+ const [page, setPage] = useState(1);
42
+ const [reviews, setReviews] = useState<WidgetReview[]>([]);
43
+ const [pagination, setPagination] = useState<WidgetPagination | null>(null);
44
+ const [sort, setSort] = useState<WidgetSortValue>(initialSort);
45
+ const [rating, setRating] = useState<1 | 2 | 3 | 4 | 5 | null>(null);
46
+ const [mediaOnly, setMediaOnly] = useState(false);
47
+ const [searchInput, setSearchInput] = useState('');
48
+ const [search, setSearch] = useState('');
49
+ const [loading, setLoading] = useState(true);
50
+ const [refetching, setRefetching] = useState(false);
51
+ const [loadingMore, setLoadingMore] = useState(false);
52
+ const [error, setError] = useState(false);
53
+
54
+ const inflightRef = useRef<AbortController | null>(null);
55
+ const initialLoadDoneRef = useRef(false);
56
+
57
+ // Debounce keystrokes → committed `search` (drives fetches).
58
+ useEffect(() => {
59
+ const t = setTimeout(() => setSearch(searchInput), 300);
60
+ return () => clearTimeout(t);
61
+ }, [searchInput]);
62
+
63
+ const loadFirstPage = useCallback(async () => {
64
+ inflightRef.current?.abort();
65
+ const ctrl = new AbortController();
66
+ inflightRef.current = ctrl;
67
+
68
+ if (!initialLoadDoneRef.current) setLoading(true);
69
+ else setRefetching(true);
70
+ setError(false);
71
+
72
+ try {
73
+ const res = await client.listReviews(
74
+ { page: 1, perPage: PAGE_SIZE, sort, rating, mediaOnly, search },
75
+ ctrl.signal,
76
+ );
77
+ if (ctrl.signal.aborted) return;
78
+ setReviews(res.reviews);
79
+ setPagination(res.pagination);
80
+ setPage(1);
81
+ initialLoadDoneRef.current = true;
82
+ } catch {
83
+ if (ctrl.signal.aborted) return;
84
+ setError(true);
85
+ } finally {
86
+ if (!ctrl.signal.aborted) {
87
+ setLoading(false);
88
+ setRefetching(false);
89
+ }
90
+ }
91
+ }, [client, sort, rating, mediaOnly, search]);
92
+
93
+ useEffect(() => {
94
+ loadFirstPage();
95
+ return () => inflightRef.current?.abort();
96
+ }, [loadFirstPage]);
97
+
98
+ const onLoadMore = useCallback(async () => {
99
+ if (loadingMore || !pagination?.has_next) return;
100
+ setLoadingMore(true);
101
+ try {
102
+ const next = page + 1;
103
+ const res = await client.listReviews({ page: next, perPage: PAGE_SIZE, sort, rating, mediaOnly, search });
104
+ setReviews((prev) => [...prev, ...res.reviews]);
105
+ setPagination(res.pagination);
106
+ setPage(next);
107
+ } catch {
108
+ emit({ type: 'betterreviews.fetch.failure', error_code: 'load_more_failed' });
109
+ } finally {
110
+ setLoadingMore(false);
111
+ }
112
+ }, [loadingMore, pagination, page, client, sort, rating, mediaOnly, search, emit]);
113
+
114
+ const onFilterChange = useCallback((patch: FilterPatch) => {
115
+ if ('rating' in patch) setRating(patch.rating ?? null);
116
+ if ('mediaOnly' in patch) setMediaOnly(!!patch.mediaOnly);
117
+ if ('search' in patch) setSearchInput(patch.search ?? '');
118
+ }, []);
119
+
120
+ return {
121
+ reviews,
122
+ pagination,
123
+ sort,
124
+ rating,
125
+ mediaOnly,
126
+ searchInput,
127
+ loading,
128
+ refetching,
129
+ loadingMore,
130
+ error,
131
+ setSort,
132
+ onFilterChange,
133
+ onLoadMore,
134
+ reload: loadFirstPage,
135
+ };
136
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Product-level summary. Keyed off the client ONLY (the client closes over
3
+ * store+product) — it does NOT refetch when sort/rating/media/search change,
4
+ * so the 7-call synthesis runs once per product, not once per interaction.
5
+ */
6
+ import { useEffect, useState } from 'react';
7
+ import type { BetterReviewsClient, WidgetSummary } from '../../client/types.js';
8
+
9
+ export function useReviewSummary(client: BetterReviewsClient): WidgetSummary | null {
10
+ const [summary, setSummary] = useState<WidgetSummary | null>(null);
11
+
12
+ useEffect(() => {
13
+ const ctrl = new AbortController();
14
+ client
15
+ .fetchSummary(ctrl.signal)
16
+ .then((s) => {
17
+ if (!ctrl.signal.aborted) setSummary(s);
18
+ })
19
+ .catch(() => {});
20
+ return () => ctrl.abort();
21
+ }, [client]);
22
+
23
+ return summary;
24
+ }