@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,47 @@
1
+ /**
2
+ * Renders the `reviews_summary` section. v1 displays a placeholder
3
+ * — the partner host app is expected to supply the live aggregate
4
+ * (star rating, count, top quote) by mounting its own review-summary
5
+ * component as a child or sibling.
6
+ *
7
+ * The presence of this section in the metafield blob is the merchant's
8
+ * "show me the reviews summary on this product" toggle. Absence means
9
+ * "don't render this section." A future PR may expand this to render
10
+ * `betterreviews.summary` data directly when supplied via
11
+ * `BetterReviewsProvider`'s `summary` prop.
12
+ */
13
+
14
+ import React from 'react';
15
+ import { View, Text, StyleSheet } from 'react-native';
16
+ import { useBetterReviews } from '../BetterReviewsProvider.js';
17
+ import { applyTheme } from '../theme/applyTheme.js';
18
+
19
+ export function ReviewsSummarySection(): React.ReactElement {
20
+ const { theme } = useBetterReviews();
21
+ const resolved = applyTheme(theme);
22
+
23
+ return (
24
+ // Flat — no background fill / box; reads as normal page text.
25
+ <View style={styles.container}>
26
+ <Text
27
+ style={[
28
+ styles.label,
29
+ resolved.textColor ? { color: resolved.textColor } : null,
30
+ resolved.fontFamily ? { fontFamily: resolved.fontFamily } : null,
31
+ ]}
32
+ >
33
+ Customer reviews
34
+ </Text>
35
+ </View>
36
+ );
37
+ }
38
+
39
+ const styles = StyleSheet.create({
40
+ container: {
41
+ paddingVertical: 8,
42
+ },
43
+ label: {
44
+ fontSize: 16,
45
+ fontWeight: '600',
46
+ },
47
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Telemetry event types emitted by the package. Per SECURITY.md, all
3
+ * events are pre-redacted — no customer email, name, IP, transcript,
4
+ * or review content appears in any payload.
5
+ *
6
+ * Consumers (partner host apps) wire `onTelemetryEvent` on the
7
+ * `<BetterReviewsProvider>` to forward events to their own
8
+ * observability stack.
9
+ */
10
+
11
+ export type TelemetryEvent =
12
+ | { type: 'betterreviews.fetch.success'; block_id?: string; latency_ms?: number; cache_hit?: boolean }
13
+ | { type: 'betterreviews.fetch.failure'; block_id?: string; error_code: string; retry_attempt?: number }
14
+ | { type: 'betterreviews.signature.invalid'; endpoint?: string; source_ip_hash?: string }
15
+ | {
16
+ type: 'betterreviews.schema.violation';
17
+ schema_version: number;
18
+ violation_path?: string;
19
+ // Count of rows/items dropped in one response (one event per response,
20
+ // not one per row). Paths + counts only — never row contents.
21
+ dropped_count?: number;
22
+ }
23
+ | { type: 'betterreviews.webview.error'; error_code: string; url_origin?: string };
24
+
25
+ export type TelemetryHandler = (event: TelemetryEvent) => void;
26
+
27
+ const noopHandler: TelemetryHandler = () => {};
28
+
29
+ /**
30
+ * Returns a handler that swallows all events. Used as the default
31
+ * when the partner host app doesn't supply `onTelemetryEvent`.
32
+ */
33
+ export function noopTelemetry(): TelemetryHandler {
34
+ return noopHandler;
35
+ }
36
+
37
+ /**
38
+ * Wraps a handler so synchronous exceptions in the partner's
39
+ * implementation can't crash the renderer. Partner-side bugs become
40
+ * silent failures rather than UI outages.
41
+ */
42
+ export function safeTelemetry(handler: TelemetryHandler | undefined): TelemetryHandler {
43
+ if (!handler) return noopHandler;
44
+
45
+ return (event) => {
46
+ try {
47
+ handler(event);
48
+ } catch {
49
+ // Telemetry must never crash render. Swallow.
50
+ }
51
+ };
52
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Maps a `betterreviews_reactiv.theme` JSON blob to a flat StyleSheet
3
+ * record consumed by the section renderers. All fields are optional —
4
+ * absent fields fall back to RN's platform defaults.
5
+ *
6
+ * The shape matches `PPO.Reactiv.ContentResolver.build_theme/1` —
7
+ * flat color keys + corner_style enum + font_family enum.
8
+ */
9
+
10
+ import type { Theme } from '../../../schemas/src/theme.js';
11
+
12
+ // Maps `corner_style` enum to a numeric `borderRadius` value usable
13
+ // directly in RN style props.
14
+ const cornerRadiusMap: Record<string, number> = {
15
+ sharp: 0,
16
+ 'slightly-rounded': 8,
17
+ rounded: 20,
18
+ 'extra-rounded': 28,
19
+ };
20
+
21
+ // Maps `font_family` enum to an RN-friendly font family string.
22
+ // `'system'` resolves to platform default at render time (RN's null
23
+ // `fontFamily` already does this, so we omit the key entirely for
24
+ // 'system' to let RN fall back naturally).
25
+ const fontFamilyMap: Record<string, string | null> = {
26
+ system: null,
27
+ serif: 'Georgia',
28
+ 'sans-serif': 'System',
29
+ mono: 'Courier',
30
+ };
31
+
32
+ export interface ResolvedTheme {
33
+ primaryColor?: string;
34
+ backgroundColor?: string;
35
+ textColor?: string;
36
+ accentColor?: string;
37
+ borderRadius?: number;
38
+ fontFamily?: string;
39
+ }
40
+
41
+ /**
42
+ * Pure function — no React, no platform calls. Maps a Theme blob to a
43
+ * ResolvedTheme suitable for spreading into style props.
44
+ *
45
+ * Unknown enum values for `corner_style` / `font_family` resolve to
46
+ * `undefined` (the schema validator at the write boundary already
47
+ * rejects unknown values, but defensive in case of a malformed
48
+ * payload landing in the read path).
49
+ */
50
+ export function applyTheme(theme: Theme | null | undefined): ResolvedTheme {
51
+ if (!theme) return {};
52
+
53
+ const out: ResolvedTheme = {};
54
+
55
+ if (theme.primary_color) out.primaryColor = theme.primary_color;
56
+ if (theme.background_color) out.backgroundColor = theme.background_color;
57
+ if (theme.text_color) out.textColor = theme.text_color;
58
+ if (theme.accent_color) out.accentColor = theme.accent_color;
59
+
60
+ if (theme.corner_style && theme.corner_style in cornerRadiusMap) {
61
+ out.borderRadius = cornerRadiusMap[theme.corner_style];
62
+ }
63
+
64
+ if (theme.font_family && theme.font_family in fontFamilyMap) {
65
+ const resolved = fontFamilyMap[theme.font_family];
66
+ if (resolved !== null && resolved !== undefined) {
67
+ out.fontFamily = resolved;
68
+ }
69
+ }
70
+
71
+ return out;
72
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Resolved theme for the review widget.
3
+ *
4
+ * v1 policy (locked): the merchant's `betterreviews_reactiv.theme` supplies
5
+ * background / text / accent / corner-radius / font (via the existing
6
+ * `applyTheme`); everything else — star, verified, muted, border, scrim — is
7
+ * a FIXED neutral. Per-surface theming of those is a later additive schema
8
+ * bump, so widening this resolver is non-breaking.
9
+ *
10
+ * Color set mirrors the prototype's `THEME` constant.
11
+ */
12
+ import { applyTheme } from './applyTheme.js';
13
+ import type { Theme } from '../../../schemas/src/theme.js';
14
+
15
+ // Neutral defaults — zinc grayscale + a black CTA. The widget shows NO brand
16
+ // color unless the host passes `accent_color`/`background_color`/`text_color`.
17
+ // The only non-grayscale defaults are the gold star/bars and the green
18
+ // "Verified Buyer" badge (universal review conventions, fixed in v1).
19
+ export const WIDGET_NEUTRALS = {
20
+ bg: '#ffffff',
21
+ text: '#18181b', // zinc-900
22
+ accent: '#18181b', // black CTA
23
+ card: '#fafafa', // zinc-50
24
+ muted: '#f4f4f5', // zinc-100
25
+ mutedFg: '#71717a', // zinc-500
26
+ border: '#e4e4e7', // zinc-200
27
+ star: '#e8a808', // gold — fixed
28
+ verified: '#1c8c4d', // green — fixed Verified Buyer badge
29
+ verifiedBg: '#e2f1e9',
30
+ mark: 'rgba(228,228,231,0.6)', // zinc highlight
31
+ overflowScrim: 'rgba(0,0,0,0.55)',
32
+ } as const;
33
+
34
+ export interface WidgetTheme {
35
+ bg: string;
36
+ text: string;
37
+ accent: string;
38
+ card: string;
39
+ muted: string;
40
+ mutedFg: string;
41
+ border: string;
42
+ star: string;
43
+ verified: string;
44
+ verifiedBg: string;
45
+ mark: string;
46
+ overflowScrim: string;
47
+ /** From `corner_style` — applied to cards/buttons; undefined = RN default. */
48
+ cornerRadius?: number;
49
+ /** From `font_family`; undefined = platform default. */
50
+ fontFamily?: string;
51
+ }
52
+
53
+ /**
54
+ * Merge the merchant theme (bg/text/accent/corner/font) over the fixed
55
+ * neutrals. Pure — safe to call during render.
56
+ */
57
+ export function resolveWidgetTheme(theme: Theme | null | undefined): WidgetTheme {
58
+ const r = applyTheme(theme);
59
+ return {
60
+ ...WIDGET_NEUTRALS,
61
+ bg: r.backgroundColor ?? WIDGET_NEUTRALS.bg,
62
+ text: r.textColor ?? WIDGET_NEUTRALS.text,
63
+ accent: r.accentColor ?? WIDGET_NEUTRALS.accent,
64
+ cornerRadius: r.borderRadius,
65
+ fontFamily: r.fontFamily,
66
+ };
67
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pure helpers for messages posted from the embedded WebView page. Kept free
3
+ * of `react-native` / `react-native-webview` imports so it is unit-testable in
4
+ * plain Node (vitest), unlike `WebViewHost.tsx` itself.
5
+ */
6
+
7
+ /**
8
+ * True when the embedded page posted `{ "type": "close" }` (the chat's
9
+ * "Back to store" / done signal). Safe on any string — non-JSON, `null`, and
10
+ * primitives all return false.
11
+ */
12
+ export function isCloseSignal(data: string): boolean {
13
+ try {
14
+ const parsed: unknown = JSON.parse(data);
15
+ return (
16
+ typeof parsed === 'object' &&
17
+ parsed !== null &&
18
+ (parsed as { type?: unknown }).type === 'close'
19
+ );
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `<ReviewWidget>` — the public, stateful review-browsing surface.
3
+ *
4
+ * Renders INLINE — it owns no scroll container, so it drops into a host's
5
+ * product-page `ScrollView` as one section (below the product info /
6
+ * `ProductContentBlock`) and scrolls with the page. Paging is a "Load more"
7
+ * button, so no virtualization is needed.
8
+ *
9
+ * Theme + telemetry flow from `<BetterReviewsProvider>` context (same
10
+ * convention as `ProductContentBlock`); transport/auth flow from the host via
11
+ * the injected `client` (or `fetcher` + ids). Schema violations are emitted as
12
+ * telemetry from the client's async callback (post-render, so the b9357c30
13
+ * "no emit during render" rule holds).
14
+ */
15
+ import React, { useMemo, useState } from 'react';
16
+ import { ActivityIndicator, Pressable, Text, View } from 'react-native';
17
+ import { useBetterReviews } from '../BetterReviewsProvider.js';
18
+ import { createBetterReviewsClient, createMemoryVoteStore } from '../client/createBetterReviewsClient.js';
19
+ import type {
20
+ BetterReviewsClient,
21
+ Fetcher,
22
+ VoteStateStore,
23
+ WidgetReview,
24
+ WidgetSortValue,
25
+ } from '../client/types.js';
26
+ import { resolveWidgetTheme } from '../theme/widgetTheme.js';
27
+ import { MediaGallery, type GalleryPhoto } from './components/MediaGallery.js';
28
+ import { FilterToolbar, SortBar, WriteReviewBtn } from './components/FilterToolbar.js';
29
+ import { PulseMetrics, PulseSummary, SectionTitle } from './components/PulseSection.js';
30
+ import { ReviewCard } from './components/ReviewCard.js';
31
+ import { SortDrawer } from './components/SortDrawer.js';
32
+ import { StaleListOverlay } from './components/StaleListOverlay.js';
33
+ import { useReviewList } from './hooks/useReviewList.js';
34
+ import { useReviewSummary } from './hooks/useReviewSummary.js';
35
+ import { makeWidgetStyles } from './styles.js';
36
+ import { MediaReviewViewer } from './viewer/MediaReviewViewer.js';
37
+ import { WidgetProvider, type WidgetContextValue } from './WidgetContext.js';
38
+
39
+ export interface ReviewWidgetProps {
40
+ /** Primary: a client built via `createBetterReviewsClient`. */
41
+ client?: BetterReviewsClient;
42
+ /** Convenience: supply transport + ids and the widget builds the client. */
43
+ fetcher?: Fetcher;
44
+ storeId?: string;
45
+ productId?: string;
46
+ /** Persist "already voted" across launches; defaults to in-memory. */
47
+ voteStateStore?: VoteStateStore;
48
+ /** Host-owned write-review action; the CTA hides if omitted. */
49
+ onWriteReview?: () => void;
50
+ initialSort?: WidgetSortValue;
51
+ }
52
+
53
+ export function ReviewWidget(props: ReviewWidgetProps): React.ReactElement {
54
+ const { client: clientProp, fetcher, storeId, productId, voteStateStore, onWriteReview, initialSort } = props;
55
+ const { theme: rawTheme, emit } = useBetterReviews();
56
+
57
+ const client = useMemo<BetterReviewsClient>(() => {
58
+ if (clientProp) return clientProp;
59
+ if (!fetcher || !storeId || !productId) {
60
+ throw new Error(
61
+ '<ReviewWidget> requires either a `client` or `fetcher` + `storeId` + `productId`.',
62
+ );
63
+ }
64
+ return createBetterReviewsClient({
65
+ fetcher,
66
+ storeId,
67
+ productId,
68
+ onSchemaViolation: (info) =>
69
+ emit({
70
+ type: 'betterreviews.schema.violation',
71
+ schema_version: 1,
72
+ violation_path: info.firstBadPath,
73
+ dropped_count: info.droppedCount,
74
+ }),
75
+ });
76
+ }, [clientProp, fetcher, storeId, productId, emit]);
77
+
78
+ const theme = useMemo(() => resolveWidgetTheme(rawTheme), [rawTheme]);
79
+ const styles = useMemo(() => makeWidgetStyles(theme), [theme]);
80
+ const fallbackStore = useMemo(() => createMemoryVoteStore(), []);
81
+ const voteStore = voteStateStore ?? fallbackStore;
82
+
83
+ const list = useReviewList(client, emit, initialSort);
84
+ const summary = useReviewSummary(client);
85
+ const [viewer, setViewer] = useState<{ reviewId: number; mediaIndex: number } | null>(null);
86
+ const [sortOpen, setSortOpen] = useState(false);
87
+
88
+ const ctx = useMemo<WidgetContextValue>(
89
+ () => ({ client, voteStore, theme, styles, emit, onWriteReview }),
90
+ [client, voteStore, theme, styles, emit, onWriteReview],
91
+ );
92
+
93
+ const onOpenPhoto = (reviewId: number, mediaIndex: number) => setViewer({ reviewId, mediaIndex });
94
+
95
+ // Flatten reviews[].media → gallery photos (images, deduped by URL, capped).
96
+ const galleryPhotos = useMemo<GalleryPhoto[]>(() => {
97
+ const seen = new Set<string>();
98
+ const out: GalleryPhoto[] = [];
99
+ for (const r of list.reviews) {
100
+ const imgs = r.media.filter((m) => m.type === 'image');
101
+ imgs.forEach((m, i) => {
102
+ const key = m.full || m.thumbnail;
103
+ if (!key || seen.has(key)) return;
104
+ seen.add(key);
105
+ out.push({ thumbnail: m.thumbnail, full: m.full, reviewId: r.id, mediaIndex: i });
106
+ });
107
+ if (out.length >= 24) break;
108
+ }
109
+ return out.slice(0, 24);
110
+ }, [list.reviews]);
111
+
112
+ const reviewsWithMedia = useMemo<WidgetReview[]>(
113
+ () => list.reviews.filter((r) => r.media.some((m) => m.type === 'image')),
114
+ [list.reviews],
115
+ );
116
+
117
+ const header = (
118
+ <View>
119
+ <SectionTitle>Customer Reviews</SectionTitle>
120
+ {summary && (
121
+ <View style={styles.pulse}>
122
+ <PulseSummary summary={summary} />
123
+ <MediaGallery photos={galleryPhotos} onOpen={onOpenPhoto} />
124
+ <WriteReviewBtn />
125
+ <PulseMetrics summary={summary} />
126
+ </View>
127
+ )}
128
+ <FilterToolbar
129
+ rating={list.rating}
130
+ mediaOnly={list.mediaOnly}
131
+ search={list.searchInput}
132
+ onChange={list.onFilterChange}
133
+ />
134
+ <SortBar sort={list.sort} onOpenSort={() => setSortOpen(true)} />
135
+ <StaleListOverlay active={list.refetching} />
136
+ </View>
137
+ );
138
+
139
+ // Initial spinner.
140
+ if (list.loading && list.reviews.length === 0) {
141
+ return (
142
+ <WidgetProvider value={ctx}>
143
+ <View style={styles.center}>
144
+ <ActivityIndicator color={theme.accent} />
145
+ <Text style={styles.muted}>Loading reviews…</Text>
146
+ </View>
147
+ </WidgetProvider>
148
+ );
149
+ }
150
+
151
+ if (list.error && list.reviews.length === 0) {
152
+ return (
153
+ <WidgetProvider value={ctx}>
154
+ <View style={styles.center}>
155
+ <Text style={styles.errorLine}>Reviews unavailable</Text>
156
+ <Pressable onPress={list.reload} accessibilityHint="Tap to try loading reviews again">
157
+ <Text style={styles.retryLink}>Try again</Text>
158
+ </Pressable>
159
+ </View>
160
+ </WidgetProvider>
161
+ );
162
+ }
163
+
164
+ // total>0 but nothing rendered ⇒ rows failed to parse (contract break),
165
+ // distinct from a genuinely empty/filtered product.
166
+ const allDropped = list.reviews.length === 0 && (list.pagination?.total ?? 0) > 0;
167
+
168
+ return (
169
+ <WidgetProvider value={ctx}>
170
+ {/* Inline — no scroll container. The host's product-page ScrollView owns
171
+ the scroll; this is one section that flows with the page. */}
172
+ <View style={styles.list}>
173
+ {header}
174
+ {list.reviews.length === 0 ? (
175
+ <View style={styles.emptyState}>
176
+ <Text style={styles.emptyStateText}>
177
+ {allDropped ? 'Reviews could not be displayed.' : 'No reviews match your filter.'}
178
+ </Text>
179
+ {!allDropped && (
180
+ <Pressable onPress={() => list.onFilterChange({ rating: null, mediaOnly: false, search: '' })}>
181
+ <Text style={styles.emptyStateClear}>Clear filters</Text>
182
+ </Pressable>
183
+ )}
184
+ </View>
185
+ ) : (
186
+ list.reviews.map((item) => (
187
+ <View key={item.id} style={list.refetching ? { opacity: 0.5 } : undefined}>
188
+ <ReviewCard review={item} onOpenPhoto={onOpenPhoto} />
189
+ </View>
190
+ ))
191
+ )}
192
+ {list.pagination?.has_next && (
193
+ <View style={styles.footerWrap}>
194
+ <Pressable
195
+ onPress={list.onLoadMore}
196
+ disabled={list.loadingMore}
197
+ style={({ pressed }: { pressed: boolean }) => [
198
+ styles.showMore,
199
+ list.loadingMore && styles.showMoreDisabled,
200
+ pressed && { borderColor: theme.text },
201
+ ]}
202
+ accessibilityRole="button"
203
+ accessibilityLabel="Load more reviews"
204
+ >
205
+ {list.loadingMore ? (
206
+ <ActivityIndicator color={theme.text} />
207
+ ) : (
208
+ <Text style={styles.showMoreText}>Load more</Text>
209
+ )}
210
+ </Pressable>
211
+ </View>
212
+ )}
213
+ </View>
214
+ <SortDrawer
215
+ visible={sortOpen}
216
+ current={list.sort}
217
+ onSelect={list.setSort}
218
+ onClose={() => setSortOpen(false)}
219
+ />
220
+ {viewer && reviewsWithMedia.length > 0 && (
221
+ <MediaReviewViewer
222
+ reviews={reviewsWithMedia}
223
+ initialReviewId={viewer.reviewId}
224
+ initialMediaIndex={viewer.mediaIndex}
225
+ onClose={() => setViewer(null)}
226
+ />
227
+ )}
228
+ </WidgetProvider>
229
+ );
230
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Internal widget-scoped context. Threads the resolved client, vote-state
3
+ * store, resolved theme, the memoized StyleSheet, the telemetry emitter, and
4
+ * the host's write-review handler down to deeply-nested children (ReviewCard,
5
+ * ViewerPage) without prop-drilling through FlatList `renderItem`.
6
+ *
7
+ * This does NOT replace `BetterReviewsProvider` — it is mounted internally at
8
+ * the top of `<ReviewWidget>`.
9
+ */
10
+ import React, { createContext, useContext } from 'react';
11
+ import type { BetterReviewsClient, VoteStateStore } from '../client/types.js';
12
+ import type { TelemetryHandler } from '../telemetry.js';
13
+ import type { WidgetTheme } from '../theme/widgetTheme.js';
14
+ import type { WidgetStyles } from './styles.js';
15
+
16
+ export interface WidgetContextValue {
17
+ client: BetterReviewsClient;
18
+ voteStore: VoteStateStore;
19
+ theme: WidgetTheme;
20
+ styles: WidgetStyles;
21
+ emit: TelemetryHandler;
22
+ onWriteReview?: () => void;
23
+ }
24
+
25
+ const WidgetContext = createContext<WidgetContextValue | null>(null);
26
+
27
+ export function WidgetProvider({
28
+ value,
29
+ children,
30
+ }: {
31
+ value: WidgetContextValue;
32
+ children: React.ReactNode;
33
+ }) {
34
+ return <WidgetContext.Provider value={value}>{children}</WidgetContext.Provider>;
35
+ }
36
+
37
+ export function useWidget(): WidgetContextValue {
38
+ const ctx = useContext(WidgetContext);
39
+ if (!ctx) {
40
+ throw new Error('useWidget must be used within <ReviewWidget>');
41
+ }
42
+ return ctx;
43
+ }
@@ -0,0 +1,146 @@
1
+ import React from 'react';
2
+ import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
3
+ import {
4
+ IconArrowUpDown,
5
+ IconCamera,
6
+ IconChevronDown,
7
+ IconSearch,
8
+ IconStar,
9
+ IconXClose,
10
+ } from '../../icons/BRIcons.js';
11
+ import type { WidgetSortValue } from '../../client/types.js';
12
+ import { useWidget } from '../WidgetContext.js';
13
+ import { SORT_OPTIONS } from '../util.js';
14
+
15
+ export interface FilterPatch {
16
+ rating?: 1 | 2 | 3 | 4 | 5 | null;
17
+ mediaOnly?: boolean;
18
+ search?: string;
19
+ }
20
+
21
+ export interface FilterToolbarProps {
22
+ rating: 1 | 2 | 3 | 4 | 5 | null;
23
+ mediaOnly: boolean;
24
+ search: string;
25
+ onChange: (patch: FilterPatch) => void;
26
+ }
27
+
28
+ export function FilterToolbar({ rating, mediaOnly, search, onChange }: FilterToolbarProps) {
29
+ const { styles, theme } = useWidget();
30
+ const ratings: Array<1 | 2 | 3 | 4 | 5> = [5, 4, 3, 2, 1];
31
+ const showClear = !!(rating || mediaOnly || search);
32
+ return (
33
+ <View style={styles.toolbar}>
34
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.pillsRow}>
35
+ {ratings.map((r) => {
36
+ const active = rating === r;
37
+ return (
38
+ <Pressable
39
+ key={r}
40
+ onPress={() => onChange({ rating: active ? null : r })}
41
+ style={({ pressed }: { pressed: boolean }) => [
42
+ styles.pill,
43
+ pressed && !active && { borderColor: theme.text },
44
+ active && styles.pillActive,
45
+ ]}
46
+ accessibilityRole="button"
47
+ accessibilityState={{ selected: active }}
48
+ accessibilityLabel={`${r} stars filter`}
49
+ >
50
+ <Text style={[styles.pillText, active && styles.pillTextActive]}>{r}</Text>
51
+ <IconStar
52
+ size={10}
53
+ fillPercentage={100}
54
+ fillColor={active ? theme.bg : theme.star}
55
+ uid={`pill-${r}`}
56
+ />
57
+ </Pressable>
58
+ );
59
+ })}
60
+ <Pressable
61
+ onPress={() => onChange({ mediaOnly: !mediaOnly })}
62
+ style={({ pressed }: { pressed: boolean }) => [
63
+ styles.pill,
64
+ styles.pillWithIcon,
65
+ pressed && !mediaOnly && { borderColor: theme.text },
66
+ mediaOnly && styles.pillActive,
67
+ ]}
68
+ accessibilityRole="button"
69
+ accessibilityState={{ selected: mediaOnly }}
70
+ accessibilityLabel="Show only reviews with photos"
71
+ >
72
+ <IconCamera size={12} color={mediaOnly ? theme.bg : theme.text} />
73
+ <Text style={[styles.pillText, mediaOnly && styles.pillTextActive]}>Photos</Text>
74
+ </Pressable>
75
+ {showClear && (
76
+ <Pressable
77
+ onPress={() => onChange({ rating: null, mediaOnly: false, search: '' })}
78
+ style={({ pressed }: { pressed: boolean }) => [styles.clearBtn, pressed && { opacity: 0.6 }]}
79
+ >
80
+ <IconXClose size={12} color={theme.mutedFg} />
81
+ <Text style={styles.clearBtnText}>Clear</Text>
82
+ </Pressable>
83
+ )}
84
+ </ScrollView>
85
+ <View style={styles.searchWrap}>
86
+ <IconSearch size={14} color={theme.mutedFg} style={{ marginRight: 8 }} />
87
+ <TextInput
88
+ style={styles.searchInput}
89
+ placeholder="Search reviews..."
90
+ placeholderTextColor={theme.mutedFg}
91
+ value={search}
92
+ onChangeText={(t) => onChange({ search: t })}
93
+ maxLength={100}
94
+ autoCorrect={false}
95
+ autoCapitalize="none"
96
+ returnKeyType="search"
97
+ />
98
+ {!!search && (
99
+ <Pressable
100
+ onPress={() => onChange({ search: '' })}
101
+ hitSlop={8}
102
+ style={({ pressed }: { pressed: boolean }) => [styles.searchClear, pressed && { opacity: 0.5 }]}
103
+ accessibilityLabel="Clear search"
104
+ >
105
+ <IconXClose size={14} color={theme.mutedFg} />
106
+ </Pressable>
107
+ )}
108
+ </View>
109
+ {search.length > 0 && search.length < 2 && <Text style={styles.searchHint}>Keep typing…</Text>}
110
+ </View>
111
+ );
112
+ }
113
+
114
+ export function SortBar({ sort, onOpenSort }: { sort: WidgetSortValue; onOpenSort: () => void }) {
115
+ const { styles, theme } = useWidget();
116
+ const label = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Most relevant';
117
+ return (
118
+ <Pressable
119
+ onPress={onOpenSort}
120
+ style={({ pressed }: { pressed: boolean }) => [styles.sortBtn, pressed && { borderColor: theme.text }]}
121
+ accessibilityRole="button"
122
+ accessibilityLabel={`Sort: ${label}. Tap to change.`}
123
+ >
124
+ <IconArrowUpDown size={13} color={theme.text} />
125
+ <Text style={styles.sortBtnText}>{label}</Text>
126
+ <IconChevronDown size={12} color={theme.text} />
127
+ </Pressable>
128
+ );
129
+ }
130
+
131
+ // Solid pill CTA. Renders only when the host supplies `onWriteReview` — no
132
+ // dead button if the host hasn't wired the write-review action.
133
+ export function WriteReviewBtn({ label = 'Write a review' }: { label?: string }) {
134
+ const { styles, onWriteReview } = useWidget();
135
+ if (!onWriteReview) return null;
136
+ return (
137
+ <Pressable
138
+ onPress={onWriteReview}
139
+ style={({ pressed }: { pressed: boolean }) => [styles.writeReviewBtn, pressed && { opacity: 0.85 }]}
140
+ accessibilityRole="button"
141
+ accessibilityLabel={label}
142
+ >
143
+ <Text style={styles.writeReviewBtnText}>{label}</Text>
144
+ </Pressable>
145
+ );
146
+ }