@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
package/dist/index.mjs ADDED
@@ -0,0 +1,2346 @@
1
+ // src/BetterReviewsProvider.tsx
2
+ import { createContext, useContext, useMemo } from "react";
3
+
4
+ // src/telemetry.ts
5
+ var noopHandler = () => {
6
+ };
7
+ function noopTelemetry() {
8
+ return noopHandler;
9
+ }
10
+ function safeTelemetry(handler) {
11
+ if (!handler) return noopHandler;
12
+ return (event) => {
13
+ try {
14
+ handler(event);
15
+ } catch {
16
+ }
17
+ };
18
+ }
19
+
20
+ // src/BetterReviewsProvider.tsx
21
+ import { jsx } from "react/jsx-runtime";
22
+ var defaultValue = {
23
+ theme: null,
24
+ config: null,
25
+ emit: () => {
26
+ }
27
+ };
28
+ var BetterReviewsContext = createContext(defaultValue);
29
+ function BetterReviewsProvider({
30
+ theme = null,
31
+ config = null,
32
+ onTelemetryEvent,
33
+ children
34
+ }) {
35
+ const emit = useMemo(() => safeTelemetry(onTelemetryEvent), [onTelemetryEvent]);
36
+ const value = useMemo(
37
+ () => ({ theme, config, emit }),
38
+ [theme, config, emit]
39
+ );
40
+ return /* @__PURE__ */ jsx(BetterReviewsContext.Provider, { value, children });
41
+ }
42
+ function useBetterReviews() {
43
+ return useContext(BetterReviewsContext);
44
+ }
45
+
46
+ // src/ProductContentBlock.tsx
47
+ import { useEffect, useMemo as useMemo2 } from "react";
48
+ import { View as View3 } from "react-native";
49
+ import * as v2 from "valibot";
50
+
51
+ // ../schemas/src/product_content_block.ts
52
+ import * as v from "valibot";
53
+ var featuresSectionSchema = v.object({
54
+ type: v.literal("features"),
55
+ bullets: v.pipe(
56
+ v.array(v.pipe(v.string(), v.maxLength(80))),
57
+ v.maxLength(6)
58
+ )
59
+ });
60
+ var reviewsSummarySectionSchema = v.object({
61
+ type: v.literal("reviews_summary")
62
+ });
63
+ var sectionSchema = v.variant("type", [
64
+ featuresSectionSchema,
65
+ reviewsSummarySectionSchema
66
+ ]);
67
+ var productContentBlockSchema = v.object({
68
+ v: v.literal(1),
69
+ updated_at: v.pipe(v.string(), v.isoTimestamp()),
70
+ sections: v.array(sectionSchema)
71
+ });
72
+
73
+ // package.json
74
+ var package_default = {
75
+ name: "@betterreviews/react-native",
76
+ version: "1.0.0",
77
+ description: "React Native renderer for BetterReviews mobile PDP content. Consumes the betterreviews_reactiv.* Shopify metafields and renders product content blocks (features, reviews_summary) themed to the merchant.",
78
+ license: "SEE LICENSE IN LICENSE",
79
+ main: "./dist/index.js",
80
+ module: "./dist/index.mjs",
81
+ types: "./dist/index.d.ts",
82
+ exports: {
83
+ ".": {
84
+ import: "./dist/index.mjs",
85
+ require: "./dist/index.js",
86
+ types: "./dist/index.d.ts"
87
+ }
88
+ },
89
+ files: [
90
+ "dist",
91
+ "src",
92
+ "README.md",
93
+ "LICENSE",
94
+ "SECURITY.md"
95
+ ],
96
+ publishConfig: {
97
+ access: "public",
98
+ registry: "https://registry.npmjs.org/"
99
+ },
100
+ repository: {
101
+ type: "git",
102
+ url: "git+https://github.com/Product-Page-Optimizer/ppo.git",
103
+ directory: "react-native/packages/react-native"
104
+ },
105
+ bugs: {
106
+ url: "https://github.com/Product-Page-Optimizer/ppo/issues",
107
+ email: "security@betterreviews.app"
108
+ },
109
+ homepage: "https://betterreviews.app",
110
+ engines: {
111
+ node: ">=20.0.0"
112
+ },
113
+ scripts: {
114
+ prepublishOnly: "yarn typecheck && yarn test && yarn build",
115
+ build: "tsup src/index.ts --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview --external react-native-svg --external react-native-gesture-handler",
116
+ test: "vitest run",
117
+ "test:rn": "jest",
118
+ typecheck: "tsc --noEmit",
119
+ smoke: "bash scripts/smoke-from-pack.sh"
120
+ },
121
+ peerDependencies: {
122
+ react: ">=18.0.0",
123
+ "react-native": ">=0.74.0",
124
+ "react-native-gesture-handler": ">=2.16.0",
125
+ "react-native-svg": ">=15.0.0",
126
+ "react-native-webview": ">=13.0.0"
127
+ },
128
+ dependencies: {
129
+ valibot: "^1.0.0"
130
+ },
131
+ devDependencies: {
132
+ "@babel/core": "^7.25.0",
133
+ "@babel/plugin-syntax-import-attributes": "^7.29.7",
134
+ "@react-native/babel-preset": "0.74.89",
135
+ "@testing-library/react-native": "^12.5.0",
136
+ "@types/jest": "^29.5.0",
137
+ "@types/node": "^22.0.0",
138
+ "@types/react": "^18.0.0",
139
+ "babel-jest": "^29.7.0",
140
+ jest: "^29.7.0",
141
+ react: "^18.0.0",
142
+ "react-native": "^0.74.0",
143
+ "react-native-gesture-handler": "^2.28.0",
144
+ "react-native-svg": "^15.12.0",
145
+ "react-native-webview": "^13.0.0",
146
+ "react-test-renderer": "18.2.0",
147
+ tsup: "^8.3.0",
148
+ typescript: "^5.6.0",
149
+ vitest: "^2.1.0"
150
+ }
151
+ };
152
+
153
+ // src/minSdkVersion.ts
154
+ var SDK_VERSION = package_default.version;
155
+ function parse(version) {
156
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
157
+ if (!match) return null;
158
+ return {
159
+ major: parseInt(match[1], 10),
160
+ minor: parseInt(match[2], 10),
161
+ patch: parseInt(match[3], 10)
162
+ };
163
+ }
164
+ function meetsFloor(floor) {
165
+ if (!floor) return true;
166
+ const required = parse(floor);
167
+ const have = parse(SDK_VERSION);
168
+ if (!required || !have) return true;
169
+ if (have.major !== required.major) return have.major > required.major;
170
+ if (have.minor !== required.minor) return have.minor > required.minor;
171
+ return have.patch >= required.patch;
172
+ }
173
+
174
+ // src/sections/FeaturesSection.tsx
175
+ import { View, Text, StyleSheet } from "react-native";
176
+
177
+ // src/theme/applyTheme.ts
178
+ var cornerRadiusMap = {
179
+ sharp: 0,
180
+ "slightly-rounded": 8,
181
+ rounded: 20,
182
+ "extra-rounded": 28
183
+ };
184
+ var fontFamilyMap = {
185
+ system: null,
186
+ serif: "Georgia",
187
+ "sans-serif": "System",
188
+ mono: "Courier"
189
+ };
190
+ function applyTheme(theme) {
191
+ if (!theme) return {};
192
+ const out = {};
193
+ if (theme.primary_color) out.primaryColor = theme.primary_color;
194
+ if (theme.background_color) out.backgroundColor = theme.background_color;
195
+ if (theme.text_color) out.textColor = theme.text_color;
196
+ if (theme.accent_color) out.accentColor = theme.accent_color;
197
+ if (theme.corner_style && theme.corner_style in cornerRadiusMap) {
198
+ out.borderRadius = cornerRadiusMap[theme.corner_style];
199
+ }
200
+ if (theme.font_family && theme.font_family in fontFamilyMap) {
201
+ const resolved = fontFamilyMap[theme.font_family];
202
+ if (resolved !== null && resolved !== void 0) {
203
+ out.fontFamily = resolved;
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ // src/sections/FeaturesSection.tsx
210
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
211
+ function FeaturesSection({ bullets }) {
212
+ const { theme } = useBetterReviews();
213
+ const resolved = applyTheme(theme);
214
+ return (
215
+ // Renders flat — like normal page text, no background fill / box. Inherits
216
+ // the host's surrounding layout (no own horizontal padding).
217
+ /* @__PURE__ */ jsx2(View, { style: styles.container, children: bullets.map((bullet, index) => /* @__PURE__ */ jsxs(View, { style: styles.row, children: [
218
+ /* @__PURE__ */ jsx2(
219
+ Text,
220
+ {
221
+ style: [
222
+ styles.bullet,
223
+ resolved.textColor ? { color: resolved.textColor } : null
224
+ ],
225
+ children: "\u2022"
226
+ }
227
+ ),
228
+ /* @__PURE__ */ jsx2(
229
+ Text,
230
+ {
231
+ style: [
232
+ styles.text,
233
+ resolved.textColor ? { color: resolved.textColor } : null,
234
+ resolved.fontFamily ? { fontFamily: resolved.fontFamily } : null
235
+ ],
236
+ children: bullet
237
+ }
238
+ )
239
+ ] }, index)) })
240
+ );
241
+ }
242
+ var styles = StyleSheet.create({
243
+ container: {
244
+ paddingVertical: 8
245
+ },
246
+ row: {
247
+ flexDirection: "row",
248
+ alignItems: "flex-start",
249
+ paddingVertical: 4
250
+ },
251
+ bullet: {
252
+ fontSize: 16,
253
+ lineHeight: 22,
254
+ marginRight: 8
255
+ },
256
+ text: {
257
+ flex: 1,
258
+ fontSize: 14,
259
+ lineHeight: 22
260
+ }
261
+ });
262
+
263
+ // src/sections/ReviewsSummarySection.tsx
264
+ import { View as View2, Text as Text2, StyleSheet as StyleSheet2 } from "react-native";
265
+ import { jsx as jsx3 } from "react/jsx-runtime";
266
+ function ReviewsSummarySection() {
267
+ const { theme } = useBetterReviews();
268
+ const resolved = applyTheme(theme);
269
+ return (
270
+ // Flat — no background fill / box; reads as normal page text.
271
+ /* @__PURE__ */ jsx3(View2, { style: styles2.container, children: /* @__PURE__ */ jsx3(
272
+ Text2,
273
+ {
274
+ style: [
275
+ styles2.label,
276
+ resolved.textColor ? { color: resolved.textColor } : null,
277
+ resolved.fontFamily ? { fontFamily: resolved.fontFamily } : null
278
+ ],
279
+ children: "Customer reviews"
280
+ }
281
+ ) })
282
+ );
283
+ }
284
+ var styles2 = StyleSheet2.create({
285
+ container: {
286
+ paddingVertical: 8
287
+ },
288
+ label: {
289
+ fontSize: 16,
290
+ fontWeight: "600"
291
+ }
292
+ });
293
+
294
+ // src/ProductContentBlock.tsx
295
+ import { jsx as jsx4 } from "react/jsx-runtime";
296
+ var readEnvelopeSchema = v2.object({
297
+ v: v2.literal(1),
298
+ sections: v2.array(v2.unknown())
299
+ });
300
+ function ProductContentBlock({ block }) {
301
+ const { config, emit } = useBetterReviews();
302
+ const disabled = config?.product_content_block_enabled === false;
303
+ const sdkOk = meetsFloor(config?.min_sdk_version);
304
+ const { nodes, violations } = useMemo2(() => {
305
+ if (disabled || !sdkOk || !block) {
306
+ return { nodes: null, violations: [] };
307
+ }
308
+ const envelope = v2.safeParse(readEnvelopeSchema, block);
309
+ if (!envelope.success) {
310
+ return {
311
+ nodes: null,
312
+ violations: [envelope.issues?.[0]?.path?.[0]?.key]
313
+ };
314
+ }
315
+ const violations2 = [];
316
+ const nodes2 = envelope.output.sections.map((section, index) => renderSection(section, index, violations2)).filter((node) => node !== null);
317
+ return { nodes: nodes2, violations: violations2 };
318
+ }, [disabled, sdkOk, block]);
319
+ useEffect(() => {
320
+ if (!sdkOk) {
321
+ emit({
322
+ type: "betterreviews.fetch.failure",
323
+ error_code: "sdk_below_floor"
324
+ });
325
+ }
326
+ }, [sdkOk, emit]);
327
+ useEffect(() => {
328
+ for (const violation_path of violations) {
329
+ emit({
330
+ type: "betterreviews.schema.violation",
331
+ schema_version: 1,
332
+ violation_path
333
+ });
334
+ }
335
+ }, [violations, emit]);
336
+ if (disabled || !sdkOk || !block) return null;
337
+ if (nodes === null) return null;
338
+ return /* @__PURE__ */ jsx4(View3, { children: nodes });
339
+ }
340
+ function renderSection(section, index, violations) {
341
+ const result = v2.safeParse(sectionSchema, section);
342
+ if (!result.success) {
343
+ violations.push(`sections[${index}]`);
344
+ return null;
345
+ }
346
+ switch (result.output.type) {
347
+ case "features":
348
+ return /* @__PURE__ */ jsx4(FeaturesSection, { bullets: result.output.bullets }, index);
349
+ case "reviews_summary":
350
+ return /* @__PURE__ */ jsx4(ReviewsSummarySection, {}, index);
351
+ default:
352
+ violations.push(`sections[${index}].type`);
353
+ return null;
354
+ }
355
+ }
356
+
357
+ // src/WebViewHost.tsx
358
+ import { useEffect as useEffect2 } from "react";
359
+ import { WebView } from "react-native-webview";
360
+
361
+ // src/webviewMessage.ts
362
+ function isCloseSignal(data) {
363
+ try {
364
+ const parsed = JSON.parse(data);
365
+ return typeof parsed === "object" && parsed !== null && parsed.type === "close";
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+
371
+ // src/WebViewHost.tsx
372
+ import { jsx as jsx5 } from "react/jsx-runtime";
373
+ var ALLOWED_ORIGIN_SUFFIXES = [
374
+ ".betterreviews.app",
375
+ ".betterreviews.ngrok-free.dev"
376
+ ];
377
+ function originAllowed(rawUrl) {
378
+ let parsed;
379
+ try {
380
+ parsed = new URL(rawUrl);
381
+ } catch {
382
+ return false;
383
+ }
384
+ if (parsed.protocol !== "https:") return false;
385
+ const host = parsed.hostname.toLowerCase();
386
+ return ALLOWED_ORIGIN_SUFFIXES.some(
387
+ (suffix) => host === suffix.slice(1) || host.endsWith(suffix)
388
+ );
389
+ }
390
+ function WebViewHost({ url, onMessage, onError, onClose }) {
391
+ const { emit } = useBetterReviews();
392
+ const allowed = originAllowed(url);
393
+ useEffect2(() => {
394
+ if (!allowed) {
395
+ emit({
396
+ type: "betterreviews.webview.error",
397
+ error_code: "origin_not_allowed",
398
+ url_origin: safeOrigin(url)
399
+ });
400
+ }
401
+ }, [allowed, url, emit]);
402
+ if (!allowed) return null;
403
+ const handleMessage = (event) => {
404
+ if (onClose && isCloseSignal(event.nativeEvent.data)) {
405
+ onClose();
406
+ return;
407
+ }
408
+ onMessage?.(event);
409
+ };
410
+ const handleShouldStartLoad = (request) => {
411
+ if (originAllowed(request.url)) return true;
412
+ emit({
413
+ type: "betterreviews.webview.error",
414
+ error_code: "cross_origin_blocked",
415
+ url_origin: safeOrigin(request.url)
416
+ });
417
+ return false;
418
+ };
419
+ return /* @__PURE__ */ jsx5(
420
+ WebView,
421
+ {
422
+ source: { uri: url },
423
+ sharedCookiesEnabled: false,
424
+ automaticallyAdjustContentInsets: false,
425
+ contentInsetAdjustmentBehavior: "never",
426
+ keyboardDisplayRequiresUserAction: false,
427
+ allowsInlineMediaPlayback: true,
428
+ mediaPlaybackRequiresUserAction: false,
429
+ onShouldStartLoadWithRequest: handleShouldStartLoad,
430
+ onMessage: onMessage || onClose ? handleMessage : void 0,
431
+ onError
432
+ }
433
+ );
434
+ }
435
+ function safeOrigin(rawUrl) {
436
+ try {
437
+ const parsed = new URL(rawUrl);
438
+ return `${parsed.protocol}//${parsed.hostname}`;
439
+ } catch {
440
+ return "invalid";
441
+ }
442
+ }
443
+
444
+ // src/StarRating.tsx
445
+ import { useId } from "react";
446
+ import { Pressable, Text as Text3, View as View4 } from "react-native";
447
+
448
+ // src/icons/BRIcons.tsx
449
+ import Svg, { Path, Line, Polyline, Circle, Defs, LinearGradient, Stop } from "react-native-svg";
450
+ import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
451
+ function StrokeIcon({ size = 16, color = "currentColor", children, style }) {
452
+ return /* @__PURE__ */ jsx6(
453
+ Svg,
454
+ {
455
+ width: size,
456
+ height: size,
457
+ viewBox: "0 0 24 24",
458
+ fill: "none",
459
+ stroke: color,
460
+ strokeWidth: 2,
461
+ strokeLinecap: "round",
462
+ strokeLinejoin: "round",
463
+ style,
464
+ children
465
+ }
466
+ );
467
+ }
468
+ var IconShieldCheck = (props) => /* @__PURE__ */ jsxs2(StrokeIcon, { ...props, children: [
469
+ /* @__PURE__ */ jsx6(Path, { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
470
+ /* @__PURE__ */ jsx6(Polyline, { points: "9 12 12 15 16 10" })
471
+ ] });
472
+ var IconThumbsUp = (props) => /* @__PURE__ */ jsxs2(StrokeIcon, { ...props, children: [
473
+ /* @__PURE__ */ jsx6(Path, { d: "M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" }),
474
+ /* @__PURE__ */ jsx6(Path, { d: "M14 2l-3 7v13h9a2 2 0 0 0 2-1.7l1.38-9A2 2 0 0 0 21.4 9H14" })
475
+ ] });
476
+ var IconThumbsDown = (props) => /* @__PURE__ */ jsxs2(StrokeIcon, { ...props, children: [
477
+ /* @__PURE__ */ jsx6(Path, { d: "M17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" }),
478
+ /* @__PURE__ */ jsx6(Path, { d: "M10 22l3-7V2H4a2 2 0 0 0-2 1.7l-1.38 9A2 2 0 0 0 2.6 15H10" })
479
+ ] });
480
+ var IconCamera = (props) => /* @__PURE__ */ jsxs2(StrokeIcon, { ...props, children: [
481
+ /* @__PURE__ */ jsx6(Path, { d: "M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" }),
482
+ /* @__PURE__ */ jsx6(Circle, { cx: "12", cy: "13", r: "4" })
483
+ ] });
484
+ var IconChevronDown = (props) => /* @__PURE__ */ jsx6(StrokeIcon, { ...props, children: /* @__PURE__ */ jsx6(Polyline, { points: "6 9 12 15 18 9" }) });
485
+ var IconXClose = (props) => /* @__PURE__ */ jsxs2(StrokeIcon, { ...props, children: [
486
+ /* @__PURE__ */ jsx6(Line, { x1: "18", y1: "6", x2: "6", y2: "18" }),
487
+ /* @__PURE__ */ jsx6(Line, { x1: "6", y1: "6", x2: "18", y2: "18" })
488
+ ] });
489
+ var IconArrowUpDown = (props) => /* @__PURE__ */ jsxs2(StrokeIcon, { ...props, children: [
490
+ /* @__PURE__ */ jsx6(Polyline, { points: "7 3 7 21" }),
491
+ /* @__PURE__ */ jsx6(Polyline, { points: "4 6 7 3 10 6" }),
492
+ /* @__PURE__ */ jsx6(Polyline, { points: "17 21 17 3" }),
493
+ /* @__PURE__ */ jsx6(Polyline, { points: "14 18 17 21 20 18" })
494
+ ] });
495
+ var IconSearch = ({ size = 16, color = "currentColor", style }) => /* @__PURE__ */ jsxs2(
496
+ Svg,
497
+ {
498
+ width: size,
499
+ height: size,
500
+ viewBox: "0 0 24 24",
501
+ fill: "none",
502
+ stroke: color,
503
+ strokeWidth: 2,
504
+ strokeLinecap: "round",
505
+ strokeLinejoin: "round",
506
+ style,
507
+ children: [
508
+ /* @__PURE__ */ jsx6(Circle, { cx: "11", cy: "11", r: "8" }),
509
+ /* @__PURE__ */ jsx6(Line, { x1: "21", y1: "21", x2: "16.65", y2: "16.65" })
510
+ ]
511
+ }
512
+ );
513
+ var STAR_PATH = "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z";
514
+ function IconStar({
515
+ size = 16,
516
+ fillPercentage = 100,
517
+ fillColor = "#e8a808",
518
+ emptyColor = "#eef0f3",
519
+ uid = "star",
520
+ style
521
+ }) {
522
+ const p = Math.max(0, Math.min(100, fillPercentage));
523
+ const gradId = `br-half-${uid}-${size}-${p}`;
524
+ if (p >= 100) {
525
+ return /* @__PURE__ */ jsx6(Svg, { width: size, height: size, viewBox: "0 0 24 24", style, children: /* @__PURE__ */ jsx6(Path, { d: STAR_PATH, fill: fillColor }) });
526
+ }
527
+ if (p <= 0) {
528
+ return /* @__PURE__ */ jsx6(Svg, { width: size, height: size, viewBox: "0 0 24 24", style, children: /* @__PURE__ */ jsx6(Path, { d: STAR_PATH, fill: emptyColor }) });
529
+ }
530
+ return /* @__PURE__ */ jsxs2(Svg, { width: size, height: size, viewBox: "0 0 24 24", style, children: [
531
+ /* @__PURE__ */ jsx6(Defs, { children: /* @__PURE__ */ jsxs2(LinearGradient, { id: gradId, x1: "0", y1: "0", x2: "1", y2: "0", children: [
532
+ /* @__PURE__ */ jsx6(Stop, { offset: `${p}%`, stopColor: fillColor }),
533
+ /* @__PURE__ */ jsx6(Stop, { offset: `${p}%`, stopColor: emptyColor })
534
+ ] }) }),
535
+ /* @__PURE__ */ jsx6(Path, { d: STAR_PATH, fill: `url(#${gradId})` })
536
+ ] });
537
+ }
538
+
539
+ // src/theme/widgetTheme.ts
540
+ var WIDGET_NEUTRALS = {
541
+ bg: "#ffffff",
542
+ text: "#18181b",
543
+ // zinc-900
544
+ accent: "#18181b",
545
+ // black CTA
546
+ card: "#fafafa",
547
+ // zinc-50
548
+ muted: "#f4f4f5",
549
+ // zinc-100
550
+ mutedFg: "#71717a",
551
+ // zinc-500
552
+ border: "#e4e4e7",
553
+ // zinc-200
554
+ star: "#e8a808",
555
+ // gold — fixed
556
+ verified: "#1c8c4d",
557
+ // green — fixed Verified Buyer badge
558
+ verifiedBg: "#e2f1e9",
559
+ mark: "rgba(228,228,231,0.6)",
560
+ // zinc highlight
561
+ overflowScrim: "rgba(0,0,0,0.55)"
562
+ };
563
+ function resolveWidgetTheme(theme) {
564
+ const r = applyTheme(theme);
565
+ return {
566
+ ...WIDGET_NEUTRALS,
567
+ bg: r.backgroundColor ?? WIDGET_NEUTRALS.bg,
568
+ text: r.textColor ?? WIDGET_NEUTRALS.text,
569
+ accent: r.accentColor ?? WIDGET_NEUTRALS.accent,
570
+ cornerRadius: r.borderRadius,
571
+ fontFamily: r.fontFamily
572
+ };
573
+ }
574
+
575
+ // src/StarRating.tsx
576
+ import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
577
+ function StarRating({
578
+ average,
579
+ total,
580
+ size = 16,
581
+ starColor,
582
+ onPress,
583
+ hideWhenEmpty = true
584
+ }) {
585
+ const { theme } = useBetterReviews();
586
+ const t = resolveWidgetTheme(theme);
587
+ const uid = useId();
588
+ if (hideWhenEmpty && total <= 0) return null;
589
+ const fill = starColor ?? t.star;
590
+ const content = /* @__PURE__ */ jsxs3(
591
+ View4,
592
+ {
593
+ style: { flexDirection: "row", alignItems: "center" },
594
+ accessibilityRole: "image",
595
+ accessibilityLabel: `${average.toFixed(1)} out of 5 stars, ${total} ${total === 1 ? "review" : "reviews"}`,
596
+ children: [
597
+ [1, 2, 3, 4, 5].map((i) => {
598
+ const diff = average - (i - 1);
599
+ const p = diff >= 0.75 ? 100 : diff >= 0.25 ? 50 : 0;
600
+ return /* @__PURE__ */ jsx7(View4, { style: { marginRight: 1 }, children: /* @__PURE__ */ jsx7(IconStar, { size, fillPercentage: p, fillColor: fill, emptyColor: t.muted, uid: `${uid}-${i}` }) }, i);
601
+ }),
602
+ /* @__PURE__ */ jsx7(Text3, { style: { marginLeft: 6, fontSize: size * 0.85, fontWeight: "600", color: t.text }, children: average.toFixed(1) }),
603
+ /* @__PURE__ */ jsxs3(Text3, { style: { marginLeft: 5, fontSize: size * 0.8, color: t.mutedFg }, children: [
604
+ "\xB7 ",
605
+ total,
606
+ " ",
607
+ total === 1 ? "review" : "reviews"
608
+ ] })
609
+ ]
610
+ }
611
+ );
612
+ if (!onPress) return content;
613
+ return /* @__PURE__ */ jsx7(
614
+ Pressable,
615
+ {
616
+ onPress,
617
+ accessibilityRole: "button",
618
+ style: ({ pressed }) => pressed ? { opacity: 0.6 } : null,
619
+ children: content
620
+ }
621
+ );
622
+ }
623
+
624
+ // src/widget/ReviewWidget.tsx
625
+ import { useMemo as useMemo3, useState as useState7 } from "react";
626
+ import { ActivityIndicator, Pressable as Pressable8, Text as Text11, View as View13 } from "react-native";
627
+
628
+ // src/client/createBetterReviewsClient.ts
629
+ import * as v7 from "valibot";
630
+
631
+ // ../schemas/src/theme.ts
632
+ import * as v3 from "valibot";
633
+ var hexColorRegex = /^#[0-9a-fA-F]{6}$/;
634
+ var hexColorSchema = v3.pipe(
635
+ v3.string(),
636
+ v3.regex(hexColorRegex, "Color must be hex #RRGGBB (case-insensitive)")
637
+ );
638
+ var fontFamilySchema = v3.picklist([
639
+ "system",
640
+ "serif",
641
+ "sans-serif",
642
+ "mono"
643
+ ]);
644
+ var cornerStyleSchema = v3.picklist([
645
+ "sharp",
646
+ "slightly-rounded",
647
+ "rounded",
648
+ "extra-rounded"
649
+ ]);
650
+ var themeSchema = v3.object({
651
+ v: v3.literal(1),
652
+ primary_color: v3.optional(hexColorSchema),
653
+ background_color: v3.optional(hexColorSchema),
654
+ text_color: v3.optional(hexColorSchema),
655
+ accent_color: v3.optional(hexColorSchema),
656
+ corner_style: v3.optional(cornerStyleSchema),
657
+ font_family: v3.optional(fontFamilySchema)
658
+ });
659
+
660
+ // ../schemas/src/config.ts
661
+ import * as v4 from "valibot";
662
+ var semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
663
+ var configSchema = v4.object({
664
+ v: v4.literal(1),
665
+ updated_at: v4.pipe(v4.string(), v4.isoTimestamp()),
666
+ // `false` means "merchant explicitly turned ProductContentBlock off
667
+ // for this product" — different from "never configured" (metafield
668
+ // absent entirely). The RN component renders nothing in both cases.
669
+ product_content_block_enabled: v4.boolean(),
670
+ // Forward-compat. Resolver doesn't emit today.
671
+ min_sdk_version: v4.optional(
672
+ v4.pipe(v4.string(), v4.regex(semverRegex, "min_sdk_version must be valid semver"))
673
+ ),
674
+ // Forward-compat. Resolver doesn't emit today. Schema spec § 11.5 —
675
+ // v1 RN package ships in-WebView fallback only; native bridge is
676
+ // post-soft-launch. Default behavior across all three values in v1
677
+ // is "off" (everything in-WebView).
678
+ bridge: v4.optional(v4.picklist(["auto", "required", "off"]))
679
+ });
680
+
681
+ // ../schemas/src/widget_review.ts
682
+ import * as v5 from "valibot";
683
+ var widgetMediaItemSchema = v5.object({
684
+ type: v5.string(),
685
+ thumbnail: v5.nullable(v5.string()),
686
+ full: v5.nullable(v5.string()),
687
+ alt: v5.nullable(v5.string())
688
+ });
689
+ var widgetMerchantReplySchema = v5.object({
690
+ body: v5.string(),
691
+ author_name: v5.nullable(v5.string()),
692
+ date: v5.nullable(v5.string())
693
+ });
694
+ var widgetReviewSchema = v5.object({
695
+ id: v5.number(),
696
+ author: v5.nullable(v5.string()),
697
+ rating: v5.number(),
698
+ title: v5.nullable(v5.string()),
699
+ body: v5.string(),
700
+ body_truncated: v5.boolean(),
701
+ verified: v5.boolean(),
702
+ date: v5.nullable(v5.string()),
703
+ date_iso: v5.nullable(v5.string()),
704
+ helpful_count: v5.number(),
705
+ unhelpful_count: v5.number(),
706
+ media: v5.array(widgetMediaItemSchema),
707
+ tags: v5.array(v5.string()),
708
+ merchant_reply: v5.nullable(widgetMerchantReplySchema),
709
+ display_product_id: v5.string(),
710
+ display_product_title: v5.nullable(v5.string())
711
+ });
712
+ var widgetPaginationSchema = v5.object({
713
+ page: v5.number(),
714
+ per_page: v5.number(),
715
+ total: v5.number(),
716
+ total_pages: v5.number(),
717
+ has_next: v5.boolean()
718
+ });
719
+ var widgetReviewListSchema = v5.object({
720
+ reviews: v5.array(v5.unknown()),
721
+ pagination: widgetPaginationSchema
722
+ });
723
+ var widgetVoteCountsSchema = v5.object({
724
+ helpful_count: v5.number(),
725
+ unhelpful_count: v5.number()
726
+ });
727
+ var widgetSortValueSchema = v5.picklist([
728
+ "most_relevant",
729
+ "most_helpful",
730
+ "newest",
731
+ "highest",
732
+ "lowest"
733
+ ]);
734
+
735
+ // ../schemas/src/widget_summary.ts
736
+ import * as v6 from "valibot";
737
+ var widgetSummarySchema = v6.object({
738
+ // Total approved reviews across all ratings.
739
+ total: v6.number(),
740
+ // Mean rating, 1 decimal place.
741
+ average: v6.number(),
742
+ // Rating distribution counts, index 0 = 1★ … index 4 = 5★.
743
+ breakdown: v6.array(v6.number()),
744
+ // Percentage of 4★+5★ reviews (0..100).
745
+ positivePct: v6.number(),
746
+ // Count of reviews with media (best-effort; degrades to 0 on error).
747
+ photoCount: v6.number(),
748
+ // Count of verified-buyer reviews in the sampled page (best-effort).
749
+ verifiedCount: v6.number()
750
+ });
751
+
752
+ // src/client/createBetterReviewsClient.ts
753
+ var MIN_PER_PAGE = 5;
754
+ var MAX_PER_PAGE = 25;
755
+ var DEFAULT_PER_PAGE = 10;
756
+ var MAX_PAGE = 50;
757
+ var MIN_SEARCH_LENGTH = 2;
758
+ function clamp(n, lo, hi) {
759
+ return Math.min(Math.max(n, lo), hi);
760
+ }
761
+ function isHttps(url) {
762
+ return typeof url === "string" && url.startsWith("https://");
763
+ }
764
+ function sanitizeMedia(review) {
765
+ const media = review.media.filter(
766
+ (m) => (m.thumbnail == null || isHttps(m.thumbnail)) && (m.full == null || isHttps(m.full))
767
+ );
768
+ return media.length === review.media.length ? review : { ...review, media };
769
+ }
770
+ function cleanQuery(q) {
771
+ const out = {};
772
+ for (const [k, val] of Object.entries(q)) {
773
+ if (val !== void 0) out[k] = val;
774
+ }
775
+ return out;
776
+ }
777
+ function createBetterReviewsClient(config) {
778
+ const { fetcher, storeId, productId, onSchemaViolation } = config;
779
+ async function listReviews(params = {}, signal) {
780
+ const search = typeof params.search === "string" && params.search.trim().length >= MIN_SEARCH_LENGTH ? params.search.trim() : void 0;
781
+ const query = cleanQuery({
782
+ page: clamp(params.page ?? 1, 1, MAX_PAGE),
783
+ per_page: clamp(params.perPage ?? DEFAULT_PER_PAGE, MIN_PER_PAGE, MAX_PER_PAGE),
784
+ sort: params.sort,
785
+ rating: params.rating ?? void 0,
786
+ media_only: params.mediaOnly ? "true" : void 0,
787
+ search
788
+ });
789
+ const raw = await fetcher({
790
+ path: `/api/widget/${storeId}/products/${productId}/reviews`,
791
+ query,
792
+ signal
793
+ });
794
+ const envelope = v7.safeParse(widgetReviewListSchema, raw);
795
+ if (!envelope.success) {
796
+ throw new Error("betterreviews: invalid review list response");
797
+ }
798
+ const reviews = [];
799
+ let droppedCount = 0;
800
+ let firstBadPath;
801
+ envelope.output.reviews.forEach((row, i) => {
802
+ const parsed = v7.safeParse(widgetReviewSchema, row);
803
+ if (parsed.success) {
804
+ reviews.push(sanitizeMedia(parsed.output));
805
+ } else {
806
+ droppedCount += 1;
807
+ if (firstBadPath === void 0) firstBadPath = `reviews[${i}]`;
808
+ }
809
+ });
810
+ if (droppedCount > 0 && firstBadPath !== void 0) {
811
+ onSchemaViolation?.({ droppedCount, firstBadPath });
812
+ }
813
+ return { reviews, pagination: envelope.output.pagination };
814
+ }
815
+ async function getReviewDetail(reviewId, signal) {
816
+ const raw = await fetcher({
817
+ path: `/api/widget/${storeId}/reviews/${reviewId}`,
818
+ query: { product_id: productId },
819
+ signal
820
+ });
821
+ const parsed = v7.safeParse(widgetReviewSchema, raw);
822
+ if (!parsed.success) {
823
+ onSchemaViolation?.({ droppedCount: 1, firstBadPath: "detail" });
824
+ throw new Error("betterreviews: invalid review detail response");
825
+ }
826
+ return sanitizeMedia(parsed.output);
827
+ }
828
+ async function voteReview(reviewId, type, undo, signal) {
829
+ const raw = await fetcher({
830
+ path: `/api/widget/${storeId}/reviews/${reviewId}/vote`,
831
+ query: { product_id: productId },
832
+ method: "POST",
833
+ // `undo === true` forces a literal boolean — never the string "true".
834
+ body: { type, undo: undo === true },
835
+ signal
836
+ });
837
+ const parsed = v7.safeParse(widgetVoteCountsSchema, raw);
838
+ if (!parsed.success) {
839
+ onSchemaViolation?.({ droppedCount: 1, firstBadPath: "vote" });
840
+ return null;
841
+ }
842
+ return parsed.output;
843
+ }
844
+ async function fetchSummary(signal) {
845
+ const ratings = [1, 2, 3, 4, 5];
846
+ const breakdown = await Promise.all(
847
+ ratings.map(
848
+ (r) => listReviews({ page: 1, perPage: MIN_PER_PAGE, sort: "most_relevant", rating: r }, signal).then(
849
+ (res) => res.pagination.total
850
+ )
851
+ )
852
+ );
853
+ const total = breakdown.reduce((a, b) => a + b, 0);
854
+ const weighted = breakdown.reduce((acc, n, i) => acc + n * (i + 1), 0);
855
+ const average = total > 0 ? Math.round(weighted / total * 10) / 10 : 0;
856
+ const positive = breakdown[3] + breakdown[4];
857
+ const positivePct = total > 0 ? Math.round(positive / total * 100) : 0;
858
+ const [photoCount, verifiedCount] = await Promise.all([
859
+ listReviews({ page: 1, perPage: MAX_PER_PAGE, sort: "most_relevant", mediaOnly: true }, signal).then((r) => r.pagination.total).catch(() => 0),
860
+ listReviews({ page: 1, perPage: MAX_PER_PAGE, sort: "most_relevant" }, signal).then((r) => r.reviews.filter((x) => x.verified).length).catch(() => 0)
861
+ ]);
862
+ return { total, average, breakdown, positivePct, photoCount, verifiedCount };
863
+ }
864
+ return { listReviews, getReviewDetail, voteReview, fetchSummary };
865
+ }
866
+ function createMemoryVoteStore() {
867
+ const map = /* @__PURE__ */ new Map();
868
+ return {
869
+ get: (reviewId) => map.get(reviewId),
870
+ set: (reviewId, vote) => {
871
+ map.set(reviewId, vote);
872
+ }
873
+ };
874
+ }
875
+
876
+ // src/widget/components/MediaGallery.tsx
877
+ import { Image, Pressable as Pressable2, Text as Text4, View as View5 } from "react-native";
878
+
879
+ // src/widget/WidgetContext.tsx
880
+ import { createContext as createContext2, useContext as useContext2 } from "react";
881
+ import { jsx as jsx8 } from "react/jsx-runtime";
882
+ var WidgetContext = createContext2(null);
883
+ function WidgetProvider({
884
+ value,
885
+ children
886
+ }) {
887
+ return /* @__PURE__ */ jsx8(WidgetContext.Provider, { value, children });
888
+ }
889
+ function useWidget() {
890
+ const ctx = useContext2(WidgetContext);
891
+ if (!ctx) {
892
+ throw new Error("useWidget must be used within <ReviewWidget>");
893
+ }
894
+ return ctx;
895
+ }
896
+
897
+ // src/widget/components/MediaGallery.tsx
898
+ import { jsx as jsx9, jsxs as jsxs4 } from "react/jsx-runtime";
899
+ function MediaGallery({ photos, onOpen }) {
900
+ const { styles: styles3 } = useWidget();
901
+ if (!photos || photos.length === 0) return null;
902
+ const visible = photos.slice(0, 6);
903
+ const overflow = Math.max(0, photos.length - 6);
904
+ return /* @__PURE__ */ jsxs4(View5, { style: styles3.mediaGallery, children: [
905
+ /* @__PURE__ */ jsx9(Text4, { style: styles3.mediaGalleryLabel, children: "Customer Photos" }),
906
+ /* @__PURE__ */ jsx9(View5, { style: styles3.mediaGalleryGrid, children: visible.map((p, i) => {
907
+ const isOverflowTile = i === 5 && overflow > 0;
908
+ const uri = p.thumbnail || p.full || void 0;
909
+ return /* @__PURE__ */ jsxs4(
910
+ Pressable2,
911
+ {
912
+ onPress: () => onOpen(p.reviewId, p.mediaIndex || 0),
913
+ style: ({ pressed }) => [
914
+ styles3.galleryTile,
915
+ pressed && { opacity: 0.7 }
916
+ ],
917
+ accessibilityRole: "button",
918
+ accessibilityLabel: `Customer photo ${i + 1} of ${photos.length}, double-tap to enlarge.`,
919
+ children: [
920
+ /* @__PURE__ */ jsx9(Image, { source: { uri }, style: styles3.galleryTileImg, resizeMode: "cover" }),
921
+ isOverflowTile && /* @__PURE__ */ jsx9(View5, { style: styles3.galleryOverflow, children: /* @__PURE__ */ jsxs4(Text4, { style: styles3.galleryOverflowText, children: [
922
+ "+",
923
+ overflow
924
+ ] }) })
925
+ ]
926
+ },
927
+ `g${i}`
928
+ );
929
+ }) })
930
+ ] });
931
+ }
932
+
933
+ // src/widget/components/FilterToolbar.tsx
934
+ import { Pressable as Pressable3, ScrollView, Text as Text5, TextInput, View as View6 } from "react-native";
935
+
936
+ // src/widget/util.ts
937
+ var SORT_OPTIONS = [
938
+ { value: "most_relevant", label: "Most relevant" },
939
+ { value: "most_helpful", label: "Most helpful" },
940
+ { value: "newest", label: "Newest" },
941
+ { value: "highest", label: "Highest rated" },
942
+ { value: "lowest", label: "Lowest rated" }
943
+ ];
944
+ function getInitials(name) {
945
+ if (!name) return "?";
946
+ const parts = name.trim().split(/\s+/);
947
+ return ((parts[0]?.[0] || "") + (parts[1]?.[0] || "")).toUpperCase() || "?";
948
+ }
949
+
950
+ // src/widget/components/FilterToolbar.tsx
951
+ import { jsx as jsx10, jsxs as jsxs5 } from "react/jsx-runtime";
952
+ function FilterToolbar({ rating, mediaOnly, search, onChange }) {
953
+ const { styles: styles3, theme } = useWidget();
954
+ const ratings = [5, 4, 3, 2, 1];
955
+ const showClear = !!(rating || mediaOnly || search);
956
+ return /* @__PURE__ */ jsxs5(View6, { style: styles3.toolbar, children: [
957
+ /* @__PURE__ */ jsxs5(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, contentContainerStyle: styles3.pillsRow, children: [
958
+ ratings.map((r) => {
959
+ const active = rating === r;
960
+ return /* @__PURE__ */ jsxs5(
961
+ Pressable3,
962
+ {
963
+ onPress: () => onChange({ rating: active ? null : r }),
964
+ style: ({ pressed }) => [
965
+ styles3.pill,
966
+ pressed && !active && { borderColor: theme.text },
967
+ active && styles3.pillActive
968
+ ],
969
+ accessibilityRole: "button",
970
+ accessibilityState: { selected: active },
971
+ accessibilityLabel: `${r} stars filter`,
972
+ children: [
973
+ /* @__PURE__ */ jsx10(Text5, { style: [styles3.pillText, active && styles3.pillTextActive], children: r }),
974
+ /* @__PURE__ */ jsx10(
975
+ IconStar,
976
+ {
977
+ size: 10,
978
+ fillPercentage: 100,
979
+ fillColor: active ? theme.bg : theme.star,
980
+ uid: `pill-${r}`
981
+ }
982
+ )
983
+ ]
984
+ },
985
+ r
986
+ );
987
+ }),
988
+ /* @__PURE__ */ jsxs5(
989
+ Pressable3,
990
+ {
991
+ onPress: () => onChange({ mediaOnly: !mediaOnly }),
992
+ style: ({ pressed }) => [
993
+ styles3.pill,
994
+ styles3.pillWithIcon,
995
+ pressed && !mediaOnly && { borderColor: theme.text },
996
+ mediaOnly && styles3.pillActive
997
+ ],
998
+ accessibilityRole: "button",
999
+ accessibilityState: { selected: mediaOnly },
1000
+ accessibilityLabel: "Show only reviews with photos",
1001
+ children: [
1002
+ /* @__PURE__ */ jsx10(IconCamera, { size: 12, color: mediaOnly ? theme.bg : theme.text }),
1003
+ /* @__PURE__ */ jsx10(Text5, { style: [styles3.pillText, mediaOnly && styles3.pillTextActive], children: "Photos" })
1004
+ ]
1005
+ }
1006
+ ),
1007
+ showClear && /* @__PURE__ */ jsxs5(
1008
+ Pressable3,
1009
+ {
1010
+ onPress: () => onChange({ rating: null, mediaOnly: false, search: "" }),
1011
+ style: ({ pressed }) => [styles3.clearBtn, pressed && { opacity: 0.6 }],
1012
+ children: [
1013
+ /* @__PURE__ */ jsx10(IconXClose, { size: 12, color: theme.mutedFg }),
1014
+ /* @__PURE__ */ jsx10(Text5, { style: styles3.clearBtnText, children: "Clear" })
1015
+ ]
1016
+ }
1017
+ )
1018
+ ] }),
1019
+ /* @__PURE__ */ jsxs5(View6, { style: styles3.searchWrap, children: [
1020
+ /* @__PURE__ */ jsx10(IconSearch, { size: 14, color: theme.mutedFg, style: { marginRight: 8 } }),
1021
+ /* @__PURE__ */ jsx10(
1022
+ TextInput,
1023
+ {
1024
+ style: styles3.searchInput,
1025
+ placeholder: "Search reviews...",
1026
+ placeholderTextColor: theme.mutedFg,
1027
+ value: search,
1028
+ onChangeText: (t) => onChange({ search: t }),
1029
+ maxLength: 100,
1030
+ autoCorrect: false,
1031
+ autoCapitalize: "none",
1032
+ returnKeyType: "search"
1033
+ }
1034
+ ),
1035
+ !!search && /* @__PURE__ */ jsx10(
1036
+ Pressable3,
1037
+ {
1038
+ onPress: () => onChange({ search: "" }),
1039
+ hitSlop: 8,
1040
+ style: ({ pressed }) => [styles3.searchClear, pressed && { opacity: 0.5 }],
1041
+ accessibilityLabel: "Clear search",
1042
+ children: /* @__PURE__ */ jsx10(IconXClose, { size: 14, color: theme.mutedFg })
1043
+ }
1044
+ )
1045
+ ] }),
1046
+ search.length > 0 && search.length < 2 && /* @__PURE__ */ jsx10(Text5, { style: styles3.searchHint, children: "Keep typing\u2026" })
1047
+ ] });
1048
+ }
1049
+ function SortBar({ sort, onOpenSort }) {
1050
+ const { styles: styles3, theme } = useWidget();
1051
+ const label = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Most relevant";
1052
+ return /* @__PURE__ */ jsxs5(
1053
+ Pressable3,
1054
+ {
1055
+ onPress: onOpenSort,
1056
+ style: ({ pressed }) => [styles3.sortBtn, pressed && { borderColor: theme.text }],
1057
+ accessibilityRole: "button",
1058
+ accessibilityLabel: `Sort: ${label}. Tap to change.`,
1059
+ children: [
1060
+ /* @__PURE__ */ jsx10(IconArrowUpDown, { size: 13, color: theme.text }),
1061
+ /* @__PURE__ */ jsx10(Text5, { style: styles3.sortBtnText, children: label }),
1062
+ /* @__PURE__ */ jsx10(IconChevronDown, { size: 12, color: theme.text })
1063
+ ]
1064
+ }
1065
+ );
1066
+ }
1067
+ function WriteReviewBtn({ label = "Write a review" }) {
1068
+ const { styles: styles3, onWriteReview } = useWidget();
1069
+ if (!onWriteReview) return null;
1070
+ return /* @__PURE__ */ jsx10(
1071
+ Pressable3,
1072
+ {
1073
+ onPress: onWriteReview,
1074
+ style: ({ pressed }) => [styles3.writeReviewBtn, pressed && { opacity: 0.85 }],
1075
+ accessibilityRole: "button",
1076
+ accessibilityLabel: label,
1077
+ children: /* @__PURE__ */ jsx10(Text5, { style: styles3.writeReviewBtnText, children: label })
1078
+ }
1079
+ );
1080
+ }
1081
+
1082
+ // src/widget/components/PulseSection.tsx
1083
+ import { Text as Text6, View as View8 } from "react-native";
1084
+
1085
+ // src/widget/components/RatingStars.tsx
1086
+ import { View as View7 } from "react-native";
1087
+ import { jsx as jsx11 } from "react/jsx-runtime";
1088
+ function RatingStars({ value, size = 13, uid = "r" }) {
1089
+ const { theme } = useWidget();
1090
+ return /* @__PURE__ */ jsx11(
1091
+ View7,
1092
+ {
1093
+ style: { flexDirection: "row", alignItems: "center" },
1094
+ accessibilityRole: "image",
1095
+ accessibilityLabel: `${value} stars out of 5`,
1096
+ children: [1, 2, 3, 4, 5].map((i) => {
1097
+ const diff = value - (i - 1);
1098
+ const p = diff >= 0.75 ? 100 : diff >= 0.25 ? 50 : 0;
1099
+ return /* @__PURE__ */ jsx11(View7, { style: { marginRight: 1 }, children: /* @__PURE__ */ jsx11(
1100
+ IconStar,
1101
+ {
1102
+ size,
1103
+ fillPercentage: p,
1104
+ fillColor: theme.star,
1105
+ emptyColor: theme.muted,
1106
+ uid: `${uid}-${i}`
1107
+ }
1108
+ ) }, i);
1109
+ })
1110
+ }
1111
+ );
1112
+ }
1113
+
1114
+ // src/widget/components/PulseSection.tsx
1115
+ import { jsx as jsx12, jsxs as jsxs6 } from "react/jsx-runtime";
1116
+ function SectionTitle({ children }) {
1117
+ const { styles: styles3 } = useWidget();
1118
+ return /* @__PURE__ */ jsx12(Text6, { style: styles3.sectionTitle, children });
1119
+ }
1120
+ function PulseSummary({ summary }) {
1121
+ const { styles: styles3, theme } = useWidget();
1122
+ const max = Math.max(...summary.breakdown, 1);
1123
+ return /* @__PURE__ */ jsxs6(View8, { style: styles3.pulseSummary, children: [
1124
+ /* @__PURE__ */ jsxs6(View8, { style: styles3.pulseRating, children: [
1125
+ /* @__PURE__ */ jsx12(Text6, { style: styles3.pulseScore, children: summary.average.toFixed(1) }),
1126
+ /* @__PURE__ */ jsx12(RatingStars, { value: summary.average, size: 15, uid: "pulse" }),
1127
+ /* @__PURE__ */ jsxs6(Text6, { style: styles3.pulseCount, children: [
1128
+ "Based on ",
1129
+ summary.total,
1130
+ " ",
1131
+ summary.total === 1 ? "review" : "reviews"
1132
+ ] })
1133
+ ] }),
1134
+ /* @__PURE__ */ jsx12(View8, { style: styles3.pulseBars, children: [5, 4, 3, 2, 1].map((star) => {
1135
+ const count = summary.breakdown[star - 1] ?? 0;
1136
+ const pct = max > 0 ? count / max * 100 : 0;
1137
+ return /* @__PURE__ */ jsxs6(View8, { style: styles3.barRow, children: [
1138
+ /* @__PURE__ */ jsxs6(View8, { style: styles3.barLabel, children: [
1139
+ /* @__PURE__ */ jsx12(Text6, { style: styles3.barLabelText, children: star }),
1140
+ /* @__PURE__ */ jsx12(IconStar, { size: 12, fillPercentage: 100, fillColor: theme.star, uid: `bar-${star}` })
1141
+ ] }),
1142
+ /* @__PURE__ */ jsx12(View8, { style: styles3.barTrack, children: /* @__PURE__ */ jsx12(View8, { style: [styles3.barFill, { width: `${Math.max(0, pct)}%` }] }) }),
1143
+ /* @__PURE__ */ jsx12(Text6, { style: styles3.barCount, children: count })
1144
+ ] }, star);
1145
+ }) })
1146
+ ] });
1147
+ }
1148
+ function Metric({ value, label }) {
1149
+ const { styles: styles3 } = useWidget();
1150
+ return /* @__PURE__ */ jsxs6(View8, { style: styles3.metric, children: [
1151
+ /* @__PURE__ */ jsx12(Text6, { style: styles3.metricValue, children: value }),
1152
+ /* @__PURE__ */ jsx12(Text6, { style: styles3.metricLabel, children: label })
1153
+ ] });
1154
+ }
1155
+ function PulseMetrics({ summary }) {
1156
+ const { styles: styles3 } = useWidget();
1157
+ return /* @__PURE__ */ jsxs6(View8, { style: styles3.metrics, children: [
1158
+ /* @__PURE__ */ jsx12(Metric, { value: summary.total, label: "REVIEWS" }),
1159
+ /* @__PURE__ */ jsx12(Metric, { value: `${summary.positivePct}%`, label: "POSITIVE" }),
1160
+ /* @__PURE__ */ jsx12(Metric, { value: summary.photoCount, label: "PHOTOS" }),
1161
+ /* @__PURE__ */ jsx12(Metric, { value: summary.verifiedCount, label: "VERIFIED" })
1162
+ ] });
1163
+ }
1164
+
1165
+ // src/widget/components/ReviewCard.tsx
1166
+ import { Image as Image2, Pressable as Pressable5, ScrollView as ScrollView2, Text as Text8, View as View9 } from "react-native";
1167
+
1168
+ // src/widget/hooks/useReviewDetail.ts
1169
+ import { useCallback, useEffect as useEffect3, useState } from "react";
1170
+ function useReviewDetail(review, preFetchedBody) {
1171
+ const { client, emit } = useWidget();
1172
+ const [expanded, setExpanded] = useState(false);
1173
+ const [fullBody, setFullBody] = useState(preFetchedBody ?? null);
1174
+ const [error, setError] = useState(false);
1175
+ useEffect3(() => {
1176
+ if (preFetchedBody && !fullBody) setFullBody(preFetchedBody);
1177
+ }, [preFetchedBody, fullBody]);
1178
+ const toggle = useCallback(() => {
1179
+ if (expanded) {
1180
+ setExpanded(false);
1181
+ return;
1182
+ }
1183
+ if (fullBody || !review.body_truncated) {
1184
+ setExpanded(true);
1185
+ return;
1186
+ }
1187
+ client.getReviewDetail(review.id).then((detail) => {
1188
+ setFullBody(detail.body);
1189
+ setExpanded(true);
1190
+ }).catch(() => {
1191
+ setExpanded(true);
1192
+ setError(true);
1193
+ emit({ type: "betterreviews.fetch.failure", error_code: "detail_failed" });
1194
+ });
1195
+ }, [expanded, fullBody, review.id, review.body_truncated, client, emit]);
1196
+ const body = expanded && fullBody ? fullBody : review.body;
1197
+ return { expanded, body, error, toggle };
1198
+ }
1199
+
1200
+ // src/widget/components/VoteButtons.tsx
1201
+ import { Pressable as Pressable4, Text as Text7 } from "react-native";
1202
+
1203
+ // src/widget/hooks/useVote.ts
1204
+ import { useCallback as useCallback2, useRef, useState as useState2 } from "react";
1205
+ function useVote(review) {
1206
+ const { client, voteStore, emit } = useWidget();
1207
+ const [vote, setVote] = useState2(() => voteStore.get(review.id) ?? null);
1208
+ const [helpfulCount, setHelpful] = useState2(review.helpful_count);
1209
+ const [unhelpfulCount, setUnhelpful] = useState2(review.unhelpful_count);
1210
+ const pendingRef = useRef(false);
1211
+ const press = useCallback2(
1212
+ (type) => {
1213
+ if (pendingRef.current) return;
1214
+ if (vote !== null && vote !== type) return;
1215
+ const snapshot = { vote, helpfulCount, unhelpfulCount };
1216
+ const isUndo = vote === type;
1217
+ setVote(isUndo ? null : type);
1218
+ if (type === "helpful") setHelpful((n) => n + (isUndo ? -1 : 1));
1219
+ else setUnhelpful((n) => n + (isUndo ? -1 : 1));
1220
+ pendingRef.current = true;
1221
+ client.voteReview(review.id, type, isUndo).then((counts) => {
1222
+ voteStore.set(review.id, isUndo ? null : type);
1223
+ if (counts) {
1224
+ setHelpful(counts.helpful_count);
1225
+ setUnhelpful(counts.unhelpful_count);
1226
+ }
1227
+ }).catch(() => {
1228
+ setVote(snapshot.vote);
1229
+ setHelpful(snapshot.helpfulCount);
1230
+ setUnhelpful(snapshot.unhelpfulCount);
1231
+ emit({ type: "betterreviews.fetch.failure", error_code: "vote_failed" });
1232
+ }).finally(() => {
1233
+ pendingRef.current = false;
1234
+ });
1235
+ },
1236
+ [vote, helpfulCount, unhelpfulCount, review.id, client, voteStore, emit]
1237
+ );
1238
+ return { vote, helpfulCount, unhelpfulCount, press };
1239
+ }
1240
+
1241
+ // src/widget/components/VoteButtons.tsx
1242
+ import { Fragment, jsx as jsx13, jsxs as jsxs7 } from "react/jsx-runtime";
1243
+ function VoteButtons({ review }) {
1244
+ const { styles: styles3, theme } = useWidget();
1245
+ const { vote, helpfulCount, unhelpfulCount, press } = useVote(review);
1246
+ const helpfulDisabled = vote === "unhelpful";
1247
+ const unhelpfulDisabled = vote === "helpful";
1248
+ return /* @__PURE__ */ jsxs7(Fragment, { children: [
1249
+ /* @__PURE__ */ jsxs7(
1250
+ Pressable4,
1251
+ {
1252
+ onPress: () => press("helpful"),
1253
+ disabled: helpfulDisabled,
1254
+ style: ({ pressed }) => [
1255
+ styles3.actionBtn,
1256
+ helpfulDisabled && { opacity: 0.4 },
1257
+ pressed && { opacity: 0.6 }
1258
+ ],
1259
+ accessibilityRole: "button",
1260
+ accessibilityState: { selected: vote === "helpful", disabled: helpfulDisabled },
1261
+ accessibilityLabel: `Helpful (${helpfulCount})`,
1262
+ children: [
1263
+ /* @__PURE__ */ jsx13(IconThumbsUp, { size: 14, color: vote === "helpful" ? theme.accent : theme.mutedFg }),
1264
+ /* @__PURE__ */ jsxs7(Text7, { style: [styles3.actionBtnText, vote === "helpful" && styles3.actionBtnTextActive], children: [
1265
+ "Helpful (",
1266
+ helpfulCount,
1267
+ ")"
1268
+ ] })
1269
+ ]
1270
+ }
1271
+ ),
1272
+ /* @__PURE__ */ jsxs7(
1273
+ Pressable4,
1274
+ {
1275
+ onPress: () => press("unhelpful"),
1276
+ disabled: unhelpfulDisabled,
1277
+ style: ({ pressed }) => [
1278
+ styles3.actionBtn,
1279
+ unhelpfulDisabled && { opacity: 0.4 },
1280
+ pressed && { opacity: 0.6 }
1281
+ ],
1282
+ accessibilityRole: "button",
1283
+ accessibilityState: { selected: vote === "unhelpful", disabled: unhelpfulDisabled },
1284
+ accessibilityLabel: `Not helpful (${unhelpfulCount})`,
1285
+ children: [
1286
+ /* @__PURE__ */ jsx13(IconThumbsDown, { size: 14, color: vote === "unhelpful" ? theme.accent : theme.mutedFg }),
1287
+ /* @__PURE__ */ jsxs7(Text7, { style: [styles3.actionBtnText, vote === "unhelpful" && styles3.actionBtnTextActive], children: [
1288
+ "(",
1289
+ unhelpfulCount,
1290
+ ")"
1291
+ ] })
1292
+ ]
1293
+ }
1294
+ )
1295
+ ] });
1296
+ }
1297
+
1298
+ // src/widget/components/ReviewCard.tsx
1299
+ import { jsx as jsx14, jsxs as jsxs8 } from "react/jsx-runtime";
1300
+ function ReviewCard({ review, onOpenPhoto }) {
1301
+ const { styles: styles3, theme } = useWidget();
1302
+ const { expanded, body, error, toggle } = useReviewDetail(review);
1303
+ const media = review.media.filter((m) => m.type === "image");
1304
+ const visibleTags = review.tags.slice(0, 4);
1305
+ const reply = review.merchant_reply;
1306
+ const wasTruncated = review.body_truncated === true;
1307
+ return /* @__PURE__ */ jsxs8(View9, { style: styles3.card, children: [
1308
+ /* @__PURE__ */ jsxs8(View9, { style: styles3.cardHeader, children: [
1309
+ /* @__PURE__ */ jsx14(View9, { style: styles3.avatar, children: /* @__PURE__ */ jsx14(Text8, { style: styles3.avatarText, children: getInitials(review.author) }) }),
1310
+ /* @__PURE__ */ jsxs8(View9, { style: styles3.cardInfo, children: [
1311
+ /* @__PURE__ */ jsxs8(View9, { style: styles3.cardTopRow, children: [
1312
+ /* @__PURE__ */ jsx14(Text8, { style: styles3.cardAuthor, numberOfLines: 1, children: review.author || "Anonymous" }),
1313
+ /* @__PURE__ */ jsx14(Text8, { style: styles3.cardDate, children: review.date })
1314
+ ] }),
1315
+ /* @__PURE__ */ jsxs8(View9, { style: styles3.cardRatingRow, children: [
1316
+ /* @__PURE__ */ jsx14(RatingStars, { value: review.rating, size: 13, uid: `c-${review.id}` }),
1317
+ review.verified && /* @__PURE__ */ jsxs8(View9, { style: styles3.verifiedBadge, children: [
1318
+ /* @__PURE__ */ jsx14(IconShieldCheck, { size: 11, color: theme.verified }),
1319
+ /* @__PURE__ */ jsx14(Text8, { style: styles3.verifiedBadgeText, children: "Verified Buyer" })
1320
+ ] })
1321
+ ] })
1322
+ ] })
1323
+ ] }),
1324
+ !!review.title && /* @__PURE__ */ jsx14(Text8, { style: styles3.cardTitle, children: review.title }),
1325
+ !!body && /* @__PURE__ */ jsx14(Text8, { style: styles3.cardBody, numberOfLines: wasTruncated && !expanded ? 3 : void 0, children: body }),
1326
+ wasTruncated && /* @__PURE__ */ jsx14(
1327
+ Pressable5,
1328
+ {
1329
+ onPress: toggle,
1330
+ style: ({ pressed }) => [styles3.readMoreBtn, pressed && { opacity: 0.6 }],
1331
+ accessibilityRole: "button",
1332
+ accessibilityLabel: expanded ? "Read less" : "Read more",
1333
+ children: /* @__PURE__ */ jsx14(Text8, { style: styles3.readMoreBtnText, children: expanded ? "Read less" : "Read more" })
1334
+ }
1335
+ ),
1336
+ error && /* @__PURE__ */ jsx14(Text8, { style: styles3.readMoreErr, children: "Could not load full review \u2014 showing preview." }),
1337
+ media.length > 0 && /* @__PURE__ */ jsx14(
1338
+ ScrollView2,
1339
+ {
1340
+ horizontal: true,
1341
+ showsHorizontalScrollIndicator: false,
1342
+ style: styles3.cardMedia,
1343
+ contentContainerStyle: { gap: 8 },
1344
+ children: media.map((m, i) => /* @__PURE__ */ jsx14(
1345
+ Pressable5,
1346
+ {
1347
+ onPress: () => onOpenPhoto(review.id, i),
1348
+ style: ({ pressed }) => [styles3.mediaThumb, pressed && { opacity: 0.7 }],
1349
+ accessibilityRole: "button",
1350
+ accessibilityLabel: `Customer photo ${i + 1} of ${media.length}, double-tap to enlarge.`,
1351
+ children: /* @__PURE__ */ jsx14(Image2, { source: { uri: m.thumbnail || void 0 }, style: styles3.mediaThumbImg })
1352
+ },
1353
+ i
1354
+ ))
1355
+ }
1356
+ ),
1357
+ visibleTags.length > 0 && /* @__PURE__ */ jsx14(View9, { style: styles3.tagsRow, children: visibleTags.map((tag, i) => /* @__PURE__ */ jsx14(View9, { style: styles3.tag, children: /* @__PURE__ */ jsx14(Text8, { style: styles3.tagText, children: tag }) }, i)) }),
1358
+ /* @__PURE__ */ jsx14(View9, { style: styles3.actionsRow, children: /* @__PURE__ */ jsx14(VoteButtons, { review }) }),
1359
+ reply && /* @__PURE__ */ jsxs8(View9, { style: styles3.merchantReply, children: [
1360
+ /* @__PURE__ */ jsxs8(View9, { style: styles3.merchantReplyHeader, children: [
1361
+ /* @__PURE__ */ jsx14(Text8, { style: styles3.merchantReplyAuthor, children: reply.author_name }),
1362
+ /* @__PURE__ */ jsx14(Text8, { style: styles3.merchantReplyDate, children: reply.date })
1363
+ ] }),
1364
+ /* @__PURE__ */ jsx14(Text8, { style: styles3.merchantReplyBody, children: reply.body })
1365
+ ] })
1366
+ ] });
1367
+ }
1368
+
1369
+ // src/widget/components/SortDrawer.tsx
1370
+ import { Modal, Pressable as Pressable6, Text as Text9, View as View10 } from "react-native";
1371
+ import { jsx as jsx15, jsxs as jsxs9 } from "react/jsx-runtime";
1372
+ function SortDrawer({ visible, current, onSelect, onClose }) {
1373
+ const { styles: styles3, theme } = useWidget();
1374
+ return /* @__PURE__ */ jsxs9(Modal, { visible, animationType: "slide", transparent: true, onRequestClose: onClose, children: [
1375
+ /* @__PURE__ */ jsx15(Pressable6, { style: styles3.modalBackdrop, onPress: onClose }),
1376
+ /* @__PURE__ */ jsxs9(View10, { style: styles3.drawer, accessibilityRole: "menu", children: [
1377
+ /* @__PURE__ */ jsx15(View10, { style: styles3.drawerHandle }),
1378
+ /* @__PURE__ */ jsx15(Text9, { style: styles3.drawerTitle, children: "Sort reviews" }),
1379
+ SORT_OPTIONS.map((opt) => {
1380
+ const active = opt.value === current;
1381
+ return /* @__PURE__ */ jsxs9(
1382
+ Pressable6,
1383
+ {
1384
+ onPress: () => {
1385
+ onSelect(opt.value);
1386
+ onClose();
1387
+ },
1388
+ style: ({ pressed }) => [
1389
+ styles3.drawerOption,
1390
+ pressed && { backgroundColor: theme.card }
1391
+ ],
1392
+ accessibilityRole: "menuitem",
1393
+ accessibilityState: { selected: active },
1394
+ children: [
1395
+ /* @__PURE__ */ jsx15(Text9, { style: [styles3.drawerOptionText, active && styles3.drawerOptionTextActive], children: opt.label }),
1396
+ active && /* @__PURE__ */ jsx15(View10, { style: styles3.drawerActiveDot })
1397
+ ]
1398
+ },
1399
+ opt.value
1400
+ );
1401
+ })
1402
+ ] })
1403
+ ] });
1404
+ }
1405
+
1406
+ // src/widget/components/StaleListOverlay.tsx
1407
+ import { useEffect as useEffect4, useRef as useRef2, useState as useState3 } from "react";
1408
+ import { Animated, Easing, View as View11 } from "react-native";
1409
+
1410
+ // src/widget/styles.ts
1411
+ import { Dimensions, StyleSheet as StyleSheet3 } from "react-native";
1412
+ var R = 16;
1413
+ var SCREEN_W = Dimensions.get("window").width;
1414
+ var SCREEN_H = Dimensions.get("window").height;
1415
+ var MEDIA_ZONE_H = Math.round(SCREEN_H * 0.6);
1416
+ var CARD_ZONE_H = SCREEN_H - MEDIA_ZONE_H;
1417
+ function makeWidgetStyles(t) {
1418
+ return StyleSheet3.create({
1419
+ root: { flex: 1, backgroundColor: t.bg },
1420
+ list: { paddingHorizontal: R, paddingBottom: R * 1.5 },
1421
+ center: { padding: R * 2, alignItems: "center", justifyContent: "center" },
1422
+ muted: { color: t.mutedFg, marginTop: 8, fontSize: 14 },
1423
+ errorLine: { color: t.mutedFg, fontSize: 14, marginBottom: 8 },
1424
+ retryLink: { color: t.accent, fontSize: 14, textDecorationLine: "underline" },
1425
+ sectionTitle: {
1426
+ fontSize: R * 1.5,
1427
+ fontWeight: "700",
1428
+ color: t.text,
1429
+ marginTop: R,
1430
+ marginBottom: R * 0.75
1431
+ },
1432
+ pulse: { paddingTop: R * 0.5, marginBottom: R },
1433
+ pulseSummary: { flexDirection: "column", gap: R * 0.75 },
1434
+ pulseRating: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: R * 0.5 },
1435
+ pulseScore: {
1436
+ fontSize: R * 2.5,
1437
+ fontWeight: "700",
1438
+ color: t.text,
1439
+ letterSpacing: -1,
1440
+ marginRight: R * 0.25,
1441
+ lineHeight: R * 2.5
1442
+ },
1443
+ pulseCount: { fontSize: 13, color: t.mutedFg },
1444
+ pulseBars: { gap: R * 0.375, marginTop: R * 0.25 },
1445
+ barRow: { flexDirection: "row", alignItems: "center", gap: R * 0.5 },
1446
+ barLabel: { width: R * 1.5, flexDirection: "row", alignItems: "center", gap: 2 },
1447
+ barLabelText: { fontSize: 13, color: t.text },
1448
+ barTrack: { flex: 1, height: 8, backgroundColor: t.muted, borderRadius: 4, overflow: "hidden" },
1449
+ barFill: { height: "100%", backgroundColor: t.star, borderRadius: 4 },
1450
+ barCount: { width: R * 2, textAlign: "right", fontSize: 12, color: t.mutedFg },
1451
+ mediaGallery: { marginTop: R },
1452
+ mediaGalleryLabel: {
1453
+ fontSize: 12,
1454
+ fontWeight: "600",
1455
+ color: t.mutedFg,
1456
+ textTransform: "uppercase",
1457
+ letterSpacing: 0.6,
1458
+ marginBottom: 8
1459
+ },
1460
+ mediaGalleryGrid: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
1461
+ galleryTile: {
1462
+ width: "31.5%",
1463
+ aspectRatio: 1,
1464
+ borderRadius: 16,
1465
+ borderWidth: 1,
1466
+ borderColor: t.border,
1467
+ overflow: "hidden",
1468
+ backgroundColor: t.muted
1469
+ },
1470
+ galleryTileImg: { width: "100%", height: "100%" },
1471
+ galleryOverflow: {
1472
+ position: "absolute",
1473
+ top: 0,
1474
+ left: 0,
1475
+ right: 0,
1476
+ bottom: 0,
1477
+ backgroundColor: t.overflowScrim,
1478
+ alignItems: "center",
1479
+ justifyContent: "center"
1480
+ },
1481
+ galleryOverflowText: { color: "#fff", fontSize: 18, fontWeight: "700" },
1482
+ writeReviewBtn: {
1483
+ width: "100%",
1484
+ paddingHorizontal: R * 1.5,
1485
+ paddingVertical: R * 0.625,
1486
+ backgroundColor: t.accent,
1487
+ borderRadius: 9999,
1488
+ alignItems: "center",
1489
+ justifyContent: "center",
1490
+ marginTop: R * 0.75
1491
+ },
1492
+ writeReviewBtnText: { color: "#ffffff", fontWeight: "600", fontSize: 14 },
1493
+ metrics: { flexDirection: "row", gap: R * 0.5, marginTop: R },
1494
+ metric: { flex: 1, alignItems: "center" },
1495
+ metricValue: { fontSize: R * 1.25, fontWeight: "700", color: t.text, lineHeight: R * 1.5 },
1496
+ metricLabel: {
1497
+ fontSize: 11,
1498
+ color: t.mutedFg,
1499
+ textTransform: "uppercase",
1500
+ letterSpacing: 0.5,
1501
+ marginTop: 2
1502
+ },
1503
+ toolbar: {
1504
+ paddingTop: R * 0.75,
1505
+ paddingBottom: R * 0.75,
1506
+ borderBottomWidth: 1,
1507
+ borderBottomColor: t.border,
1508
+ marginBottom: R * 0.75,
1509
+ gap: R * 0.5
1510
+ },
1511
+ pillsRow: { flexDirection: "row", gap: R * 0.375, paddingRight: R, alignItems: "center" },
1512
+ pill: {
1513
+ paddingHorizontal: R * 0.75,
1514
+ paddingVertical: R * 0.375,
1515
+ borderRadius: 9999,
1516
+ borderWidth: 1,
1517
+ borderColor: t.border,
1518
+ backgroundColor: t.bg,
1519
+ flexDirection: "row",
1520
+ alignItems: "center",
1521
+ gap: 4
1522
+ },
1523
+ pillWithIcon: { paddingHorizontal: R * 0.625 },
1524
+ pillActive: { backgroundColor: t.text, borderColor: t.text },
1525
+ pillText: { color: t.text, fontSize: 13, fontWeight: "500" },
1526
+ pillTextActive: { color: t.bg },
1527
+ clearBtn: {
1528
+ paddingHorizontal: R * 0.5,
1529
+ paddingVertical: R * 0.375,
1530
+ flexDirection: "row",
1531
+ alignItems: "center",
1532
+ gap: 4
1533
+ },
1534
+ clearBtnText: { color: t.mutedFg, fontSize: 13, fontWeight: "500" },
1535
+ searchWrap: {
1536
+ flexDirection: "row",
1537
+ alignItems: "center",
1538
+ borderWidth: 1,
1539
+ borderColor: t.border,
1540
+ borderRadius: 9999,
1541
+ backgroundColor: t.bg,
1542
+ paddingLeft: R * 0.75,
1543
+ paddingRight: R * 0.5,
1544
+ height: 36
1545
+ },
1546
+ searchInput: { flex: 1, fontSize: 13, color: t.text, paddingVertical: 0 },
1547
+ searchClear: { width: 22, height: 22, borderRadius: 11, alignItems: "center", justifyContent: "center" },
1548
+ searchHint: { fontSize: 12, color: t.mutedFg, paddingLeft: R * 0.25, paddingTop: 4 },
1549
+ sortBtn: {
1550
+ flexDirection: "row",
1551
+ alignItems: "center",
1552
+ justifyContent: "center",
1553
+ alignSelf: "flex-start",
1554
+ paddingHorizontal: R * 0.75,
1555
+ paddingVertical: R * 0.375,
1556
+ borderRadius: 9999,
1557
+ borderWidth: 1,
1558
+ borderColor: t.border,
1559
+ backgroundColor: t.bg,
1560
+ marginBottom: R * 0.5,
1561
+ gap: R * 0.375
1562
+ },
1563
+ sortBtnText: { color: t.text, fontSize: 13, fontWeight: "500" },
1564
+ // 2px top progress bar — faint neutral track (the fill uses the accent).
1565
+ progressTrack: {
1566
+ height: 2,
1567
+ width: "100%",
1568
+ backgroundColor: "rgba(0, 0, 0, 0.06)",
1569
+ overflow: "hidden",
1570
+ marginBottom: -2
1571
+ },
1572
+ progressFill: { width: "30%", height: "100%", backgroundColor: t.accent, borderRadius: 2 },
1573
+ card: { paddingVertical: R * 1.25, borderBottomWidth: 1, borderBottomColor: t.border },
1574
+ cardHeader: { flexDirection: "row", alignItems: "flex-start", gap: R * 0.75, marginBottom: R * 0.5 },
1575
+ avatar: {
1576
+ width: 40,
1577
+ height: 40,
1578
+ borderRadius: 20,
1579
+ backgroundColor: t.muted,
1580
+ alignItems: "center",
1581
+ justifyContent: "center"
1582
+ },
1583
+ avatarText: { fontWeight: "600", fontSize: 14, color: t.mutedFg, textTransform: "uppercase" },
1584
+ cardInfo: { flex: 1, minWidth: 0 },
1585
+ cardTopRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: R * 0.5 },
1586
+ cardAuthor: { fontWeight: "600", fontSize: 15, color: t.text, flexShrink: 1 },
1587
+ cardDate: { fontSize: 13, color: t.mutedFg },
1588
+ cardRatingRow: { flexDirection: "row", alignItems: "center", gap: R * 0.5, marginTop: 2 },
1589
+ verifiedBadge: {
1590
+ flexDirection: "row",
1591
+ alignItems: "center",
1592
+ gap: 4,
1593
+ paddingHorizontal: R * 0.5,
1594
+ paddingVertical: 2,
1595
+ borderRadius: 9999,
1596
+ backgroundColor: t.verifiedBg
1597
+ },
1598
+ verifiedBadgeText: { fontSize: 11, color: t.verified, fontWeight: "500" },
1599
+ cardTitle: { fontSize: 15, fontWeight: "700", color: t.text, marginTop: R * 0.5, marginBottom: R * 0.375 },
1600
+ cardBody: { fontSize: 15, lineHeight: 15 * 1.7, color: t.text },
1601
+ readMoreBtn: { paddingVertical: 4, marginTop: 2, alignSelf: "flex-start" },
1602
+ readMoreBtnText: { fontSize: 13, color: t.accent, fontWeight: "500", textDecorationLine: "underline" },
1603
+ readMoreErr: { fontSize: 12, color: t.mutedFg, marginTop: 2 },
1604
+ cardMedia: { marginTop: R * 0.75 },
1605
+ mediaThumb: {
1606
+ width: 64,
1607
+ height: 64,
1608
+ borderRadius: R,
1609
+ borderWidth: 1,
1610
+ borderColor: t.border,
1611
+ overflow: "hidden",
1612
+ backgroundColor: t.muted
1613
+ },
1614
+ mediaThumbImg: { width: "100%", height: "100%" },
1615
+ tagsRow: { flexDirection: "row", flexWrap: "wrap", gap: R * 0.375, marginTop: R * 0.75 },
1616
+ tag: {
1617
+ paddingHorizontal: R * 0.5,
1618
+ paddingVertical: 2,
1619
+ borderRadius: 9999,
1620
+ borderWidth: 1,
1621
+ borderColor: t.border,
1622
+ backgroundColor: t.muted
1623
+ },
1624
+ tagText: { fontSize: 12, color: t.mutedFg },
1625
+ actionsRow: { flexDirection: "row", gap: R, marginTop: R * 0.75 },
1626
+ actionBtn: { flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: R * 0.25 },
1627
+ actionBtnText: { fontSize: 13, color: t.mutedFg },
1628
+ actionBtnTextActive: { color: t.accent, fontWeight: "600" },
1629
+ merchantReply: {
1630
+ marginTop: R * 0.75,
1631
+ padding: R,
1632
+ borderRadius: R,
1633
+ backgroundColor: t.muted,
1634
+ borderWidth: 1,
1635
+ borderColor: t.border
1636
+ },
1637
+ merchantReplyHeader: { flexDirection: "row", alignItems: "baseline", gap: R * 0.5, marginBottom: R * 0.25 },
1638
+ merchantReplyAuthor: { fontWeight: "600", fontSize: 13, color: t.text },
1639
+ merchantReplyDate: { fontSize: 12, color: t.mutedFg },
1640
+ merchantReplyBody: { fontSize: 14, lineHeight: 22, color: t.mutedFg },
1641
+ footerWrap: { alignItems: "center", paddingVertical: R },
1642
+ showMore: {
1643
+ paddingHorizontal: R * 2,
1644
+ paddingVertical: R * 0.625,
1645
+ borderRadius: 9999,
1646
+ borderWidth: 1,
1647
+ borderColor: t.border,
1648
+ backgroundColor: "transparent"
1649
+ },
1650
+ showMoreDisabled: { opacity: 0.5 },
1651
+ showMoreText: { fontSize: 14, color: t.text },
1652
+ emptyState: {
1653
+ alignItems: "center",
1654
+ padding: R * 2,
1655
+ borderRadius: R,
1656
+ borderWidth: 1,
1657
+ borderColor: t.border,
1658
+ marginVertical: R
1659
+ },
1660
+ emptyStateText: { fontSize: 15, color: t.mutedFg, marginBottom: R * 0.5 },
1661
+ emptyStateClear: { fontSize: 14, color: t.text, textDecorationLine: "underline" },
1662
+ modalBackdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.5)" },
1663
+ drawer: {
1664
+ backgroundColor: t.bg,
1665
+ borderTopLeftRadius: R,
1666
+ borderTopRightRadius: R,
1667
+ paddingBottom: R * 1.5,
1668
+ paddingTop: R * 0.75
1669
+ },
1670
+ drawerHandle: {
1671
+ alignSelf: "center",
1672
+ width: 36,
1673
+ height: 4,
1674
+ borderRadius: 2,
1675
+ backgroundColor: t.border,
1676
+ marginBottom: R
1677
+ },
1678
+ drawerTitle: {
1679
+ paddingHorizontal: R,
1680
+ paddingBottom: R * 0.75,
1681
+ fontWeight: "600",
1682
+ fontSize: 16,
1683
+ color: t.text,
1684
+ borderBottomWidth: 1,
1685
+ borderBottomColor: t.border
1686
+ },
1687
+ drawerOption: {
1688
+ flexDirection: "row",
1689
+ alignItems: "center",
1690
+ justifyContent: "space-between",
1691
+ paddingHorizontal: R,
1692
+ paddingVertical: R * 0.75
1693
+ },
1694
+ drawerOptionText: { fontSize: 14, color: t.text },
1695
+ drawerOptionTextActive: { fontWeight: "600" },
1696
+ drawerActiveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: t.accent },
1697
+ viewerRoot: { flex: 1, backgroundColor: "#000" },
1698
+ viewerClose: {
1699
+ position: "absolute",
1700
+ top: 50,
1701
+ right: 20,
1702
+ zIndex: 20,
1703
+ backgroundColor: "rgba(0,0,0,0.45)",
1704
+ width: 36,
1705
+ height: 36,
1706
+ borderRadius: 18,
1707
+ alignItems: "center",
1708
+ justifyContent: "center"
1709
+ },
1710
+ viewerMediaZone: { width: "100%", height: MEDIA_ZONE_H, backgroundColor: "#000" },
1711
+ viewerMediaPage: { height: MEDIA_ZONE_H, alignItems: "center", justifyContent: "center" },
1712
+ viewerImage: { width: "100%", height: "100%" },
1713
+ viewerTopOverlay: {
1714
+ position: "absolute",
1715
+ top: 50,
1716
+ left: 16,
1717
+ right: 64,
1718
+ flexDirection: "row",
1719
+ alignItems: "center",
1720
+ justifyContent: "space-between",
1721
+ gap: 12
1722
+ },
1723
+ viewerDots: {
1724
+ flexDirection: "row",
1725
+ alignItems: "center",
1726
+ gap: 4,
1727
+ backgroundColor: "rgba(0,0,0,0.35)",
1728
+ paddingHorizontal: 8,
1729
+ paddingVertical: 5,
1730
+ borderRadius: 9999
1731
+ },
1732
+ viewerDot: { width: 5, height: 5, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.5)" },
1733
+ viewerDotActive: { backgroundColor: "#fff", width: 7, height: 7, borderRadius: 4 },
1734
+ viewerPosition: {
1735
+ backgroundColor: "rgba(0,0,0,0.35)",
1736
+ paddingHorizontal: 10,
1737
+ paddingVertical: 4,
1738
+ borderRadius: 9999
1739
+ },
1740
+ viewerPositionText: { color: "#fff", fontSize: 12, fontWeight: "500" },
1741
+ viewerCard: {
1742
+ height: CARD_ZONE_H,
1743
+ backgroundColor: t.bg,
1744
+ borderTopLeftRadius: R * 1.25,
1745
+ borderTopRightRadius: R * 1.25,
1746
+ paddingTop: 0
1747
+ },
1748
+ viewerHandleHit: { alignItems: "center", paddingTop: 8, paddingBottom: 6 },
1749
+ viewerHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: t.border },
1750
+ viewerCardScroll: { flex: 1 },
1751
+ viewerCardContent: { paddingHorizontal: R, paddingTop: R * 0.5, paddingBottom: R * 1.5 },
1752
+ viewerCardHeader: { flexDirection: "row", alignItems: "flex-start", gap: R * 0.75, marginBottom: R * 0.5 },
1753
+ viewerAvatar: {
1754
+ width: 36,
1755
+ height: 36,
1756
+ borderRadius: 18,
1757
+ backgroundColor: t.muted,
1758
+ alignItems: "center",
1759
+ justifyContent: "center"
1760
+ },
1761
+ viewerAvatarText: { fontWeight: "600", fontSize: 13, color: t.mutedFg, textTransform: "uppercase" },
1762
+ viewerHeaderTopRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: R * 0.5 },
1763
+ viewerAuthor: { fontWeight: "600", fontSize: 15, color: t.text, flexShrink: 1 },
1764
+ viewerDate: { fontSize: 13, color: t.mutedFg },
1765
+ viewerStarsRow: { flexDirection: "row", alignItems: "center", gap: R * 0.5, marginTop: 2 },
1766
+ viewerTitle: { fontSize: 15, fontWeight: "700", color: t.text, marginTop: R * 0.25, marginBottom: R * 0.375 },
1767
+ viewerBody: { fontSize: 14, lineHeight: 14 * 1.6, color: t.text },
1768
+ viewerActionsRow: { flexDirection: "row", gap: R, marginTop: R * 0.75 },
1769
+ viewerReplyToggle: { marginTop: R * 0.5, paddingVertical: 6, alignSelf: "flex-start" },
1770
+ viewerReplyToggleText: { fontSize: 13, color: t.accent, fontWeight: "500" }
1771
+ });
1772
+ }
1773
+
1774
+ // src/widget/components/StaleListOverlay.tsx
1775
+ import { jsx as jsx16 } from "react/jsx-runtime";
1776
+ function StaleListOverlay({ active }) {
1777
+ const { styles: styles3 } = useWidget();
1778
+ const slide = useRef2(new Animated.Value(0)).current;
1779
+ const [shown, setShown] = useState3(false);
1780
+ useEffect4(() => {
1781
+ let graceTimer;
1782
+ if (active) {
1783
+ graceTimer = setTimeout(() => setShown(true), 150);
1784
+ } else {
1785
+ setShown(false);
1786
+ }
1787
+ return () => clearTimeout(graceTimer);
1788
+ }, [active]);
1789
+ useEffect4(() => {
1790
+ if (!shown) {
1791
+ slide.setValue(0);
1792
+ return;
1793
+ }
1794
+ const loop = Animated.loop(
1795
+ Animated.timing(slide, {
1796
+ toValue: 1,
1797
+ duration: 1400,
1798
+ easing: Easing.linear,
1799
+ useNativeDriver: true
1800
+ })
1801
+ );
1802
+ loop.start();
1803
+ return () => loop.stop();
1804
+ }, [shown, slide]);
1805
+ if (!shown) return null;
1806
+ const translateX = slide.interpolate({
1807
+ inputRange: [0, 1],
1808
+ outputRange: [-SCREEN_W * 0.4, SCREEN_W * 1.4]
1809
+ });
1810
+ return /* @__PURE__ */ jsx16(View11, { style: styles3.progressTrack, pointerEvents: "none", children: /* @__PURE__ */ jsx16(Animated.View, { style: [styles3.progressFill, { transform: [{ translateX }] }] }) });
1811
+ }
1812
+
1813
+ // src/widget/hooks/useReviewList.ts
1814
+ import { useCallback as useCallback3, useEffect as useEffect5, useRef as useRef3, useState as useState4 } from "react";
1815
+ var PAGE_SIZE = 10;
1816
+ function useReviewList(client, emit, initialSort = "most_relevant") {
1817
+ const [page, setPage] = useState4(1);
1818
+ const [reviews, setReviews] = useState4([]);
1819
+ const [pagination, setPagination] = useState4(null);
1820
+ const [sort, setSort] = useState4(initialSort);
1821
+ const [rating, setRating] = useState4(null);
1822
+ const [mediaOnly, setMediaOnly] = useState4(false);
1823
+ const [searchInput, setSearchInput] = useState4("");
1824
+ const [search, setSearch] = useState4("");
1825
+ const [loading, setLoading] = useState4(true);
1826
+ const [refetching, setRefetching] = useState4(false);
1827
+ const [loadingMore, setLoadingMore] = useState4(false);
1828
+ const [error, setError] = useState4(false);
1829
+ const inflightRef = useRef3(null);
1830
+ const initialLoadDoneRef = useRef3(false);
1831
+ useEffect5(() => {
1832
+ const t = setTimeout(() => setSearch(searchInput), 300);
1833
+ return () => clearTimeout(t);
1834
+ }, [searchInput]);
1835
+ const loadFirstPage = useCallback3(async () => {
1836
+ inflightRef.current?.abort();
1837
+ const ctrl = new AbortController();
1838
+ inflightRef.current = ctrl;
1839
+ if (!initialLoadDoneRef.current) setLoading(true);
1840
+ else setRefetching(true);
1841
+ setError(false);
1842
+ try {
1843
+ const res = await client.listReviews(
1844
+ { page: 1, perPage: PAGE_SIZE, sort, rating, mediaOnly, search },
1845
+ ctrl.signal
1846
+ );
1847
+ if (ctrl.signal.aborted) return;
1848
+ setReviews(res.reviews);
1849
+ setPagination(res.pagination);
1850
+ setPage(1);
1851
+ initialLoadDoneRef.current = true;
1852
+ } catch {
1853
+ if (ctrl.signal.aborted) return;
1854
+ setError(true);
1855
+ } finally {
1856
+ if (!ctrl.signal.aborted) {
1857
+ setLoading(false);
1858
+ setRefetching(false);
1859
+ }
1860
+ }
1861
+ }, [client, sort, rating, mediaOnly, search]);
1862
+ useEffect5(() => {
1863
+ loadFirstPage();
1864
+ return () => inflightRef.current?.abort();
1865
+ }, [loadFirstPage]);
1866
+ const onLoadMore = useCallback3(async () => {
1867
+ if (loadingMore || !pagination?.has_next) return;
1868
+ setLoadingMore(true);
1869
+ try {
1870
+ const next = page + 1;
1871
+ const res = await client.listReviews({ page: next, perPage: PAGE_SIZE, sort, rating, mediaOnly, search });
1872
+ setReviews((prev) => [...prev, ...res.reviews]);
1873
+ setPagination(res.pagination);
1874
+ setPage(next);
1875
+ } catch {
1876
+ emit({ type: "betterreviews.fetch.failure", error_code: "load_more_failed" });
1877
+ } finally {
1878
+ setLoadingMore(false);
1879
+ }
1880
+ }, [loadingMore, pagination, page, client, sort, rating, mediaOnly, search, emit]);
1881
+ const onFilterChange = useCallback3((patch) => {
1882
+ if ("rating" in patch) setRating(patch.rating ?? null);
1883
+ if ("mediaOnly" in patch) setMediaOnly(!!patch.mediaOnly);
1884
+ if ("search" in patch) setSearchInput(patch.search ?? "");
1885
+ }, []);
1886
+ return {
1887
+ reviews,
1888
+ pagination,
1889
+ sort,
1890
+ rating,
1891
+ mediaOnly,
1892
+ searchInput,
1893
+ loading,
1894
+ refetching,
1895
+ loadingMore,
1896
+ error,
1897
+ setSort,
1898
+ onFilterChange,
1899
+ onLoadMore,
1900
+ reload: loadFirstPage
1901
+ };
1902
+ }
1903
+
1904
+ // src/widget/hooks/useReviewSummary.ts
1905
+ import { useEffect as useEffect6, useState as useState5 } from "react";
1906
+ function useReviewSummary(client) {
1907
+ const [summary, setSummary] = useState5(null);
1908
+ useEffect6(() => {
1909
+ const ctrl = new AbortController();
1910
+ client.fetchSummary(ctrl.signal).then((s) => {
1911
+ if (!ctrl.signal.aborted) setSummary(s);
1912
+ }).catch(() => {
1913
+ });
1914
+ return () => ctrl.abort();
1915
+ }, [client]);
1916
+ return summary;
1917
+ }
1918
+
1919
+ // src/widget/viewer/MediaReviewViewer.tsx
1920
+ import { useCallback as useCallback4, useEffect as useEffect7, useRef as useRef4, useState as useState6 } from "react";
1921
+ import {
1922
+ Animated as Animated2,
1923
+ Easing as Easing2,
1924
+ FlatList,
1925
+ Image as Image3,
1926
+ Modal as Modal2,
1927
+ Platform,
1928
+ Pressable as Pressable7,
1929
+ Text as Text10,
1930
+ View as View12
1931
+ } from "react-native";
1932
+ import {
1933
+ GestureHandlerRootView,
1934
+ FlatList as GHFlatList,
1935
+ ScrollView as GHScrollView
1936
+ } from "react-native-gesture-handler";
1937
+ import { jsx as jsx17, jsxs as jsxs10 } from "react/jsx-runtime";
1938
+ function MediaReviewViewer({
1939
+ reviews,
1940
+ initialReviewId,
1941
+ initialMediaIndex,
1942
+ onClose
1943
+ }) {
1944
+ const { styles: styles3, client } = useWidget();
1945
+ const startIndex = Math.max(
1946
+ 0,
1947
+ reviews.findIndex((r) => r.id === initialReviewId)
1948
+ );
1949
+ const [reviewIndex, setReviewIndex] = useState6(startIndex);
1950
+ const [mediaIndexByReview, setMediaIndexByReview] = useState6(() => ({
1951
+ [reviews[startIndex]?.id ?? -1]: initialMediaIndex || 0
1952
+ }));
1953
+ const [fullBodies, setFullBodies] = useState6({});
1954
+ const vListRef = useRef4(null);
1955
+ const fade = useRef4(new Animated2.Value(0)).current;
1956
+ useEffect7(() => {
1957
+ Animated2.timing(fade, {
1958
+ toValue: 1,
1959
+ duration: 200,
1960
+ easing: Easing2.out(Easing2.quad),
1961
+ useNativeDriver: true
1962
+ }).start();
1963
+ }, [fade]);
1964
+ const close = useCallback4(() => {
1965
+ Animated2.timing(fade, {
1966
+ toValue: 0,
1967
+ duration: 180,
1968
+ easing: Easing2.in(Easing2.quad),
1969
+ useNativeDriver: true
1970
+ }).start(() => onClose());
1971
+ }, [fade, onClose]);
1972
+ useEffect7(() => {
1973
+ const next = reviews[reviewIndex + 1];
1974
+ if (!next || !next.body_truncated || fullBodies[next.id]) return;
1975
+ let cancelled = false;
1976
+ client.getReviewDetail(next.id).then((detail) => {
1977
+ if (!cancelled) setFullBodies((m) => ({ ...m, [next.id]: detail.body }));
1978
+ }).catch(() => {
1979
+ });
1980
+ return () => {
1981
+ cancelled = true;
1982
+ };
1983
+ }, [reviewIndex, reviews, fullBodies, client]);
1984
+ const onMomentumScrollEnd = useCallback4(
1985
+ (e) => {
1986
+ const next = Math.round(e.nativeEvent.contentOffset.y / SCREEN_H);
1987
+ if (next !== reviewIndex) setReviewIndex(next);
1988
+ },
1989
+ [reviewIndex]
1990
+ );
1991
+ const setMediaIndex = useCallback4((reviewId, i) => {
1992
+ setMediaIndexByReview((m) => ({ ...m, [reviewId]: i }));
1993
+ }, []);
1994
+ if (!reviews.length) return null;
1995
+ return /* @__PURE__ */ jsx17(
1996
+ Modal2,
1997
+ {
1998
+ visible: true,
1999
+ transparent: true,
2000
+ animationType: "none",
2001
+ presentationStyle: "overFullScreen",
2002
+ onRequestClose: close,
2003
+ statusBarTranslucent: true,
2004
+ children: /* @__PURE__ */ jsx17(GestureHandlerRootView, { style: styles3.viewerRoot, children: /* @__PURE__ */ jsxs10(Animated2.View, { style: [styles3.viewerRoot, { opacity: fade }], accessibilityViewIsModal: true, children: [
2005
+ /* @__PURE__ */ jsx17(
2006
+ GHFlatList,
2007
+ {
2008
+ ref: vListRef,
2009
+ data: reviews,
2010
+ keyExtractor: (r) => `vr-${r.id}`,
2011
+ pagingEnabled: true,
2012
+ showsVerticalScrollIndicator: false,
2013
+ initialScrollIndex: reviewIndex,
2014
+ getItemLayout: (_, i) => ({ length: SCREEN_H, offset: SCREEN_H * i, index: i }),
2015
+ onMomentumScrollEnd,
2016
+ decelerationRate: "fast",
2017
+ snapToInterval: SCREEN_H,
2018
+ initialNumToRender: 1,
2019
+ windowSize: 3,
2020
+ removeClippedSubviews: Platform.OS === "android",
2021
+ renderItem: ({ item, index }) => /* @__PURE__ */ jsx17(
2022
+ ViewerPage,
2023
+ {
2024
+ review: item,
2025
+ reviewPositionLabel: `Review ${index + 1} / ${reviews.length}`,
2026
+ mediaIndex: mediaIndexByReview[item.id] || 0,
2027
+ onMediaIndexChange: (i) => setMediaIndex(item.id, i),
2028
+ onSwipeNextReview: () => {
2029
+ if (index < reviews.length - 1) {
2030
+ vListRef.current?.scrollToIndex({ index: index + 1, animated: true });
2031
+ }
2032
+ },
2033
+ preFetchedBody: fullBodies[item.id],
2034
+ onRequestClose: close
2035
+ }
2036
+ )
2037
+ }
2038
+ ),
2039
+ /* @__PURE__ */ jsx17(
2040
+ Pressable7,
2041
+ {
2042
+ style: ({ pressed }) => [styles3.viewerClose, pressed && { opacity: 0.7 }],
2043
+ onPress: close,
2044
+ accessibilityLabel: "Close review viewer",
2045
+ hitSlop: 12,
2046
+ children: /* @__PURE__ */ jsx17(IconXClose, { size: 20, color: "#fff" })
2047
+ }
2048
+ )
2049
+ ] }) })
2050
+ }
2051
+ );
2052
+ }
2053
+ function ViewerPage({
2054
+ review,
2055
+ reviewPositionLabel,
2056
+ mediaIndex,
2057
+ onMediaIndexChange,
2058
+ onSwipeNextReview,
2059
+ preFetchedBody,
2060
+ onRequestClose
2061
+ }) {
2062
+ const { styles: styles3, theme } = useWidget();
2063
+ const { expanded, body, error, toggle } = useReviewDetail(review, preFetchedBody);
2064
+ const [replyOpen, setReplyOpen] = useState6(false);
2065
+ const media = review.media.filter((m) => m.type === "image");
2066
+ const reply = review.merchant_reply;
2067
+ const visibleTags = review.tags.slice(0, 4);
2068
+ const wasTruncated = review.body_truncated === true;
2069
+ return /* @__PURE__ */ jsxs10(View12, { style: { width: SCREEN_W, height: SCREEN_H }, children: [
2070
+ /* @__PURE__ */ jsxs10(View12, { style: styles3.viewerMediaZone, children: [
2071
+ /* @__PURE__ */ jsx17(
2072
+ FlatList,
2073
+ {
2074
+ data: media,
2075
+ keyExtractor: (_, i) => `hm-${review.id}-${i}`,
2076
+ horizontal: true,
2077
+ pagingEnabled: true,
2078
+ showsHorizontalScrollIndicator: false,
2079
+ initialScrollIndex: mediaIndex,
2080
+ getItemLayout: (_, i) => ({ length: SCREEN_W, offset: SCREEN_W * i, index: i }),
2081
+ onMomentumScrollEnd: (e) => {
2082
+ const i = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W);
2083
+ if (i === mediaIndex && media.length <= 1) onSwipeNextReview();
2084
+ else onMediaIndexChange(i);
2085
+ },
2086
+ onScrollEndDrag: (e) => onMediaIndexChange(Math.round(e.nativeEvent.contentOffset.x / SCREEN_W)),
2087
+ renderItem: ({ item, index }) => /* @__PURE__ */ jsx17(
2088
+ View12,
2089
+ {
2090
+ style: [styles3.viewerMediaPage, { width: SCREEN_W }],
2091
+ accessibilityLabel: `Photo ${index + 1} of ${media.length} from ${review.author || "Anonymous"}'s review`,
2092
+ children: /* @__PURE__ */ jsx17(
2093
+ Image3,
2094
+ {
2095
+ source: { uri: item.full || void 0 },
2096
+ style: styles3.viewerImage,
2097
+ resizeMode: "contain",
2098
+ accessible: true,
2099
+ accessibilityLabel: item.alt || "Customer review photo"
2100
+ }
2101
+ )
2102
+ }
2103
+ )
2104
+ }
2105
+ ),
2106
+ /* @__PURE__ */ jsxs10(View12, { style: styles3.viewerTopOverlay, pointerEvents: "none", children: [
2107
+ media.length > 1 ? /* @__PURE__ */ jsx17(View12, { style: styles3.viewerDots, children: media.map((_, i) => /* @__PURE__ */ jsx17(View12, { style: [styles3.viewerDot, i === mediaIndex && styles3.viewerDotActive] }, i)) }) : /* @__PURE__ */ jsx17(View12, {}),
2108
+ /* @__PURE__ */ jsx17(View12, { style: styles3.viewerPosition, children: /* @__PURE__ */ jsx17(Text10, { style: styles3.viewerPositionText, children: reviewPositionLabel }) })
2109
+ ] })
2110
+ ] }),
2111
+ /* @__PURE__ */ jsxs10(View12, { style: styles3.viewerCard, accessibilityRole: "summary", children: [
2112
+ /* @__PURE__ */ jsx17(
2113
+ Pressable7,
2114
+ {
2115
+ onPress: onRequestClose,
2116
+ hitSlop: 12,
2117
+ accessibilityLabel: "Close review viewer",
2118
+ accessibilityRole: "button",
2119
+ style: styles3.viewerHandleHit,
2120
+ children: /* @__PURE__ */ jsx17(View12, { style: styles3.viewerHandle })
2121
+ }
2122
+ ),
2123
+ /* @__PURE__ */ jsxs10(
2124
+ GHScrollView,
2125
+ {
2126
+ style: styles3.viewerCardScroll,
2127
+ contentContainerStyle: styles3.viewerCardContent,
2128
+ showsVerticalScrollIndicator: false,
2129
+ children: [
2130
+ /* @__PURE__ */ jsxs10(View12, { style: styles3.viewerCardHeader, children: [
2131
+ /* @__PURE__ */ jsx17(View12, { style: styles3.viewerAvatar, children: /* @__PURE__ */ jsx17(Text10, { style: styles3.viewerAvatarText, children: getInitials(review.author) }) }),
2132
+ /* @__PURE__ */ jsxs10(View12, { style: { flex: 1, minWidth: 0 }, children: [
2133
+ /* @__PURE__ */ jsxs10(View12, { style: styles3.viewerHeaderTopRow, children: [
2134
+ /* @__PURE__ */ jsx17(Text10, { style: styles3.viewerAuthor, numberOfLines: 1, children: review.author || "Anonymous" }),
2135
+ /* @__PURE__ */ jsx17(Text10, { style: styles3.viewerDate, children: review.date })
2136
+ ] }),
2137
+ /* @__PURE__ */ jsxs10(View12, { style: styles3.viewerStarsRow, children: [
2138
+ /* @__PURE__ */ jsx17(RatingStars, { value: review.rating, size: 13, uid: `v-${review.id}` }),
2139
+ review.verified && /* @__PURE__ */ jsxs10(View12, { style: styles3.verifiedBadge, children: [
2140
+ /* @__PURE__ */ jsx17(IconShieldCheck, { size: 11, color: theme.verified }),
2141
+ /* @__PURE__ */ jsx17(Text10, { style: styles3.verifiedBadgeText, children: "Verified Buyer" })
2142
+ ] })
2143
+ ] })
2144
+ ] })
2145
+ ] }),
2146
+ !!review.title && /* @__PURE__ */ jsx17(Text10, { style: styles3.viewerTitle, children: review.title }),
2147
+ !!body && /* @__PURE__ */ jsx17(Text10, { style: styles3.viewerBody, numberOfLines: wasTruncated && !expanded ? 4 : void 0, children: body }),
2148
+ wasTruncated && /* @__PURE__ */ jsx17(
2149
+ Pressable7,
2150
+ {
2151
+ onPress: toggle,
2152
+ style: ({ pressed }) => [styles3.readMoreBtn, pressed && { opacity: 0.6 }],
2153
+ accessibilityRole: "button",
2154
+ accessibilityLabel: expanded ? "Read less" : "Read more",
2155
+ children: /* @__PURE__ */ jsx17(Text10, { style: styles3.readMoreBtnText, children: expanded ? "Read less" : "Read more" })
2156
+ }
2157
+ ),
2158
+ error && /* @__PURE__ */ jsx17(Text10, { style: styles3.readMoreErr, children: "Could not load full review \u2014 showing preview." }),
2159
+ visibleTags.length > 0 && /* @__PURE__ */ jsx17(View12, { style: styles3.tagsRow, children: visibleTags.map((tag, i) => /* @__PURE__ */ jsx17(View12, { style: styles3.tag, children: /* @__PURE__ */ jsx17(Text10, { style: styles3.tagText, children: tag }) }, i)) }),
2160
+ /* @__PURE__ */ jsx17(View12, { style: styles3.viewerActionsRow, children: /* @__PURE__ */ jsx17(VoteButtons, { review }) }),
2161
+ reply && /* @__PURE__ */ jsx17(
2162
+ Pressable7,
2163
+ {
2164
+ onPress: () => setReplyOpen((v8) => !v8),
2165
+ style: ({ pressed }) => [styles3.viewerReplyToggle, pressed && { opacity: 0.7 }],
2166
+ accessibilityRole: "button",
2167
+ accessibilityLabel: replyOpen ? "Hide merchant reply" : "Show merchant reply",
2168
+ children: /* @__PURE__ */ jsx17(Text10, { style: styles3.viewerReplyToggleText, children: replyOpen ? `Hide reply from ${reply.author_name}` : `Reply from ${reply.author_name} \u2192` })
2169
+ }
2170
+ ),
2171
+ reply && replyOpen && /* @__PURE__ */ jsxs10(View12, { style: styles3.merchantReply, children: [
2172
+ /* @__PURE__ */ jsxs10(View12, { style: styles3.merchantReplyHeader, children: [
2173
+ /* @__PURE__ */ jsx17(Text10, { style: styles3.merchantReplyAuthor, children: reply.author_name }),
2174
+ /* @__PURE__ */ jsx17(Text10, { style: styles3.merchantReplyDate, children: reply.date })
2175
+ ] }),
2176
+ /* @__PURE__ */ jsx17(Text10, { style: styles3.merchantReplyBody, children: reply.body })
2177
+ ] })
2178
+ ]
2179
+ }
2180
+ )
2181
+ ] })
2182
+ ] });
2183
+ }
2184
+
2185
+ // src/widget/ReviewWidget.tsx
2186
+ import { jsx as jsx18, jsxs as jsxs11 } from "react/jsx-runtime";
2187
+ function ReviewWidget(props) {
2188
+ const { client: clientProp, fetcher, storeId, productId, voteStateStore, onWriteReview, initialSort } = props;
2189
+ const { theme: rawTheme, emit } = useBetterReviews();
2190
+ const client = useMemo3(() => {
2191
+ if (clientProp) return clientProp;
2192
+ if (!fetcher || !storeId || !productId) {
2193
+ throw new Error(
2194
+ "<ReviewWidget> requires either a `client` or `fetcher` + `storeId` + `productId`."
2195
+ );
2196
+ }
2197
+ return createBetterReviewsClient({
2198
+ fetcher,
2199
+ storeId,
2200
+ productId,
2201
+ onSchemaViolation: (info) => emit({
2202
+ type: "betterreviews.schema.violation",
2203
+ schema_version: 1,
2204
+ violation_path: info.firstBadPath,
2205
+ dropped_count: info.droppedCount
2206
+ })
2207
+ });
2208
+ }, [clientProp, fetcher, storeId, productId, emit]);
2209
+ const theme = useMemo3(() => resolveWidgetTheme(rawTheme), [rawTheme]);
2210
+ const styles3 = useMemo3(() => makeWidgetStyles(theme), [theme]);
2211
+ const fallbackStore = useMemo3(() => createMemoryVoteStore(), []);
2212
+ const voteStore = voteStateStore ?? fallbackStore;
2213
+ const list = useReviewList(client, emit, initialSort);
2214
+ const summary = useReviewSummary(client);
2215
+ const [viewer, setViewer] = useState7(null);
2216
+ const [sortOpen, setSortOpen] = useState7(false);
2217
+ const ctx = useMemo3(
2218
+ () => ({ client, voteStore, theme, styles: styles3, emit, onWriteReview }),
2219
+ [client, voteStore, theme, styles3, emit, onWriteReview]
2220
+ );
2221
+ const onOpenPhoto = (reviewId, mediaIndex) => setViewer({ reviewId, mediaIndex });
2222
+ const galleryPhotos = useMemo3(() => {
2223
+ const seen = /* @__PURE__ */ new Set();
2224
+ const out = [];
2225
+ for (const r of list.reviews) {
2226
+ const imgs = r.media.filter((m) => m.type === "image");
2227
+ imgs.forEach((m, i) => {
2228
+ const key = m.full || m.thumbnail;
2229
+ if (!key || seen.has(key)) return;
2230
+ seen.add(key);
2231
+ out.push({ thumbnail: m.thumbnail, full: m.full, reviewId: r.id, mediaIndex: i });
2232
+ });
2233
+ if (out.length >= 24) break;
2234
+ }
2235
+ return out.slice(0, 24);
2236
+ }, [list.reviews]);
2237
+ const reviewsWithMedia = useMemo3(
2238
+ () => list.reviews.filter((r) => r.media.some((m) => m.type === "image")),
2239
+ [list.reviews]
2240
+ );
2241
+ const header = /* @__PURE__ */ jsxs11(View13, { children: [
2242
+ /* @__PURE__ */ jsx18(SectionTitle, { children: "Customer Reviews" }),
2243
+ summary && /* @__PURE__ */ jsxs11(View13, { style: styles3.pulse, children: [
2244
+ /* @__PURE__ */ jsx18(PulseSummary, { summary }),
2245
+ /* @__PURE__ */ jsx18(MediaGallery, { photos: galleryPhotos, onOpen: onOpenPhoto }),
2246
+ /* @__PURE__ */ jsx18(WriteReviewBtn, {}),
2247
+ /* @__PURE__ */ jsx18(PulseMetrics, { summary })
2248
+ ] }),
2249
+ /* @__PURE__ */ jsx18(
2250
+ FilterToolbar,
2251
+ {
2252
+ rating: list.rating,
2253
+ mediaOnly: list.mediaOnly,
2254
+ search: list.searchInput,
2255
+ onChange: list.onFilterChange
2256
+ }
2257
+ ),
2258
+ /* @__PURE__ */ jsx18(SortBar, { sort: list.sort, onOpenSort: () => setSortOpen(true) }),
2259
+ /* @__PURE__ */ jsx18(StaleListOverlay, { active: list.refetching })
2260
+ ] });
2261
+ if (list.loading && list.reviews.length === 0) {
2262
+ return /* @__PURE__ */ jsx18(WidgetProvider, { value: ctx, children: /* @__PURE__ */ jsxs11(View13, { style: styles3.center, children: [
2263
+ /* @__PURE__ */ jsx18(ActivityIndicator, { color: theme.accent }),
2264
+ /* @__PURE__ */ jsx18(Text11, { style: styles3.muted, children: "Loading reviews\u2026" })
2265
+ ] }) });
2266
+ }
2267
+ if (list.error && list.reviews.length === 0) {
2268
+ return /* @__PURE__ */ jsx18(WidgetProvider, { value: ctx, children: /* @__PURE__ */ jsxs11(View13, { style: styles3.center, children: [
2269
+ /* @__PURE__ */ jsx18(Text11, { style: styles3.errorLine, children: "Reviews unavailable" }),
2270
+ /* @__PURE__ */ jsx18(Pressable8, { onPress: list.reload, accessibilityHint: "Tap to try loading reviews again", children: /* @__PURE__ */ jsx18(Text11, { style: styles3.retryLink, children: "Try again" }) })
2271
+ ] }) });
2272
+ }
2273
+ const allDropped = list.reviews.length === 0 && (list.pagination?.total ?? 0) > 0;
2274
+ return /* @__PURE__ */ jsxs11(WidgetProvider, { value: ctx, children: [
2275
+ /* @__PURE__ */ jsxs11(View13, { style: styles3.list, children: [
2276
+ header,
2277
+ list.reviews.length === 0 ? /* @__PURE__ */ jsxs11(View13, { style: styles3.emptyState, children: [
2278
+ /* @__PURE__ */ jsx18(Text11, { style: styles3.emptyStateText, children: allDropped ? "Reviews could not be displayed." : "No reviews match your filter." }),
2279
+ !allDropped && /* @__PURE__ */ jsx18(Pressable8, { onPress: () => list.onFilterChange({ rating: null, mediaOnly: false, search: "" }), children: /* @__PURE__ */ jsx18(Text11, { style: styles3.emptyStateClear, children: "Clear filters" }) })
2280
+ ] }) : list.reviews.map((item) => /* @__PURE__ */ jsx18(View13, { style: list.refetching ? { opacity: 0.5 } : void 0, children: /* @__PURE__ */ jsx18(ReviewCard, { review: item, onOpenPhoto }) }, item.id)),
2281
+ list.pagination?.has_next && /* @__PURE__ */ jsx18(View13, { style: styles3.footerWrap, children: /* @__PURE__ */ jsx18(
2282
+ Pressable8,
2283
+ {
2284
+ onPress: list.onLoadMore,
2285
+ disabled: list.loadingMore,
2286
+ style: ({ pressed }) => [
2287
+ styles3.showMore,
2288
+ list.loadingMore && styles3.showMoreDisabled,
2289
+ pressed && { borderColor: theme.text }
2290
+ ],
2291
+ accessibilityRole: "button",
2292
+ accessibilityLabel: "Load more reviews",
2293
+ children: list.loadingMore ? /* @__PURE__ */ jsx18(ActivityIndicator, { color: theme.text }) : /* @__PURE__ */ jsx18(Text11, { style: styles3.showMoreText, children: "Load more" })
2294
+ }
2295
+ ) })
2296
+ ] }),
2297
+ /* @__PURE__ */ jsx18(
2298
+ SortDrawer,
2299
+ {
2300
+ visible: sortOpen,
2301
+ current: list.sort,
2302
+ onSelect: list.setSort,
2303
+ onClose: () => setSortOpen(false)
2304
+ }
2305
+ ),
2306
+ viewer && reviewsWithMedia.length > 0 && /* @__PURE__ */ jsx18(
2307
+ MediaReviewViewer,
2308
+ {
2309
+ reviews: reviewsWithMedia,
2310
+ initialReviewId: viewer.reviewId,
2311
+ initialMediaIndex: viewer.mediaIndex,
2312
+ onClose: () => setViewer(null)
2313
+ }
2314
+ )
2315
+ ] });
2316
+ }
2317
+
2318
+ // src/bridge.ts
2319
+ function resolveBridge(config, emit) {
2320
+ const requested = config?.bridge;
2321
+ if (requested && requested !== "off") {
2322
+ emit({
2323
+ type: "betterreviews.fetch.failure",
2324
+ error_code: "bridge_not_implemented"
2325
+ });
2326
+ }
2327
+ return "in_webview";
2328
+ }
2329
+ export {
2330
+ BetterReviewsProvider,
2331
+ FeaturesSection,
2332
+ ProductContentBlock,
2333
+ ReviewWidget,
2334
+ ReviewsSummarySection,
2335
+ SDK_VERSION,
2336
+ StarRating,
2337
+ WebViewHost,
2338
+ applyTheme,
2339
+ createBetterReviewsClient,
2340
+ createMemoryVoteStore,
2341
+ meetsFloor,
2342
+ noopTelemetry,
2343
+ resolveBridge,
2344
+ safeTelemetry,
2345
+ useBetterReviews
2346
+ };