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