@betterreviews/react-native 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +145 -0
  2. package/README.md +189 -0
  3. package/SECURITY.md +238 -0
  4. package/dist/index.d.mts +581 -0
  5. package/dist/index.d.ts +581 -0
  6. package/dist/index.js +2384 -0
  7. package/dist/index.mjs +2346 -0
  8. package/package.json +78 -0
  9. package/src/BetterReviewsProvider.tsx +62 -0
  10. package/src/ProductContentBlock.tsx +143 -0
  11. package/src/StarRating.tsx +85 -0
  12. package/src/WebViewHost.tsx +164 -0
  13. package/src/bridge.ts +48 -0
  14. package/src/client/createBetterReviewsClient.ts +211 -0
  15. package/src/client/types.ts +101 -0
  16. package/src/icons/BRIcons.tsx +176 -0
  17. package/src/index.ts +74 -0
  18. package/src/minSdkVersion.ts +52 -0
  19. package/src/sections/FeaturesSection.tsx +69 -0
  20. package/src/sections/ReviewsSummarySection.tsx +47 -0
  21. package/src/telemetry.ts +52 -0
  22. package/src/theme/applyTheme.ts +72 -0
  23. package/src/theme/widgetTheme.ts +67 -0
  24. package/src/webviewMessage.ts +23 -0
  25. package/src/widget/ReviewWidget.tsx +230 -0
  26. package/src/widget/WidgetContext.tsx +43 -0
  27. package/src/widget/components/FilterToolbar.tsx +146 -0
  28. package/src/widget/components/MediaGallery.tsx +53 -0
  29. package/src/widget/components/PulseSection.tsx +69 -0
  30. package/src/widget/components/RatingStars.tsx +40 -0
  31. package/src/widget/components/ReviewCard.tsx +114 -0
  32. package/src/widget/components/SortDrawer.tsx +49 -0
  33. package/src/widget/components/StaleListOverlay.tsx +51 -0
  34. package/src/widget/components/VoteButtons.tsx +55 -0
  35. package/src/widget/hooks/useReviewDetail.ts +55 -0
  36. package/src/widget/hooks/useReviewList.ts +136 -0
  37. package/src/widget/hooks/useReviewSummary.ts +24 -0
  38. package/src/widget/hooks/useVote.ts +68 -0
  39. package/src/widget/styles.ts +393 -0
  40. package/src/widget/util.ts +21 -0
  41. package/src/widget/viewer/MediaReviewViewer.tsx +350 -0
