@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/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@betterreviews/react-native",
3
+ "version": "1.0.0",
4
+ "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.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md",
20
+ "LICENSE",
21
+ "SECURITY.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org/"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/Product-Page-Optimizer/ppo.git",
30
+ "directory": "react-native/packages/react-native"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/Product-Page-Optimizer/ppo/issues",
34
+ "email": "security@betterreviews.app"
35
+ },
36
+ "homepage": "https://betterreviews.app",
37
+ "engines": {
38
+ "node": ">=20.0.0"
39
+ },
40
+ "scripts": {
41
+ "prepublishOnly": "yarn typecheck && yarn test && yarn build",
42
+ "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",
43
+ "test": "vitest run",
44
+ "test:rn": "jest",
45
+ "typecheck": "tsc --noEmit",
46
+ "smoke": "bash scripts/smoke-from-pack.sh"
47
+ },
48
+ "peerDependencies": {
49
+ "react": ">=18.0.0",
50
+ "react-native": ">=0.74.0",
51
+ "react-native-gesture-handler": ">=2.16.0",
52
+ "react-native-svg": ">=15.0.0",
53
+ "react-native-webview": ">=13.0.0"
54
+ },
55
+ "dependencies": {
56
+ "valibot": "^1.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "@babel/core": "^7.25.0",
60
+ "@babel/plugin-syntax-import-attributes": "^7.29.7",
61
+ "@react-native/babel-preset": "0.74.89",
62
+ "@testing-library/react-native": "^12.5.0",
63
+ "@types/jest": "^29.5.0",
64
+ "@types/node": "^22.0.0",
65
+ "@types/react": "^18.0.0",
66
+ "babel-jest": "^29.7.0",
67
+ "jest": "^29.7.0",
68
+ "react": "^18.0.0",
69
+ "react-native": "^0.74.0",
70
+ "react-native-gesture-handler": "^2.28.0",
71
+ "react-native-svg": "^15.12.0",
72
+ "react-native-webview": "^13.0.0",
73
+ "react-test-renderer": "18.2.0",
74
+ "tsup": "^8.3.0",
75
+ "typescript": "^5.6.0",
76
+ "vitest": "^2.1.0"
77
+ }
78
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Context provider for the BetterReviews RN package. Partner host
3
+ * apps wrap their navigation tree with `<BetterReviewsProvider>` and
4
+ * supply the merchant-resolved `theme` + `config` + an optional
5
+ * `onTelemetryEvent` callback.
6
+ *
7
+ * The provider is intentionally thin — no fetching, no state. The
8
+ * partner is responsible for fetching the metafield bodies from
9
+ * Shopify and passing them in. Decoupling fetch from render lets the
10
+ * partner cache however they want (subject to the 1h TTL ceiling per
11
+ * SECURITY.md § "Cache TTL contract").
12
+ */
13
+
14
+ import React, { createContext, useContext, useMemo } from 'react';
15
+ import type { Config } from '../../schemas/src/config.js';
16
+ import type { Theme } from '../../schemas/src/theme.js';
17
+ import { safeTelemetry, type TelemetryHandler } from './telemetry.js';
18
+
19
+ export interface BetterReviewsContextValue {
20
+ theme: Theme | null;
21
+ config: Config | null;
22
+ emit: TelemetryHandler;
23
+ }
24
+
25
+ const defaultValue: BetterReviewsContextValue = {
26
+ theme: null,
27
+ config: null,
28
+ emit: () => {},
29
+ };
30
+
31
+ const BetterReviewsContext = createContext<BetterReviewsContextValue>(defaultValue);
32
+
33
+ export interface BetterReviewsProviderProps {
34
+ theme?: Theme | null;
35
+ config?: Config | null;
36
+ onTelemetryEvent?: TelemetryHandler;
37
+ children: React.ReactNode;
38
+ }
39
+
40
+ export function BetterReviewsProvider({
41
+ theme = null,
42
+ config = null,
43
+ onTelemetryEvent,
44
+ children,
45
+ }: BetterReviewsProviderProps): React.ReactElement {
46
+ const emit = useMemo(() => safeTelemetry(onTelemetryEvent), [onTelemetryEvent]);
47
+
48
+ const value = useMemo<BetterReviewsContextValue>(
49
+ () => ({ theme, config, emit }),
50
+ [theme, config, emit],
51
+ );
52
+
53
+ return (
54
+ <BetterReviewsContext.Provider value={value}>
55
+ {children}
56
+ </BetterReviewsContext.Provider>
57
+ );
58
+ }
59
+
60
+ export function useBetterReviews(): BetterReviewsContextValue {
61
+ return useContext(BetterReviewsContext);
62
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Top-level renderer for `betterreviews_reactiv.product_content_block`.
3
+ * Iterates the merchant's section list and dispatches each to its
4
+ * type-specific renderer. Unknown section types are silently dropped
5
+ * (tolerant-reader contract) with a `schema.violation` telemetry
6
+ * event.
7
+ *
8
+ * Mount-time gates:
9
+ * 1. `config.product_content_block_enabled === false` — render nothing.
10
+ * 2. `config.min_sdk_version` declared and current SDK is below
11
+ * floor — render nothing + emit `fetch.failure` with
12
+ * `error_code: "sdk_below_floor"`.
13
+ *
14
+ * Per-section validation uses valibot's `safeParse` so a single
15
+ * malformed section doesn't crash the whole block — bad sections
16
+ * are dropped, known-good ones render.
17
+ *
18
+ * Telemetry discipline: parsing + section building happen purely during
19
+ * render (no side effects); every telemetry event is emitted from a
20
+ * `useEffect`. Emitting during render would invoke the host app's
21
+ * `onTelemetryEvent` (commonly a `setState`) mid-render and crash React
22
+ * with "Cannot update a component while rendering a different component."
23
+ */
24
+
25
+ import React, { useEffect, useMemo } from 'react';
26
+ import { View } from 'react-native';
27
+ import * as v from 'valibot';
28
+ import { sectionSchema } from '../../schemas/src/product_content_block.js';
29
+ import type { ProductContentBlock as ProductContentBlockData } from '../../schemas/src/product_content_block.js';
30
+ import { useBetterReviews } from './BetterReviewsProvider.js';
31
+
32
+ // Tolerant READ envelope: validate the wrapper shape but treat `sections` as
33
+ // `unknown[]` so a single unknown/bad section drops ITSELF (in renderSection),
34
+ // not the whole block. The strict `productContentBlockSchema` remains the
35
+ // WRITE-side contract (server pre-write validation); the reader must be lenient
36
+ // — that is the whole point of the tolerant-reader pattern.
37
+ const readEnvelopeSchema = v.object({
38
+ v: v.literal(1),
39
+ sections: v.array(v.unknown()),
40
+ });
41
+ import { meetsFloor } from './minSdkVersion.js';
42
+ import { FeaturesSection } from './sections/FeaturesSection.js';
43
+ import { ReviewsSummarySection } from './sections/ReviewsSummarySection.js';
44
+
45
+ export interface ProductContentBlockProps {
46
+ /** Parsed metafield body from `betterreviews_reactiv.product_content_block`. */
47
+ block: ProductContentBlockData | null | undefined;
48
+ }
49
+
50
+ export function ProductContentBlock({ block }: ProductContentBlockProps): React.ReactElement | null {
51
+ const { config, emit } = useBetterReviews();
52
+
53
+ // Mount-time gate 1: merchant explicitly disabled this product.
54
+ const disabled = config?.product_content_block_enabled === false;
55
+
56
+ // Mount-time gate 2: SDK version floor.
57
+ const sdkOk = meetsFloor(config?.min_sdk_version);
58
+
59
+ // Pure render-time computation: parse the envelope, build renderable
60
+ // section nodes, and COLLECT schema-violation paths. No telemetry is
61
+ // emitted here — see "Telemetry discipline" above.
62
+ const { nodes, violations } = useMemo<{
63
+ nodes: React.ReactElement[] | null;
64
+ violations: (string | undefined)[];
65
+ }>(() => {
66
+ if (disabled || !sdkOk || !block) {
67
+ return { nodes: null, violations: [] };
68
+ }
69
+
70
+ // Validate only the ENVELOPE shape (wrong `v`, missing/!array `sections`).
71
+ // Sections are parsed individually below so one bad section drops itself.
72
+ const envelope = v.safeParse(readEnvelopeSchema, block);
73
+ if (!envelope.success) {
74
+ return {
75
+ nodes: null,
76
+ violations: [envelope.issues?.[0]?.path?.[0]?.key as string | undefined],
77
+ };
78
+ }
79
+
80
+ const violations: (string | undefined)[] = [];
81
+ const nodes = envelope.output.sections
82
+ .map((section, index) => renderSection(section, index, violations))
83
+ .filter((node): node is React.ReactElement => node !== null);
84
+
85
+ return { nodes, violations };
86
+ }, [disabled, sdkOk, block]);
87
+
88
+ // Gate 2 telemetry: SDK below floor.
89
+ useEffect(() => {
90
+ if (!sdkOk) {
91
+ emit({
92
+ type: 'betterreviews.fetch.failure',
93
+ error_code: 'sdk_below_floor',
94
+ });
95
+ }
96
+ }, [sdkOk, emit]);
97
+
98
+ // Schema-violation telemetry — emitted from an effect, never during render.
99
+ useEffect(() => {
100
+ for (const violation_path of violations) {
101
+ emit({
102
+ type: 'betterreviews.schema.violation',
103
+ schema_version: 1,
104
+ violation_path,
105
+ });
106
+ }
107
+ }, [violations, emit]);
108
+
109
+ if (disabled || !sdkOk || !block) return null;
110
+ if (nodes === null) return null;
111
+
112
+ return <View>{nodes}</View>;
113
+ }
114
+
115
+ // Pure: returns the section's rendered node, or null while pushing the
116
+ // offending path onto `violations` for the caller to emit from an effect.
117
+ function renderSection(
118
+ section: unknown,
119
+ index: number,
120
+ violations: (string | undefined)[],
121
+ ): React.ReactElement | null {
122
+ // Per-section validation — a single bad section drops itself, not
123
+ // the rest. valibot's variant is strict; unknown types fail here.
124
+ const result = v.safeParse(sectionSchema, section);
125
+ if (!result.success) {
126
+ violations.push(`sections[${index}]`);
127
+ return null;
128
+ }
129
+
130
+ switch (result.output.type) {
131
+ case 'features':
132
+ return <FeaturesSection key={index} bullets={result.output.bullets} />;
133
+ case 'reviews_summary':
134
+ return <ReviewsSummarySection key={index} />;
135
+ default:
136
+ // Exhaustiveness: TypeScript narrows `result.output.type` to `never`
137
+ // here. If a new section type is added to the schema without a
138
+ // renderer case, this branch + telemetry surfaces it at runtime
139
+ // (defense-in-depth on top of the compile-time narrowing).
140
+ violations.push(`sections[${index}].type`);
141
+ return null;
142
+ }
143
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `StarRating` — compact aggregate rating badge (stars + score + review
3
+ * count), the RN equivalent of the storefront `br-star-rating` block. It sits
4
+ * near the product title; tap it (via `onPress`) to jump to the reviews.
5
+ *
6
+ * The host supplies the aggregate (`average` + `total`) — typically from the
7
+ * `betterreviews.summary` metafield it already fetches. A badge must NOT
8
+ * trigger `ReviewWidget`'s multi-call summary synthesis.
9
+ *
10
+ * Theme (star color) comes from `<BetterReviewsProvider>` context, with a fixed
11
+ * gold fallback when used outside a provider.
12
+ */
13
+ import React, { useId } from 'react';
14
+ import { Pressable, Text, View } from 'react-native';
15
+ import { useBetterReviews } from './BetterReviewsProvider.js';
16
+ import { IconStar } from './icons/BRIcons.js';
17
+ import { resolveWidgetTheme } from './theme/widgetTheme.js';
18
+
19
+ export interface StarRatingProps {
20
+ /** Aggregate average rating, e.g. 4.5. */
21
+ average: number;
22
+ /** Total review count. */
23
+ total: number;
24
+ /** Star pixel size (default 16). */
25
+ size?: number;
26
+ /** Override the star fill color (defaults to the theme's star color). */
27
+ starColor?: string;
28
+ /** Tap handler — e.g. scroll to the ReviewWidget. Renders as a button when set. */
29
+ onPress?: () => void;
30
+ /** Render nothing when there are no reviews (default true). */
31
+ hideWhenEmpty?: boolean;
32
+ }
33
+
34
+ export function StarRating({
35
+ average,
36
+ total,
37
+ size = 16,
38
+ starColor,
39
+ onPress,
40
+ hideWhenEmpty = true,
41
+ }: StarRatingProps): React.ReactElement | null {
42
+ const { theme } = useBetterReviews();
43
+ const t = resolveWidgetTheme(theme);
44
+ // Unique per-instance gradient seed — two badges with half-stars on screen
45
+ // must not share a react-native-svg gradient id.
46
+ const uid = useId();
47
+
48
+ if (hideWhenEmpty && total <= 0) return null;
49
+ const fill = starColor ?? t.star;
50
+
51
+ const content = (
52
+ <View
53
+ style={{ flexDirection: 'row', alignItems: 'center' }}
54
+ accessibilityRole="image"
55
+ accessibilityLabel={`${average.toFixed(1)} out of 5 stars, ${total} ${total === 1 ? 'review' : 'reviews'}`}
56
+ >
57
+ {[1, 2, 3, 4, 5].map((i) => {
58
+ const diff = average - (i - 1);
59
+ const p = diff >= 0.75 ? 100 : diff >= 0.25 ? 50 : 0;
60
+ return (
61
+ <View key={i} style={{ marginRight: 1 }}>
62
+ <IconStar size={size} fillPercentage={p} fillColor={fill} emptyColor={t.muted} uid={`${uid}-${i}`} />
63
+ </View>
64
+ );
65
+ })}
66
+ <Text style={{ marginLeft: 6, fontSize: size * 0.85, fontWeight: '600', color: t.text }}>
67
+ {average.toFixed(1)}
68
+ </Text>
69
+ <Text style={{ marginLeft: 5, fontSize: size * 0.8, color: t.mutedFg }}>
70
+ · {total} {total === 1 ? 'review' : 'reviews'}
71
+ </Text>
72
+ </View>
73
+ );
74
+
75
+ if (!onPress) return content;
76
+ return (
77
+ <Pressable
78
+ onPress={onPress}
79
+ accessibilityRole="button"
80
+ style={({ pressed }: { pressed: boolean }) => (pressed ? { opacity: 0.6 } : null)}
81
+ >
82
+ {content}
83
+ </Pressable>
84
+ );
85
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `WebViewHost` — the ONLY place `react-native-webview` is consumed.
3
+ *
4
+ * Critical seam discipline: this wrapper hides all underlying
5
+ * `WebView` props from callers. Public API is `{ url, onMessage,
6
+ * onError }` only. Recovery patches (e.g. adding
7
+ * `onContentProcessDidTerminate` if the Tier 2 WebView test surfaces
8
+ * a WebSocket-survival failure) are single-file, single-line changes
9
+ * INSIDE this file — zero changes at any caller. See
10
+ * `docs/proposals/reactiv-webview-tier2-test-2026-05-18.md` § "Known
11
+ * traps" for the documented recovery paths.
12
+ *
13
+ * Default `WebView` props match the Tier 2 test's chosen baseline:
14
+ * - sharedCookiesEnabled=false (worst-case isolation; Reactiv may
15
+ * flip later)
16
+ * - automaticallyAdjustContentInsets=false
17
+ * - contentInsetAdjustmentBehavior="never"
18
+ * - keyboardDisplayRequiresUserAction=false
19
+ * - allowsInlineMediaPlayback
20
+ * - mediaPlaybackRequiresUserAction=false
21
+ *
22
+ * The baseline matches what the Tier 2 test validates — keeps any
23
+ * future test pass/fail directly applicable.
24
+ */
25
+
26
+ import React, { useEffect } from 'react';
27
+ import { WebView } from 'react-native-webview';
28
+ import { useBetterReviews } from './BetterReviewsProvider.js';
29
+ import { isCloseSignal } from './webviewMessage.js';
30
+
31
+ // Derive the event types from the WebView component's own props. Avoids
32
+ // importing from a deep path (`react-native-webview/lib/WebViewTypes`)
33
+ // + insulates us if the library's type-export surface shifts between
34
+ // versions.
35
+ type WebViewProps = React.ComponentProps<typeof WebView>;
36
+ type OnMessageHandler = NonNullable<WebViewProps['onMessage']>;
37
+ type OnErrorHandler = NonNullable<WebViewProps['onError']>;
38
+ type ShouldStartLoadHandler = NonNullable<
39
+ WebViewProps['onShouldStartLoadWithRequest']
40
+ >;
41
+
42
+ // Allowed origin suffixes for navigations inside the WebView. SECURITY.md
43
+ // commits to pinning to BetterReviews-controlled hosts; this list is the
44
+ // implementation of that commitment.
45
+ //
46
+ // Includes the dev-only `*.betterreviews.ngrok-free.dev` reserved domain
47
+ // so the Tier 2 WebView test + local-dev integration with Reactiv can run
48
+ // without an SDK build tweak. Production builds reach `api.betterreviews.app`
49
+ // — the ngrok entry is a no-op for partner apps in stores.
50
+ const ALLOWED_ORIGIN_SUFFIXES = [
51
+ '.betterreviews.app',
52
+ '.betterreviews.ngrok-free.dev',
53
+ ];
54
+
55
+ function originAllowed(rawUrl: string): boolean {
56
+ // `javascript:` / `data:` / `file:` / opaque URLs lack a parseable
57
+ // host and must be rejected.
58
+ let parsed: URL;
59
+ try {
60
+ parsed = new URL(rawUrl);
61
+ } catch {
62
+ return false;
63
+ }
64
+
65
+ if (parsed.protocol !== 'https:') return false;
66
+
67
+ const host = parsed.hostname.toLowerCase();
68
+ return ALLOWED_ORIGIN_SUFFIXES.some(
69
+ (suffix) => host === suffix.slice(1) || host.endsWith(suffix),
70
+ );
71
+ }
72
+
73
+ export interface WebViewHostProps {
74
+ /** Chat surface URL (e.g. `https://api.betterreviews.app/review/chat?...`). */
75
+ url: string;
76
+ /** Fires on every `postMessage` from inside the WebView (except the close signal — see `onClose`). */
77
+ onMessage?: OnMessageHandler;
78
+ /** Fires on navigation / load error. */
79
+ onError?: OnErrorHandler;
80
+ /**
81
+ * Fires when the embedded page asks the host to dismiss it by posting
82
+ * `{ "type": "close" }` (e.g. the chat's "Back to store" / done button).
83
+ * Wire this to close the modal/screen the WebView is in. Other messages
84
+ * still flow to `onMessage`.
85
+ */
86
+ onClose?: () => void;
87
+ }
88
+
89
+ export function WebViewHost({ url, onMessage, onError, onClose }: WebViewHostProps): React.ReactElement | null {
90
+ const { emit } = useBetterReviews();
91
+
92
+ // Refuse to mount with a non-https / non-betterreviews URL. Mirrors
93
+ // SECURITY.md § "What BetterReviews commits to (the wire)" —
94
+ // "WKWebView origin pinning. The package's WebView host for
95
+ // `/review/chat` pins the origin to `*.betterreviews.app` and
96
+ // `*.betterreviews.ngrok-free.dev` (development only). Cross-origin
97
+ // navigations are blocked."
98
+ const allowed = originAllowed(url);
99
+
100
+ // Emit the origin-rejection from an effect, NEVER during render — a host
101
+ // telemetry handler that calls setState would otherwise crash render
102
+ // ("Cannot update a component while rendering a different component").
103
+ useEffect(() => {
104
+ if (!allowed) {
105
+ emit({
106
+ type: 'betterreviews.webview.error',
107
+ error_code: 'origin_not_allowed',
108
+ url_origin: safeOrigin(url),
109
+ });
110
+ }
111
+ }, [allowed, url, emit]);
112
+
113
+ if (!allowed) return null;
114
+
115
+ // Intercept the `{type:'close'}` signal for `onClose`; forward everything
116
+ // else to `onMessage`. The page sends close instead of navigating away when
117
+ // it detects a native host (so "Back to store" dismisses the WebView rather
118
+ // than loading the storefront inside it).
119
+ const handleMessage: OnMessageHandler = (event) => {
120
+ if (onClose && isCloseSignal(event.nativeEvent.data)) {
121
+ onClose();
122
+ return;
123
+ }
124
+ onMessage?.(event);
125
+ };
126
+
127
+ const handleShouldStartLoad: ShouldStartLoadHandler = (request) => {
128
+ if (originAllowed(request.url)) return true;
129
+
130
+ emit({
131
+ type: 'betterreviews.webview.error',
132
+ error_code: 'cross_origin_blocked',
133
+ url_origin: safeOrigin(request.url),
134
+ });
135
+
136
+ return false;
137
+ };
138
+
139
+ return (
140
+ <WebView
141
+ source={{ uri: url }}
142
+ sharedCookiesEnabled={false}
143
+ automaticallyAdjustContentInsets={false}
144
+ contentInsetAdjustmentBehavior="never"
145
+ keyboardDisplayRequiresUserAction={false}
146
+ allowsInlineMediaPlayback
147
+ mediaPlaybackRequiresUserAction={false}
148
+ onShouldStartLoadWithRequest={handleShouldStartLoad}
149
+ onMessage={onMessage || onClose ? handleMessage : undefined /* either prop → wire handler */}
150
+ onError={onError}
151
+ />
152
+ );
153
+ }
154
+
155
+ // Returns just the origin (scheme + host) for telemetry — never the
156
+ // path/query, which could include tokens.
157
+ function safeOrigin(rawUrl: string): string {
158
+ try {
159
+ const parsed = new URL(rawUrl);
160
+ return `${parsed.protocol}//${parsed.hostname}`;
161
+ } catch {
162
+ return 'invalid';
163
+ }
164
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Bridge config resolution (Card C.14).
3
+ *
4
+ * `config.bridge` declares the merchant's preference for native bridge
5
+ * surfaces (sort drawer, photo viewer):
6
+ * - `"off"` (default): everything renders in-WebView.
7
+ * - `"auto"`: use native if available, fall back to in-WebView.
8
+ * - `"required"`: use native; if not available, render nothing.
9
+ *
10
+ * Schema spec § 11.5 — v1 of this RN package ships in-WebView fallback
11
+ * only. The native bridge is post-soft-launch. So all three values
12
+ * resolve to `"in_webview"` behaviour today; `"auto"` and `"required"`
13
+ * additionally emit a `bridge_not_implemented` telemetry hint so the
14
+ * future merchant who configures non-default can be notified that
15
+ * their intent is not yet honoured.
16
+ */
17
+
18
+ import type { Config } from '../../schemas/src/config.js';
19
+ import type { TelemetryHandler } from './telemetry.js';
20
+
21
+ export type ResolvedBridge = 'in_webview' | 'native';
22
+
23
+ /**
24
+ * Returns the bridge mode this package will actually use. v1 always
25
+ * returns `"in_webview"` regardless of the config preference, since the
26
+ * native bridge isn't implemented yet.
27
+ *
28
+ * Side-effect: if the config requested `"auto"` or `"required"`, emits
29
+ * a `betterreviews.fetch.failure` telemetry event with
30
+ * `error_code: "bridge_not_implemented"` so the partner observability
31
+ * stack can surface that the merchant's intent is not yet honoured.
32
+ */
33
+ export function resolveBridge(
34
+ config: Config | null | undefined,
35
+ emit: TelemetryHandler,
36
+ ): ResolvedBridge {
37
+ const requested = config?.bridge;
38
+
39
+ if (requested && requested !== 'off') {
40
+ emit({
41
+ type: 'betterreviews.fetch.failure',
42
+ error_code: 'bridge_not_implemented',
43
+ });
44
+ }
45
+
46
+ // v1: always in-WebView. Native bridge lands post-soft-launch.
47
+ return 'in_webview';
48
+ }