@betterreviews/react-native
v1.1.0
Published
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.
Readme
@betterreviews/react-native
Native React Native renderer for BetterReviews mobile PDP content. Consumes the betterreviews_reactiv.* Shopify metafield namespace and renders product content blocks themed to the merchant.
Authorized Partner
This package is licensed for use by Reactiv under a written integration agreement with BetterReviews. See LICENSE.
Installation
yarn add @betterreviews/react-native \
react-native-webview react-native-svg react-native-gesture-handlerPeer dependencies your host app must provide: react ≥18, react-native ≥0.74, react-native-webview ≥13, react-native-svg ≥15, and react-native-gesture-handler ≥2.16. valibot is bundled as a direct dependency — you don't install it.
react-native-gesture-handler setup (required by ReviewWidget's media viewer): import it as the very first line of your app entry (index.js/index.ts) and wrap your app root in GestureHandlerRootView:
import 'react-native-gesture-handler'; // must be first
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function Root() {
return <GestureHandlerRootView style={{ flex: 1 }}>{/* app */}</GestureHandlerRootView>;
}npm users: React Native 0.74 ships a mismatched
@types/reactpeer range, so a plainnpm installmay fail withERESOLVE. This is an upstream React Native quirk, not specific to this package — install withnpm install --legacy-peer-deps, or use Yarn (which is more lenient). Yarn is recommended for React Native projects.
Quick start
import {
BetterReviewsProvider,
ProductContentBlock,
type Theme,
type Config,
type ProductContentBlockSchema,
} from '@betterreviews/react-native';
function App() {
// Partner host app fetches the three metafield bodies for the active
// product from Shopify's Storefront API (all are PUBLIC_READ — no Admin
// scope), directly or through a partner-backend proxy.
const theme: Theme | null = useFetchedTheme(productId);
const config: Config | null = useFetchedConfig(productId);
const block: ProductContentBlockSchema | null = useFetchedBlock(productId);
return (
<BetterReviewsProvider
theme={theme}
config={config}
onTelemetryEvent={(event) => {
// Forward to partner's own observability stack.
partnerAnalytics.log(event);
}}
>
<ProductContentBlock block={block} />
</BetterReviewsProvider>
);
}Render gates
<ProductContentBlock> renders nothing (returns null) when any of these are true:
config.product_content_block_enabled === false— merchant explicitly disabled this productconfig.min_sdk_versiondeclared and current SDK below floor — emitsbetterreviews.fetch.failuretelemetryblockisnull/undefined- The block envelope fails top-level schema validation — emits
betterreviews.schema.violationtelemetry
Per-section validation uses tolerant-reader semantics: an individual malformed section is dropped (with telemetry) while the rest render.
The Features and ReviewsSummary section text follows the resolved light/dark scheme (see Theming); a merchant text_color still wins.
Telemetry events
| Event | When |
|---|---|
| betterreviews.fetch.failure | error_code: "sdk_below_floor" (mount blocked by min_sdk_version floor), "bridge_not_implemented" (config.bridge is auto/required — see Bridge config), "load_more_failed" / "detail_failed" / "vote_failed" (a ReviewWidget load-more, read-more, or vote request failed) |
| betterreviews.schema.violation | Envelope or per-section validation failed |
| betterreviews.webview.error | WebViewHost refused a URL: error_code: "origin_not_allowed" (the url prop is not an allowed origin — nothing renders) or "cross_origin_blocked" (an in-page navigation to a non-allowed origin was stopped). Carries url_origin only, never the path/query |
Future versions will emit betterreviews.fetch.success and betterreviews.signature.invalid.
Theming
The widgets are neutral by default — zinc grayscale on light, the storefront's slate palette on dark, with a scheme-dependent CTA: black on light, #fafafa on dark. No brand color appears unless the host opts in by passing a theme to <BetterReviewsProvider> (background_color, text_color, accent_color, corner_style, font_family). The only non-grayscale defaults are the gold stars/bars and the green "Verified Buyer" badge (universal review conventions, fixed in v1).
Light / dark (≥ 1.1.0). The widgets ship a light and a dark neutral set. Pass your app's resolved scheme to the provider — colorScheme?: 'light' | 'dark' — whether the app forces a theme or follows the user's pick. The scheme is resolved in this order:
theme.background_colorluminance, when the theme carries a parseable hex background- the provider's
colorSchemeprop 'light'
Omit colorScheme and the widget stays light; pass your app's resolved scheme (forced or user-picked, or useColorScheme() yourself if you follow the OS). The widget does not read the OS setting itself. The prop's type is exported as ColorScheme; resolveScheme(theme, colorScheme) is exported too if you need the resolved value in your own UI.
The widget container stays transparent — your screen's background shows through, so render it on a surface that matches the scheme you pass. Merchant background_color / text_color / accent_color still override the neutral set. Card, muted text, borders, search highlight, overlay scrim and the Verified-badge tint follow the scheme; the CTA label is black or white, whichever contrasts with the resolved accent (including a merchant accent_color). None are merchant-settable.
Known limitation: a merchant accent_color is also used for link-style text (Read more, Try again, active Helpful); on a dark scheme a dark brand accent can be hard to read — a contrast guard is a follow-up.
StarRating (aggregate badge)
<StarRating> is the compact rating badge (stars + score + review count) for near the product title — the RN equivalent of the storefront br-star-rating block. The host supplies the aggregate (average + total, typically from the betterreviews.summary metafield it already fetches); the badge does not call the API.
import { StarRating } from '@betterreviews/react-native';
<StarRating average={4.5} total={128} onPress={scrollToReviews} />Star color comes from <BetterReviewsProvider> theme (gold fallback outside a provider); stars stay gold in both schemes, while the score/count text follows the resolved light/dark scheme. onPress makes it a button (e.g. scroll to the ReviewWidget); it renders nothing when total is 0 (hideWhenEmpty, default on).
ReviewWidget (review browsing + voting)
<ReviewWidget> renders the full review-browsing surface — paginated list, rating/photo/search filters, sort, read-more, a full-screen media viewer, and helpful/unhelpful voting.
The package owns the UI + pagination/sort/filter state. Your host app owns transport and auth via an injected Fetcher: it prepends the API base URL, injects the widget token, and returns parsed JSON (throwing on a non-2xx status). No token ever lives inside this package or your app binary's SDK code — keep it in your host's secure config / backend proxy.
import {
ReviewWidget,
createBetterReviewsClient,
type Fetcher,
} from '@betterreviews/react-native';
const fetcher: Fetcher = async ({ path, query, method = 'GET', body, signal }) => {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) params.set(k, String(v));
params.set('token', getWidgetReadTokenFromBackend()); // host-injected; never hardcode
// ^ your backend fetches read_token from
// GET /api/v1/partners/reactiv/widget-tokens?product_id=
// (Authorization: Bearer ppo_… — the merchant's BetterReviews API key,
// header only). See SECURITY.md.
const res = await fetch(`${API_BASE}${path}?${params}`, {
method, signal,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`widget request failed: HTTP ${res.status}`); // never log the URL — the token is in it
return res.json();
};
const client = createBetterReviewsClient({ fetcher, storeId, productId });
// Theme, colour scheme + telemetry come from <BetterReviewsProvider> context (NOT props).
// `scheme` is your app's resolved 'light' | 'dark' (omit and the widget stays light).
<BetterReviewsProvider theme={theme} colorScheme={scheme} onTelemetryEvent={partnerAnalytics.log}>
<ReviewWidget client={client} onWriteReview={openReviewChat} />
</BetterReviewsProvider>Notes:
ReviewWidgetrenders inline — it owns no scroll container, so drop it into your product-pageScrollViewas one section (e.g. below the product info andProductContentBlock) and it scrolls with the page. Paging is a "Load more" button (no virtualization). The sort drawer and full-screen media viewer areModaloverlays, so they work regardless.onWriteReviewis host-owned (open your chat WebView / nav). The CTA hides if omitted. Never pass server-controlled strings toLinking.openURL.- Voting persists in-memory by default; pass
voteStateStore(e.g. AsyncStorage-backed) to persist "already voted" across launches. Store only review id → direction; never review content or the token. - The widget requires the
GestureHandlerRootViewroot wrap above.
Security obligations on the host
See SECURITY.md § "What you must do (the host)" for the contract every embedding host app must honor — credential storage, GDPR cascade, Shopify Level 2 data scope, cache TTL ceiling, Info.plist permissions, logging discipline.
Versioning
This package follows semver. The betterreviews_reactiv.* metafield schema (generated JSON Schemas at elixir/priv/reactiv_schemas/v1/) follows additive-only compatibility with a 90-day deprecation window — see schemas/betterreviews-reactiv/COMPATIBILITY.md at the BetterReviews repo root.
WebView surface
For the customer-facing chat flow (/review/chat), use WebViewHost. Do not hand-assemble the URL — your backend fetches a ready-made, short-lived chat_url from the partner endpoint and passes it through:
import { WebViewHost } from '@betterreviews/react-native';
// chatUrl came from your backend's call to
// GET /api/v1/partners/reactiv/widget-tokens?product_id=
// (Authorization: Bearer ppo_… — the merchant's BetterReviews API key)
// → { read_token, chat_url } (chat_url's token is a 15-min Phoenix.Token).
// Fetch it right before opening the WebView — it expires in 15 minutes.
<WebViewHost
url={chatUrl}
onMessage={(event) => console.log('message from chat', event.nativeEvent.data)}
onError={(event) => console.warn('webview error', event.nativeEvent)}
onClose={() => dismissChat()} // the shopper tapped "Back to store" on the thank-you screen
onReviewSubmitted={({ product_id }) => markReviewed(product_id)} // ≥ 1.1.0; UX hint only
/>Page → host messages
The page posts two JSON messages through window.ReactNativeWebView.postMessage. It never navigates away to signal the host — routing the shopper afterwards is your app's decision.
| Message | When | Prop |
|---|---|---|
| {"type":"close"} | The shopper taps "Back to store" on the thank-you screen | onClose |
| {"type":"review_submitted","product_id":"<id>"} | After a review is successfully submitted | onReviewSubmitted({ product_id }) |
- Only for partner sessions. The page posts only for sessions opened from a widget-tokens
chat_url. That native flag carries across the page's own hops (chat → form when the store's default experience is the form or AI chat is unavailable, e.g. out of AI credits; and form → chat via "Write with AI assistant"). A plain storefront review link posts nothing. review_submittedcarriesproduct_idonly — no review id, no customer data.review_submittedis a UX hint, not proof of a review. Use it to update your UI (e.g. close the sheet, show a toast). Do not award rewards, points, or discounts on it — it is a client-side message from a device the shopper controls, not a server-side confirmation.- Origin-checked (≥ 1.1.0). A signal is accepted only when the URL the WebView reports for the message has the same origin as the
urlprop; otherwise (e.g. a marketing page linked from the thank-you screen) it is passed toonMessageas an ordinary message. The reported URL is the posting frame's on iOS and on current Android System WebView (WebMessageListener), but the top-level page's on older Android System WebView. BetterReviews pages embed no third-party frames. onMessageis untrusted.onMessagereceives arbitrary strings from any page the WebView reaches (including third-party scripts on allowlisted pages); treat it as untrusted and don't act on it.- Versions.
onClosealready works on1.0.0once the server change ships — but only if you passonClose: on1.0.0,window.ReactNativeWebViewis injected only whenonMessageoronCloseis given.onReviewSubmittedneeds≥ 1.1.0; from1.1.0the bridge is always injected. Every other message flows toonMessage— including aclose/review_submittedsignal when its handler isn't passed.
The component intentionally exposes only { url, onMessage, onError, onClose, onReviewSubmitted }. All underlying WebView props (cookie scope, content-inset behavior, keyboard handling, media playback) are locked to the baseline validated by the Tier 2 WebView test (docs/proposals/reactiv-webview-tier2-test-2026-05-18.md). Future recovery patches stay surgical.
react-native-webview ≥13.0.0 is a peer dep. Add it to your host app:
yarn add react-native-webviewBridge config
config.bridge declares the merchant's preference for native bridge surfaces:
"off"(default): everything renders in-WebView."auto": native if available, fall back to in-WebView."required": native; if not available, render nothing.
v1 of this package supports "off" only. "auto" and "required" resolve to "off" behavior and emit a betterreviews.fetch.failure telemetry event with error_code: "bridge_not_implemented" so partner observability can surface the unhonored intent. Native bridge implementations land post-soft-launch (Card C.14).
Not yet shipped
- Native (non-WebView) bridge surfaces for the chat flow (Card C.14 — post-soft-launch)
- Merchant control of the widget's fixed neutrals (star/verified/muted/border/scrim) via the
themeschema — a later additive schema bump. (Light/dark neutral sets already ship; see Theming.) - HMAC signature verification (forward-compat — added when a bidirectional channel emerges)
License
Proprietary. See LICENSE. For licensing inquiries: [email protected].