@@ -0,0 +1,581 @@
1
+ import React from 'react';
2
+ import * as v from 'valibot';
3
+ import { WebView } from 'react-native-webview';
4
+
5
+ /**
6
+ * `betterreviews_reactiv.config` (v1)
7
+ *
8
+ * Per-product render config. Signals "merchant has configured this
9
+ * product" so the RN package can fall back gracefully when no
10
+ * ProductContentBlock data exists.
11
+ *
12
+ * Schema SHAPE-OF-TRUTH: matches `PPO.Reactiv.ContentResolver.resolve_for_store/2`
13
+ * config emit shape today. The proposal doc mentions `bridge` and
14
+ * `min_sdk_version` as future fields; the resolver does not emit them
15
+ * yet. Both are forward-compat OPTIONAL so the schema accepts future
16
+ * resolver versions without a schema bump.
17
+ *
18
+ * See `docs/proposals/reactiv-schema-2026-05-20.md` § 10 for the
19
+ * future-state shape (bridge / min_sdk_version land in Card C.14 +
20
+ * post-soft-launch RN work).
21
+ */
22
+
23
+ declare const configSchema: v.ObjectSchema<{
24
+ readonly v: v.LiteralSchema<1, undefined>;
25
+ readonly updated_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, undefined>]>;
26
+ readonly product_content_block_enabled: v.BooleanSchema<undefined>;
27
+ readonly min_sdk_version: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "min_sdk_version must be valid semver">]>, undefined>;
28
+ readonly bridge: v.OptionalSchema<v.PicklistSchema<["auto", "required", "off"], undefined>, undefined>;
29
+ }, undefined>;
30
+ type Config = v.InferOutput<typeof configSchema>;
31
+
32
+ /**
33
+ * `betterreviews_reactiv.theme` (v1)
34
+ *
35
+ * Theme inherited from the merchant's `review_settings.conversation_theme`.
36
+ * Colors are passed through `PPO.Reactiv.Color.normalize/1` on the
37
+ * Elixir side (rgb/rgba/hsl/hex input → canonical hex output) before
38
+ * being emitted by the resolver.
39
+ *
40
+ * Schema SHAPE-OF-TRUTH: matches `PPO.Reactiv.ContentResolver.build_theme/1`
41
+ * + `PPOWeb.Live.ThemeHelpers.@font_map`/@corner_map. The
42
+ * `docs/proposals/reactiv-schema-2026-05-20.md` § 10 sketch proposed a
43
+ * richer nested `palette`/`typography`/`corners` structure; the
44
+ * implemented resolver emits the flatter shape below. Proposal-vs-impl
45
+ * drift documented in `schemas/betterreviews-reactiv/DESIGN.md`.
46
+ *
47
+ * All theme fields except `v` are OPTIONAL — the resolver emits a
48
+ * field only when the corresponding store-level theme value is set
49
+ * (`maybe_put_color`/`maybe_put` semantics).
50
+ */
51
+
52
+ declare const themeSchema: v.ObjectSchema<{
53
+ readonly v: v.LiteralSchema<1, undefined>;
54
+ readonly primary_color: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "Color must be hex #RRGGBB (case-insensitive)">]>, undefined>;
55
+ readonly background_color: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "Color must be hex #RRGGBB (case-insensitive)">]>, undefined>;
56
+ readonly text_color: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "Color must be hex #RRGGBB (case-insensitive)">]>, undefined>;
57
+ readonly accent_color: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "Color must be hex #RRGGBB (case-insensitive)">]>, undefined>;
58
+ readonly corner_style: v.OptionalSchema<v.PicklistSchema<["sharp", "slightly-rounded", "rounded", "extra-rounded"], undefined>, undefined>;
59
+ readonly font_family: v.OptionalSchema<v.PicklistSchema<["system", "serif", "sans-serif", "mono"], undefined>, undefined>;
60
+ }, undefined>;
61
+ type Theme = v.InferOutput<typeof themeSchema>;
62
+
63
+ /**
64
+ * Telemetry event types emitted by the package. Per SECURITY.md, all
65
+ * events are pre-redacted — no customer email, name, IP, transcript,
66
+ * or review content appears in any payload.
67
+ *
68
+ * Consumers (partner host apps) wire `onTelemetryEvent` on the
69
+ * `<BetterReviewsProvider>` to forward events to their own
70
+ * observability stack.
71
+ */
72
+ type TelemetryEvent = {
73
+ type: 'betterreviews.fetch.success';
74
+ block_id?: string;
75
+ latency_ms?: number;
76
+ cache_hit?: boolean;
77
+ } | {
78
+ type: 'betterreviews.fetch.failure';
79
+ block_id?: string;
80
+ error_code: string;
81
+ retry_attempt?: number;
82
+ } | {
83
+ type: 'betterreviews.signature.invalid';
84
+ endpoint?: string;
85
+ source_ip_hash?: string;
86
+ } | {
87
+ type: 'betterreviews.schema.violation';
88
+ schema_version: number;
89
+ violation_path?: string;
90
+ dropped_count?: number;
91
+ } | {
92
+ type: 'betterreviews.webview.error';
93
+ error_code: string;
94
+ url_origin?: string;
95
+ };
96
+ type TelemetryHandler = (event: TelemetryEvent) => void;
97
+ /**
98
+ * Returns a handler that swallows all events. Used as the default
99
+ * when the partner host app doesn't supply `onTelemetryEvent`.
100
+ */
101
+ declare function noopTelemetry(): TelemetryHandler;
102
+ /**
103
+ * Wraps a handler so synchronous exceptions in the partner's
104
+ * implementation can't crash the renderer. Partner-side bugs become
105
+ * silent failures rather than UI outages.
106
+ */
107
+ declare function safeTelemetry(handler: TelemetryHandler | undefined): TelemetryHandler;
108
+
109
+ /**
110
+ * Context provider for the BetterReviews RN package. Partner host
111
+ * apps wrap their navigation tree with `<BetterReviewsProvider>` and
112
+ * supply the merchant-resolved `theme` + `config` + an optional
113
+ * `onTelemetryEvent` callback.
114
+ *
115
+ * The provider is intentionally thin — no fetching, no state. The
116
+ * partner is responsible for fetching the metafield bodies from
117
+ * Shopify and passing them in. Decoupling fetch from render lets the
118
+ * partner cache however they want (subject to the 1h TTL ceiling per
119
+ * SECURITY.md § "Cache TTL contract").
120
+ */
121
+
122
+ interface BetterReviewsContextValue {
123
+ theme: Theme | null;
124
+ config: Config | null;
125
+ emit: TelemetryHandler;
126
+ }
127
+ interface BetterReviewsProviderProps {
128
+ theme?: Theme | null;
129
+ config?: Config | null;
130
+ onTelemetryEvent?: TelemetryHandler;
131
+ children: React.ReactNode;
132
+ }
133
+ declare function BetterReviewsProvider({ theme, config, onTelemetryEvent, children, }: BetterReviewsProviderProps): React.ReactElement;
134
+ declare function useBetterReviews(): BetterReviewsContextValue;
135
+
136
+ /**
137
+ * `betterreviews_reactiv.product_content_block` (v1)
138
+ *
139
+ * Merchant-curated mobile PDP content. Resolved server-side by
140
+ * `PPO.Reactiv.ContentResolver`; RN reads exactly what merchant intent
141
+ * resolved to, no client-side fallback.
142
+ *
143
+ * Schema SHAPE-OF-TRUTH: this file matches what
144
+ * `lib/ppo/reactiv/content_resolver.ex` emits today. The
145
+ * `docs/proposals/reactiv-schema-2026-05-20.md` § 10 sketch used
146
+ * `kind` + a `show` boolean for `reviews_summary`; the implemented
147
+ * resolver uses `type` + presence-as-toggle. The implemented form is
148
+ * the production contract (PR #88 has been on `dev` since 2026-05-22)
149
+ * — proposal-vs-impl drift documented in
150
+ * `schemas/betterreviews-reactiv/DESIGN.md` § "Schema vs proposal doc".
151
+ */
152
+
153
+ declare const productContentBlockSchema: v.ObjectSchema<{
154
+ readonly v: v.LiteralSchema<1, undefined>;
155
+ readonly updated_at: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, undefined>]>;
156
+ readonly sections: v.ArraySchema<v.VariantSchema<"type", [v.ObjectSchema<{
157
+ readonly type: v.LiteralSchema<"features", undefined>;
158
+ readonly bullets: v.SchemaWithPipe<readonly [v.ArraySchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MaxLengthAction<string, 80, undefined>]>, undefined>, v.MaxLengthAction<string[], 6, undefined>]>;
159
+ }, undefined>, v.ObjectSchema<{
160
+ readonly type: v.LiteralSchema<"reviews_summary", undefined>;
161
+ }, undefined>], undefined>, undefined>;
162
+ }, undefined>;
163
+ type ProductContentBlock$1 = v.InferOutput<typeof productContentBlockSchema>;
164
+
165
+ /**
166
+ * Top-level renderer for `betterreviews_reactiv.product_content_block`.
167
+ * Iterates the merchant's section list and dispatches each to its
168
+ * type-specific renderer. Unknown section types are silently dropped
169
+ * (tolerant-reader contract) with a `schema.violation` telemetry
170
+ * event.
171
+ *
172
+ * Mount-time gates:
173
+ * 1. `config.product_content_block_enabled === false` — render nothing.
174
+ * 2. `config.min_sdk_version` declared and current SDK is below
175
+ * floor — render nothing + emit `fetch.failure` with
176
+ * `error_code: "sdk_below_floor"`.
177
+ *
178
+ * Per-section validation uses valibot's `safeParse` so a single
179
+ * malformed section doesn't crash the whole block — bad sections
180
+ * are dropped, known-good ones render.
181
+ *
182
+ * Telemetry discipline: parsing + section building happen purely during
183
+ * render (no side effects); every telemetry event is emitted from a
184
+ * `useEffect`. Emitting during render would invoke the host app's
185
+ * `onTelemetryEvent` (commonly a `setState`) mid-render and crash React
186
+ * with "Cannot update a component while rendering a different component."
187
+ */
188
+
189
+ interface ProductContentBlockProps {
190
+ /** Parsed metafield body from `betterreviews_reactiv.product_content_block`. */
191
+ block: ProductContentBlock$1 | null | undefined;
192
+ }
193
+ declare function ProductContentBlock({ block }: ProductContentBlockProps): React.ReactElement | null;
194
+
195
+ /**
196
+ * Renders the `features` section — a bullet list capped by the schema
197
+ * at 6 bullets / 80 chars each. The schema's strict-overlay validator
198
+ * on the Elixir side has already enforced those caps; this component
199
+ * renders defensively in case a partial fallback path slips through.
200
+ */
201
+
202
+ interface FeaturesSectionProps {
203
+ bullets: string[];
204
+ }
205
+ declare function FeaturesSection({ bullets }: FeaturesSectionProps): React.ReactElement;
206
+
207
+ /**
208
+ * Renders the `reviews_summary` section. v1 displays a placeholder
209
+ * — the partner host app is expected to supply the live aggregate
210
+ * (star rating, count, top quote) by mounting its own review-summary
211
+ * component as a child or sibling.
212
+ *
213
+ * The presence of this section in the metafield blob is the merchant's
214
+ * "show me the reviews summary on this product" toggle. Absence means
215
+ * "don't render this section." A future PR may expand this to render
216
+ * `betterreviews.summary` data directly when supplied via
217
+ * `BetterReviewsProvider`'s `summary` prop.
218
+ */
219
+
220
+ declare function ReviewsSummarySection(): React.ReactElement;
221
+
222
+ /**
223
+ * `WebViewHost` — the ONLY place `react-native-webview` is consumed.
224
+ *
225
+ * Critical seam discipline: this wrapper hides all underlying
226
+ * `WebView` props from callers. Public API is `{ url, onMessage,
227
+ * onError }` only. Recovery patches (e.g. adding
228
+ * `onContentProcessDidTerminate` if the Tier 2 WebView test surfaces
229
+ * a WebSocket-survival failure) are single-file, single-line changes
230
+ * INSIDE this file — zero changes at any caller. See
231
+ * `docs/proposals/reactiv-webview-tier2-test-2026-05-18.md` § "Known
232
+ * traps" for the documented recovery paths.
233
+ *
234
+ * Default `WebView` props match the Tier 2 test's chosen baseline:
235
+ * - sharedCookiesEnabled=false (worst-case isolation; Reactiv may
236
+ * flip later)
237
+ * - automaticallyAdjustContentInsets=false
238
+ * - contentInsetAdjustmentBehavior="never"
239
+ * - keyboardDisplayRequiresUserAction=false
240
+ * - allowsInlineMediaPlayback
241
+ * - mediaPlaybackRequiresUserAction=false
242
+ *
243
+ * The baseline matches what the Tier 2 test validates — keeps any
244
+ * future test pass/fail directly applicable.
245
+ */
246
+
247
+ type WebViewProps = React.ComponentProps<typeof WebView>;
248
+ type OnMessageHandler = NonNullable<WebViewProps['onMessage']>;
249
+ type OnErrorHandler = NonNullable<WebViewProps['onError']>;
250
+ interface WebViewHostProps {
251
+ /** Chat surface URL (e.g. `https://api.betterreviews.app/review/chat?...`). */
252
+ url: string;
253
+ /** Fires on every `postMessage` from inside the WebView (except the close signal — see `onClose`). */
254
+ onMessage?: OnMessageHandler;
255
+ /** Fires on navigation / load error. */
256
+ onError?: OnErrorHandler;
257
+ /**
258
+ * Fires when the embedded page asks the host to dismiss it by posting
259
+ * `{ "type": "close" }` (e.g. the chat's "Back to store" / done button).
260
+ * Wire this to close the modal/screen the WebView is in. Other messages
261
+ * still flow to `onMessage`.
262
+ */
263
+ onClose?: () => void;
264
+ }
265
+ declare function WebViewHost({ url, onMessage, onError, onClose }: WebViewHostProps): React.ReactElement | null;
266
+
267
+ /**
268
+ * `StarRating` — compact aggregate rating badge (stars + score + review
269
+ * count), the RN equivalent of the storefront `br-star-rating` block. It sits
270
+ * near the product title; tap it (via `onPress`) to jump to the reviews.
271
+ *
272
+ * The host supplies the aggregate (`average` + `total`) — typically from the
273
+ * `betterreviews.summary` metafield it already fetches. A badge must NOT
274
+ * trigger `ReviewWidget`'s multi-call summary synthesis.
275
+ *
276
+ * Theme (star color) comes from `<BetterReviewsProvider>` context, with a fixed
277
+ * gold fallback when used outside a provider.
278
+ */
279
+
280
+ interface StarRatingProps {
281
+ /** Aggregate average rating, e.g. 4.5. */
282
+ average: number;
283
+ /** Total review count. */
284
+ total: number;
285
+ /** Star pixel size (default 16). */
286
+ size?: number;
287
+ /** Override the star fill color (defaults to the theme's star color). */
288
+ starColor?: string;
289
+ /** Tap handler — e.g. scroll to the ReviewWidget. Renders as a button when set. */
290
+ onPress?: () => void;
291
+ /** Render nothing when there are no reviews (default true). */
292
+ hideWhenEmpty?: boolean;
293
+ }
294
+ declare function StarRating({ average, total, size, starColor, onPress, hideWhenEmpty, }: StarRatingProps): React.ReactElement | null;
295
+
296
+ /**
297
+ * Public widget API responses — review list / detail / vote.
298
+ *
299
+ * Schema SHAPE-OF-TRUTH: matches `PPO.Reviews.Widget.to_widget_json/2`
300
+ * + `paginate_meta/3` in `elixir/lib/ppo/reviews/widget.ex` (the
301
+ * `/api/widget/{store}/...` endpoints served by `WidgetController`).
302
+ *
303
+ * UNLIKE the `betterreviews_reactiv.*` metafield schemas in this package,
304
+ * these describe LIVE API responses, not merchant-curated metafields — so
305
+ * there is NO `v: 1` / `updated_at` envelope, and they are deliberately
306
+ * EXCLUDED from `scripts/generate.ts` (the JSON-Schema drift gate is for the
307
+ * server-side metafield pre-write validator; the widget API contract is
308
+ * owned by the Elixir controller, not validated from a generated schema).
309
+ *
310
+ * Tolerant reader: plain `v.object` ignores unknown keys (the server emits
311
+ * extra fields like `date_iso`/`display_product_*` that older SDK builds
312
+ * simply skip). Nullable where the server can emit `nil`.
313
+ */
314
+
315
+ declare const widgetMediaItemSchema: v.ObjectSchema<{
316
+ readonly type: v.StringSchema<undefined>;
317
+ readonly thumbnail: v.NullableSchema<v.StringSchema<undefined>, undefined>;
318
+ readonly full: v.NullableSchema<v.StringSchema<undefined>, undefined>;
319
+ readonly alt: v.NullableSchema<v.StringSchema<undefined>, undefined>;
320
+ }, undefined>;
321
+ declare const widgetMerchantReplySchema: v.ObjectSchema<{
322
+ readonly body: v.StringSchema<undefined>;
323
+ readonly author_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
324
+ readonly date: v.NullableSchema<v.StringSchema<undefined>, undefined>;
325
+ }, undefined>;
326
+ declare const widgetReviewSchema: v.ObjectSchema<{
327
+ readonly id: v.NumberSchema<undefined>;
328
+ readonly author: v.NullableSchema<v.StringSchema<undefined>, undefined>;
329
+ readonly rating: v.NumberSchema<undefined>;
330
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
331
+ readonly body: v.StringSchema<undefined>;
332
+ readonly body_truncated: v.BooleanSchema<undefined>;
333
+ readonly verified: v.BooleanSchema<undefined>;
334
+ readonly date: v.NullableSchema<v.StringSchema<undefined>, undefined>;
335
+ readonly date_iso: v.NullableSchema<v.StringSchema<undefined>, undefined>;
336
+ readonly helpful_count: v.NumberSchema<undefined>;
337
+ readonly unhelpful_count: v.NumberSchema<undefined>;
338
+ readonly media: v.ArraySchema<v.ObjectSchema<{
339
+ readonly type: v.StringSchema<undefined>;
340
+ readonly thumbnail: v.NullableSchema<v.StringSchema<undefined>, undefined>;
341
+ readonly full: v.NullableSchema<v.StringSchema<undefined>, undefined>;
342
+ readonly alt: v.NullableSchema<v.StringSchema<undefined>, undefined>;
343
+ }, undefined>, undefined>;
344
+ readonly tags: v.ArraySchema<v.StringSchema<undefined>, undefined>;
345
+ readonly merchant_reply: v.NullableSchema<v.ObjectSchema<{
346
+ readonly body: v.StringSchema<undefined>;
347
+ readonly author_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
348
+ readonly date: v.NullableSchema<v.StringSchema<undefined>, undefined>;
349
+ }, undefined>, undefined>;
350
+ readonly display_product_id: v.StringSchema<undefined>;
351
+ readonly display_product_title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
352
+ }, undefined>;
353
+ declare const widgetPaginationSchema: v.ObjectSchema<{
354
+ readonly page: v.NumberSchema<undefined>;
355
+ readonly per_page: v.NumberSchema<undefined>;
356
+ readonly total: v.NumberSchema<undefined>;
357
+ readonly total_pages: v.NumberSchema<undefined>;
358
+ readonly has_next: v.BooleanSchema<undefined>;
359
+ }, undefined>;
360
+ declare const widgetVoteCountsSchema: v.ObjectSchema<{
361
+ readonly helpful_count: v.NumberSchema<undefined>;
362
+ readonly unhelpful_count: v.NumberSchema<undefined>;
363
+ }, undefined>;
364
+ declare const widgetSortValueSchema: v.PicklistSchema<["most_relevant", "most_helpful", "newest", "highest", "lowest"], undefined>;
365
+ type WidgetMediaItem = v.InferOutput<typeof widgetMediaItemSchema>;
366
+ type WidgetMerchantReply = v.InferOutput<typeof widgetMerchantReplySchema>;
367
+ type WidgetReview = v.InferOutput<typeof widgetReviewSchema>;
368
+ type WidgetPagination = v.InferOutput<typeof widgetPaginationSchema>;
369
+ type WidgetVoteCounts = v.InferOutput<typeof widgetVoteCountsSchema>;
370
+ type WidgetSortValue = v.InferOutput<typeof widgetSortValueSchema>;
371
+
372
+ /**
373
+ * Synthesized review summary — NOT a server response.
374
+ *
375
+ * There is no summary endpoint on the widget API. The client computes this
376
+ * from 7 list calls (5 single-rating totals for the distribution, 1
377
+ * `media_only=true` for the photo count, 1 plain page for the verified
378
+ * count) — see `createBetterReviewsClient.fetchSummary`. This schema exists
379
+ * so the type re-exports symmetrically with the other widget types; it is
380
+ * NOT fed to `scripts/generate.ts`.
381
+ */
382
+
383
+ declare const widgetSummarySchema: v.ObjectSchema<{
384
+ readonly total: v.NumberSchema<undefined>;
385
+ readonly average: v.NumberSchema<undefined>;
386
+ readonly breakdown: v.ArraySchema<v.NumberSchema<undefined>, undefined>;
387
+ readonly positivePct: v.NumberSchema<undefined>;
388
+ readonly photoCount: v.NumberSchema<undefined>;
389
+ readonly verifiedCount: v.NumberSchema<undefined>;
390
+ }, undefined>;
391
+ type WidgetSummary = v.InferOutput<typeof widgetSummarySchema>;
392
+
393
+ /**
394
+ * Client types for the public widget API.
395
+ *
396
+ * The package owns the UI + pagination/sort/filter state and assembles the
397
+ * logical requests; the HOST owns transport — it implements the `Fetcher`,
398
+ * which prepends the API base URL and injects the auth `token`. No token
399
+ * ever lives in this package or the RN binary.
400
+ */
401
+
402
+ /**
403
+ * A logical request the host fetcher fulfils. The host MUST prepend the API
404
+ * base URL, inject the `token`, perform the request, throw on a non-2xx
405
+ * status, and resolve with the parsed JSON body. The host MUST NOT put the
406
+ * resolved URL or the token into any error it throws (the token rides in the
407
+ * query string — a logged URL/error leaks it).
408
+ */
409
+ interface FetcherRequest {
410
+ /** Path relative to the API base, e.g. `/api/widget/{store}/products/{product}/reviews`. */
411
+ path: string;
412
+ /** Non-secret query params. The token is added by the host, never here. */
413
+ query?: Record<string, string | number | boolean | undefined>;
414
+ method?: 'GET' | 'POST';
415
+ /** JSON-serializable body (vote only). */
416
+ body?: unknown;
417
+ signal?: AbortSignal;
418
+ }
419
+ type Fetcher = (req: FetcherRequest) => Promise<unknown>;
420
+ type VoteType = 'helpful' | 'unhelpful';
421
+ /**
422
+ * Host-provided persistence for "has this user voted on this review".
423
+ * Stores only review id → vote direction (no PII, no token, no review
424
+ * content) — safe for unencrypted storage like AsyncStorage. Do NOT widen
425
+ * it to cache review payloads.
426
+ */
427
+ interface VoteStateStore {
428
+ get(reviewId: number): VoteType | null | undefined;
429
+ set(reviewId: number, vote: VoteType | null): void;
430
+ }
431
+ interface ListParams {
432
+ page?: number;
433
+ perPage?: number;
434
+ sort?: WidgetSortValue;
435
+ rating?: 1 | 2 | 3 | 4 | 5 | null;
436
+ mediaOnly?: boolean;
437
+ search?: string;
438
+ }
439
+ interface ReviewListResult {
440
+ reviews: WidgetReview[];
441
+ pagination: WidgetPagination;
442
+ }
443
+ /**
444
+ * Reported once per list response when one or more rows fail schema
445
+ * validation. Carries paths + a count ONLY — never row contents (untrusted
446
+ * customer-authored review text must not reach telemetry).
447
+ */
448
+ interface SchemaViolationInfo {
449
+ droppedCount: number;
450
+ firstBadPath: string;
451
+ }
452
+ interface BetterReviewsClientConfig {
453
+ fetcher: Fetcher;
454
+ storeId: string;
455
+ productId: string;
456
+ onSchemaViolation?: (info: SchemaViolationInfo) => void;
457
+ }
458
+ interface BetterReviewsClient {
459
+ listReviews(params?: ListParams, signal?: AbortSignal): Promise<ReviewListResult>;
460
+ /** Detail is scoped to the configured product (`product_id` is always sent — the A4 IDOR fix). */
461
+ getReviewDetail(reviewId: number, signal?: AbortSignal): Promise<WidgetReview>;
462
+ /** Returns updated counts, or `null` if the POST succeeded but the body was unparseable. Rejects on HTTP/network error. */
463
+ voteReview(reviewId: number, type: VoteType, undo: boolean, signal?: AbortSignal): Promise<WidgetVoteCounts | null>;
464
+ fetchSummary(signal?: AbortSignal): Promise<WidgetSummary>;
465
+ }
466
+
467
+ /**
468
+ * `<ReviewWidget>` — the public, stateful review-browsing surface.
469
+ *
470
+ * Renders INLINE — it owns no scroll container, so it drops into a host's
471
+ * product-page `ScrollView` as one section (below the product info /
472
+ * `ProductContentBlock`) and scrolls with the page. Paging is a "Load more"
473
+ * button, so no virtualization is needed.
474
+ *
475
+ * Theme + telemetry flow from `<BetterReviewsProvider>` context (same
476
+ * convention as `ProductContentBlock`); transport/auth flow from the host via
477
+ * the injected `client` (or `fetcher` + ids). Schema violations are emitted as
478
+ * telemetry from the client's async callback (post-render, so the b9357c30
479
+ * "no emit during render" rule holds).
480
+ */
481
+
482
+ interface ReviewWidgetProps {
483
+ /** Primary: a client built via `createBetterReviewsClient`. */
484
+ client?: BetterReviewsClient;
485
+ /** Convenience: supply transport + ids and the widget builds the client. */
486
+ fetcher?: Fetcher;
487
+ storeId?: string;
488
+ productId?: string;
489
+ /** Persist "already voted" across launches; defaults to in-memory. */
490
+ voteStateStore?: VoteStateStore;
491
+ /** Host-owned write-review action; the CTA hides if omitted. */
492
+ onWriteReview?: () => void;
493
+ initialSort?: WidgetSortValue;
494
+ }
495
+ declare function ReviewWidget(props: ReviewWidgetProps): React.ReactElement;
496
+
497
+ declare function createBetterReviewsClient(config: BetterReviewsClientConfig): BetterReviewsClient;
498
+ /**
499
+ * Default in-memory `VoteStateStore`. Survives only for the component's
500
+ * lifetime; hosts wanting "already voted" to persist across launches pass an
501
+ * AsyncStorage-backed implementation instead.
502
+ */
503
+ declare function createMemoryVoteStore(): VoteStateStore;
504
+
505
+ /**
506
+ * Maps a `betterreviews_reactiv.theme` JSON blob to a flat StyleSheet
507
+ * record consumed by the section renderers. All fields are optional —
508
+ * absent fields fall back to RN's platform defaults.
509
+ *
510
+ * The shape matches `PPO.Reactiv.ContentResolver.build_theme/1` —
511
+ * flat color keys + corner_style enum + font_family enum.
512
+ */
513
+
514
+ interface ResolvedTheme {
515
+ primaryColor?: string;
516
+ backgroundColor?: string;
517
+ textColor?: string;
518
+ accentColor?: string;
519
+ borderRadius?: number;
520
+ fontFamily?: string;
521
+ }
522
+ /**
523
+ * Pure function — no React, no platform calls. Maps a Theme blob to a
524
+ * ResolvedTheme suitable for spreading into style props.
525
+ *
526
+ * Unknown enum values for `corner_style` / `font_family` resolve to
527
+ * `undefined` (the schema validator at the write boundary already
528
+ * rejects unknown values, but defensive in case of a malformed
529
+ * payload landing in the read path).
530
+ */
531
+ declare function applyTheme(theme: Theme | null | undefined): ResolvedTheme;
532
+
533
+ /**
534
+ * Semver floor compare. The metafield's optional `config.min_sdk_version`
535
+ * declares the minimum SDK version the merchant expects. The RN
536
+ * package compares against its own version on mount; if below the
537
+ * floor, ProductContentBlock renders nothing (degrade-gracefully).
538
+ *
539
+ * v1 of the resolver does NOT emit `min_sdk_version` — this is
540
+ * forward-compat plumbing.
541
+ */
542
+ declare const SDK_VERSION: string;
543
+ /**
544
+ * Returns true if the SDK's current version meets the configured
545
+ * floor, false otherwise. A null/undefined floor always passes (no
546
+ * floor declared). Malformed floor strings are treated as "no floor"
547
+ * — the package errs on the side of rendering rather than refusing.
548
+ */
549
+ declare function meetsFloor(floor: string | undefined): boolean;
550
+
551
+ /**
552
+ * Bridge config resolution (Card C.14).
553
+ *
554
+ * `config.bridge` declares the merchant's preference for native bridge
555
+ * surfaces (sort drawer, photo viewer):
556
+ * - `"off"` (default): everything renders in-WebView.
557
+ * - `"auto"`: use native if available, fall back to in-WebView.
558
+ * - `"required"`: use native; if not available, render nothing.
559
+ *
560
+ * Schema spec § 11.5 — v1 of this RN package ships in-WebView fallback
561
+ * only. The native bridge is post-soft-launch. So all three values
562
+ * resolve to `"in_webview"` behaviour today; `"auto"` and `"required"`
563
+ * additionally emit a `bridge_not_implemented` telemetry hint so the
564
+ * future merchant who configures non-default can be notified that
565
+ * their intent is not yet honoured.
566
+ */
567
+
568
+ type ResolvedBridge = 'in_webview' | 'native';
569
+ /**
570
+ * Returns the bridge mode this package will actually use. v1 always
571
+ * returns `"in_webview"` regardless of the config preference, since the
572
+ * native bridge isn't implemented yet.
573
+ *
574
+ * Side-effect: if the config requested `"auto"` or `"required"`, emits
575
+ * a `betterreviews.fetch.failure` telemetry event with
576
+ * `error_code: "bridge_not_implemented"` so the partner observability
577
+ * stack can surface that the merchant's intent is not yet honoured.
578
+ */
579
+ declare function resolveBridge(config: Config | null | undefined, emit: TelemetryHandler): ResolvedBridge;
580
+
581
+ export { type BetterReviewsClient, type BetterReviewsClientConfig, type BetterReviewsContextValue, BetterReviewsProvider, type BetterReviewsProviderProps, type Config, FeaturesSection, type FeaturesSectionProps, type Fetcher, type FetcherRequest, type ListParams, ProductContentBlock, type ProductContentBlockProps, type ProductContentBlock$1 as ProductContentBlockSchema, type ResolvedBridge, type ResolvedTheme, type ReviewListResult, ReviewWidget, type ReviewWidgetProps, ReviewsSummarySection, SDK_VERSION, type SchemaViolationInfo, StarRating, type StarRatingProps, type TelemetryEvent, type TelemetryHandler, type Theme, type VoteStateStore, type VoteType, WebViewHost, type WebViewHostProps, type WidgetMediaItem, type WidgetMerchantReply, type WidgetPagination, type WidgetReview, type WidgetSortValue, type WidgetSummary, type WidgetVoteCounts, applyTheme, createBetterReviewsClient, createMemoryVoteStore, meetsFloor, noopTelemetry, resolveBridge, safeTelemetry, useBetterReviews };