@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,211 @@
1
+ /**
2
+ * `createBetterReviewsClient` — the bundled reference client for the public
3
+ * widget API. Closes over `storeId`/`productId` so:
4
+ * - `product_id` is ALWAYS sent on detail + vote (the A4 IDOR fix — a
5
+ * caller cannot omit it),
6
+ * - `per_page`/`page` are clamped to the server's accepted range BEFORE
7
+ * sending and the echoed `per_page` is never trusted (load-more keys off
8
+ * `has_next`),
9
+ * - `undo` is serialized as a literal JSON boolean (a string `"true"` is
10
+ * treated by the server as a normal vote, not an undo),
11
+ * - detail is parsed FLAT (`raw` is the review object itself, not
12
+ * `{ review: ... }`),
13
+ * - list rows are parsed individually (tolerant: drop bad rows, keep the
14
+ * rest, report ONE violation per response),
15
+ * - media items with a non-https URL are dropped (defense-in-depth).
16
+ */
17
+ import * as v from 'valibot';
18
+ import {
19
+ widgetReviewSchema,
20
+ widgetReviewListSchema,
21
+ widgetVoteCountsSchema,
22
+ } from '../../../schemas/src/index.js';
23
+ import type {
24
+ BetterReviewsClient,
25
+ BetterReviewsClientConfig,
26
+ ListParams,
27
+ ReviewListResult,
28
+ VoteStateStore,
29
+ VoteType,
30
+ WidgetReview,
31
+ WidgetSummary,
32
+ WidgetVoteCounts,
33
+ } from './types.js';
34
+
35
+ const MIN_PER_PAGE = 5;
36
+ const MAX_PER_PAGE = 25;
37
+ const DEFAULT_PER_PAGE = 10;
38
+ const MAX_PAGE = 50;
39
+ const MIN_SEARCH_LENGTH = 2;
40
+
41
+ function clamp(n: number, lo: number, hi: number): number {
42
+ return Math.min(Math.max(n, lo), hi);
43
+ }
44
+
45
+ function isHttps(url: string | null): boolean {
46
+ return typeof url === 'string' && url.startsWith('https://');
47
+ }
48
+
49
+ // Drop media items carrying a non-https URL — `<Image>`/viewer must never
50
+ // receive a `javascript:`/`data:`/`file:` URL. The server's `safe_url/1` is
51
+ // https-only today, but this package is versioned independently of the
52
+ // endpoint, so it re-checks rather than trusting that invariant forever.
53
+ function sanitizeMedia(review: WidgetReview): WidgetReview {
54
+ const media = review.media.filter(
55
+ (m) => (m.thumbnail == null || isHttps(m.thumbnail)) && (m.full == null || isHttps(m.full)),
56
+ );
57
+ return media.length === review.media.length ? review : { ...review, media };
58
+ }
59
+
60
+ // Strip undefined values so the host fetcher receives only real params.
61
+ function cleanQuery(
62
+ q: Record<string, string | number | boolean | undefined>,
63
+ ): Record<string, string | number | boolean> {
64
+ const out: Record<string, string | number | boolean> = {};
65
+ for (const [k, val] of Object.entries(q)) {
66
+ if (val !== undefined) out[k] = val;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export function createBetterReviewsClient(config: BetterReviewsClientConfig): BetterReviewsClient {
72
+ const { fetcher, storeId, productId, onSchemaViolation } = config;
73
+
74
+ async function listReviews(
75
+ params: ListParams = {},
76
+ signal?: AbortSignal,
77
+ ): Promise<ReviewListResult> {
78
+ const search =
79
+ typeof params.search === 'string' && params.search.trim().length >= MIN_SEARCH_LENGTH
80
+ ? params.search.trim()
81
+ : undefined;
82
+
83
+ const query = cleanQuery({
84
+ page: clamp(params.page ?? 1, 1, MAX_PAGE),
85
+ per_page: clamp(params.perPage ?? DEFAULT_PER_PAGE, MIN_PER_PAGE, MAX_PER_PAGE),
86
+ sort: params.sort,
87
+ rating: params.rating ?? undefined,
88
+ media_only: params.mediaOnly ? 'true' : undefined,
89
+ search,
90
+ });
91
+
92
+ const raw = await fetcher({
93
+ path: `/api/widget/${storeId}/products/${productId}/reviews`,
94
+ query,
95
+ signal,
96
+ });
97
+
98
+ const envelope = v.safeParse(widgetReviewListSchema, raw);
99
+ if (!envelope.success) {
100
+ // The whole page is unusable — never render a half-list as complete.
101
+ throw new Error('betterreviews: invalid review list response');
102
+ }
103
+
104
+ const reviews: WidgetReview[] = [];
105
+ let droppedCount = 0;
106
+ let firstBadPath: string | undefined;
107
+ envelope.output.reviews.forEach((row, i) => {
108
+ const parsed = v.safeParse(widgetReviewSchema, row);
109
+ if (parsed.success) {
110
+ reviews.push(sanitizeMedia(parsed.output));
111
+ } else {
112
+ droppedCount += 1;
113
+ if (firstBadPath === undefined) firstBadPath = `reviews[${i}]`;
114
+ }
115
+ });
116
+ if (droppedCount > 0 && firstBadPath !== undefined) {
117
+ onSchemaViolation?.({ droppedCount, firstBadPath });
118
+ }
119
+
120
+ return { reviews, pagination: envelope.output.pagination };
121
+ }
122
+
123
+ async function getReviewDetail(reviewId: number, signal?: AbortSignal): Promise<WidgetReview> {
124
+ const raw = await fetcher({
125
+ path: `/api/widget/${storeId}/reviews/${reviewId}`,
126
+ query: { product_id: productId },
127
+ signal,
128
+ });
129
+
130
+ // Detail is FLAT — `raw` IS the review object (full body, body_truncated:false).
131
+ const parsed = v.safeParse(widgetReviewSchema, raw);
132
+ if (!parsed.success) {
133
+ onSchemaViolation?.({ droppedCount: 1, firstBadPath: 'detail' });
134
+ throw new Error('betterreviews: invalid review detail response');
135
+ }
136
+ return sanitizeMedia(parsed.output);
137
+ }
138
+
139
+ async function voteReview(
140
+ reviewId: number,
141
+ type: VoteType,
142
+ undo: boolean,
143
+ signal?: AbortSignal,
144
+ ): Promise<WidgetVoteCounts | null> {
145
+ const raw = await fetcher({
146
+ path: `/api/widget/${storeId}/reviews/${reviewId}/vote`,
147
+ query: { product_id: productId },
148
+ method: 'POST',
149
+ // `undo === true` forces a literal boolean — never the string "true".
150
+ body: { type, undo: undo === true },
151
+ signal,
152
+ });
153
+
154
+ const parsed = v.safeParse(widgetVoteCountsSchema, raw);
155
+ if (!parsed.success) {
156
+ // The POST itself succeeded (fetcher throws on non-2xx). The body is
157
+ // unreadable, so keep the optimistic UI — report, return null.
158
+ onSchemaViolation?.({ droppedCount: 1, firstBadPath: 'vote' });
159
+ return null;
160
+ }
161
+ return parsed.output;
162
+ }
163
+
164
+ // No summary endpoint exists — synthesize from 7 list calls (5 single-rating
165
+ // totals + 1 media-only total + 1 verified-count sample), mirroring the
166
+ // prototype. All share one signal; the two "extra" calls degrade to 0.
167
+ async function fetchSummary(signal?: AbortSignal): Promise<WidgetSummary> {
168
+ const ratings: Array<1 | 2 | 3 | 4 | 5> = [1, 2, 3, 4, 5];
169
+ const breakdown = await Promise.all(
170
+ ratings.map((r) =>
171
+ listReviews({ page: 1, perPage: MIN_PER_PAGE, sort: 'most_relevant', rating: r }, signal).then(
172
+ (res) => res.pagination.total,
173
+ ),
174
+ ),
175
+ );
176
+
177
+ const total = breakdown.reduce((a, b) => a + b, 0);
178
+ const weighted = breakdown.reduce((acc, n, i) => acc + n * (i + 1), 0);
179
+ const average = total > 0 ? Math.round((weighted / total) * 10) / 10 : 0;
180
+ const positive = breakdown[3] + breakdown[4];
181
+ const positivePct = total > 0 ? Math.round((positive / total) * 100) : 0;
182
+
183
+ const [photoCount, verifiedCount] = await Promise.all([
184
+ listReviews({ page: 1, perPage: MAX_PER_PAGE, sort: 'most_relevant', mediaOnly: true }, signal)
185
+ .then((r) => r.pagination.total)
186
+ .catch(() => 0),
187
+ listReviews({ page: 1, perPage: MAX_PER_PAGE, sort: 'most_relevant' }, signal)
188
+ .then((r) => r.reviews.filter((x) => x.verified).length)
189
+ .catch(() => 0),
190
+ ]);
191
+
192
+ return { total, average, breakdown, positivePct, photoCount, verifiedCount };
193
+ }
194
+
195
+ return { listReviews, getReviewDetail, voteReview, fetchSummary };
196
+ }
197
+
198
+ /**
199
+ * Default in-memory `VoteStateStore`. Survives only for the component's
200
+ * lifetime; hosts wanting "already voted" to persist across launches pass an
201
+ * AsyncStorage-backed implementation instead.
202
+ */
203
+ export function createMemoryVoteStore(): VoteStateStore {
204
+ const map = new Map<number, VoteType | null>();
205
+ return {
206
+ get: (reviewId) => map.get(reviewId),
207
+ set: (reviewId, vote) => {
208
+ map.set(reviewId, vote);
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Client types for the public widget API.
3
+ *
4
+ * The package owns the UI + pagination/sort/filter state and assembles the
5
+ * logical requests; the HOST owns transport — it implements the `Fetcher`,
6
+ * which prepends the API base URL and injects the auth `token`. No token
7
+ * ever lives in this package or the RN binary.
8
+ */
9
+ import type {
10
+ WidgetReview,
11
+ WidgetPagination,
12
+ WidgetVoteCounts,
13
+ WidgetSortValue,
14
+ WidgetSummary,
15
+ } from '../../../schemas/src/index.js';
16
+
17
+ export type {
18
+ WidgetReview,
19
+ WidgetPagination,
20
+ WidgetVoteCounts,
21
+ WidgetSortValue,
22
+ WidgetSummary,
23
+ };
24
+
25
+ /**
26
+ * A logical request the host fetcher fulfils. The host MUST prepend the API
27
+ * base URL, inject the `token`, perform the request, throw on a non-2xx
28
+ * status, and resolve with the parsed JSON body. The host MUST NOT put the
29
+ * resolved URL or the token into any error it throws (the token rides in the
30
+ * query string — a logged URL/error leaks it).
31
+ */
32
+ export interface FetcherRequest {
33
+ /** Path relative to the API base, e.g. `/api/widget/{store}/products/{product}/reviews`. */
34
+ path: string;
35
+ /** Non-secret query params. The token is added by the host, never here. */
36
+ query?: Record<string, string | number | boolean | undefined>;
37
+ method?: 'GET' | 'POST';
38
+ /** JSON-serializable body (vote only). */
39
+ body?: unknown;
40
+ signal?: AbortSignal;
41
+ }
42
+
43
+ export type Fetcher = (req: FetcherRequest) => Promise<unknown>;
44
+
45
+ export type VoteType = 'helpful' | 'unhelpful';
46
+
47
+ /**
48
+ * Host-provided persistence for "has this user voted on this review".
49
+ * Stores only review id → vote direction (no PII, no token, no review
50
+ * content) — safe for unencrypted storage like AsyncStorage. Do NOT widen
51
+ * it to cache review payloads.
52
+ */
53
+ export interface VoteStateStore {
54
+ get(reviewId: number): VoteType | null | undefined;
55
+ set(reviewId: number, vote: VoteType | null): void;
56
+ }
57
+
58
+ export interface ListParams {
59
+ page?: number;
60
+ perPage?: number;
61
+ sort?: WidgetSortValue;
62
+ rating?: 1 | 2 | 3 | 4 | 5 | null;
63
+ mediaOnly?: boolean;
64
+ search?: string;
65
+ }
66
+
67
+ export interface ReviewListResult {
68
+ reviews: WidgetReview[];
69
+ pagination: WidgetPagination;
70
+ }
71
+
72
+ /**
73
+ * Reported once per list response when one or more rows fail schema
74
+ * validation. Carries paths + a count ONLY — never row contents (untrusted
75
+ * customer-authored review text must not reach telemetry).
76
+ */
77
+ export interface SchemaViolationInfo {
78
+ droppedCount: number;
79
+ firstBadPath: string;
80
+ }
81
+
82
+ export interface BetterReviewsClientConfig {
83
+ fetcher: Fetcher;
84
+ storeId: string;
85
+ productId: string;
86
+ onSchemaViolation?: (info: SchemaViolationInfo) => void;
87
+ }
88
+
89
+ export interface BetterReviewsClient {
90
+ listReviews(params?: ListParams, signal?: AbortSignal): Promise<ReviewListResult>;
91
+ /** Detail is scoped to the configured product (`product_id` is always sent — the A4 IDOR fix). */
92
+ getReviewDetail(reviewId: number, signal?: AbortSignal): Promise<WidgetReview>;
93
+ /** Returns updated counts, or `null` if the POST succeeded but the body was unparseable. Rejects on HTTP/network error. */
94
+ voteReview(
95
+ reviewId: number,
96
+ type: VoteType,
97
+ undo: boolean,
98
+ signal?: AbortSignal,
99
+ ): Promise<WidgetVoteCounts | null>;
100
+ fetchSummary(signal?: AbortSignal): Promise<WidgetSummary>;
101
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Widget icons — typed port of the prototype's `BRIcons.js`, itself a port of
3
+ * the storefront sprite (`br-widget-script.liquid`) + star snippet
4
+ * (`br-stars.liquid`). All stroke icons are 24×24, `stroke="currentColor"`,
5
+ * width 2, round caps/joins, no fill. `IconStar` is the one fill shape;
6
+ * partial fills use a `<LinearGradient>` with two stops at the same offset
7
+ * (a hard split, not a soft gradient).
8
+ *
9
+ * The prototype's unused `Stars` convenience wrapper is intentionally dropped
10
+ * — `RatingStars` composes `IconStar` directly.
11
+ */
12
+ import React from 'react';
13
+ import type { StyleProp, ViewStyle } from 'react-native';
14
+ import Svg, { Path, Line, Polyline, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
15
+
16
+ export interface IconProps {
17
+ size?: number;
18
+ color?: string;
19
+ style?: StyleProp<ViewStyle>;
20
+ }
21
+
22
+ interface StrokeIconProps extends IconProps {
23
+ children: React.ReactNode;
24
+ }
25
+
26
+ function StrokeIcon({ size = 16, color = 'currentColor', children, style }: StrokeIconProps) {
27
+ return (
28
+ <Svg
29
+ width={size}
30
+ height={size}
31
+ viewBox="0 0 24 24"
32
+ fill="none"
33
+ stroke={color}
34
+ strokeWidth={2}
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ style={style}
38
+ >
39
+ {children}
40
+ </Svg>
41
+ );
42
+ }
43
+
44
+ export const IconShieldCheck = (props: IconProps) => (
45
+ <StrokeIcon {...props}>
46
+ <Path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
47
+ <Polyline points="9 12 12 15 16 10" />
48
+ </StrokeIcon>
49
+ );
50
+
51
+ export const IconThumbsUp = (props: IconProps) => (
52
+ <StrokeIcon {...props}>
53
+ <Path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
54
+ <Path d="M14 2l-3 7v13h9a2 2 0 0 0 2-1.7l1.38-9A2 2 0 0 0 21.4 9H14" />
55
+ </StrokeIcon>
56
+ );
57
+
58
+ export const IconThumbsDown = (props: IconProps) => (
59
+ <StrokeIcon {...props}>
60
+ <Path d="M17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" />
61
+ <Path d="M10 22l3-7V2H4a2 2 0 0 0-2 1.7l-1.38 9A2 2 0 0 0 2.6 15H10" />
62
+ </StrokeIcon>
63
+ );
64
+
65
+ export const IconCamera = (props: IconProps) => (
66
+ <StrokeIcon {...props}>
67
+ <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" />
68
+ <Circle cx="12" cy="13" r="4" />
69
+ </StrokeIcon>
70
+ );
71
+
72
+ export const IconChevronDown = (props: IconProps) => (
73
+ <StrokeIcon {...props}>
74
+ <Polyline points="6 9 12 15 18 9" />
75
+ </StrokeIcon>
76
+ );
77
+
78
+ export const IconChevronLeft = (props: IconProps) => (
79
+ <StrokeIcon {...props}>
80
+ <Polyline points="15 18 9 12 15 6" />
81
+ </StrokeIcon>
82
+ );
83
+
84
+ export const IconChevronRight = (props: IconProps) => (
85
+ <StrokeIcon {...props}>
86
+ <Polyline points="9 18 15 12 9 6" />
87
+ </StrokeIcon>
88
+ );
89
+
90
+ export const IconXClose = (props: IconProps) => (
91
+ <StrokeIcon {...props}>
92
+ <Line x1="18" y1="6" x2="6" y2="18" />
93
+ <Line x1="6" y1="6" x2="18" y2="18" />
94
+ </StrokeIcon>
95
+ );
96
+
97
+ export const IconArrowUpDown = (props: IconProps) => (
98
+ <StrokeIcon {...props}>
99
+ <Polyline points="7 3 7 21" />
100
+ <Polyline points="4 6 7 3 10 6" />
101
+ <Polyline points="17 21 17 3" />
102
+ <Polyline points="14 18 17 21 20 18" />
103
+ </StrokeIcon>
104
+ );
105
+
106
+ export const IconSearch = ({ size = 16, color = 'currentColor', style }: IconProps) => (
107
+ <Svg
108
+ width={size}
109
+ height={size}
110
+ viewBox="0 0 24 24"
111
+ fill="none"
112
+ stroke={color}
113
+ strokeWidth={2}
114
+ strokeLinecap="round"
115
+ strokeLinejoin="round"
116
+ style={style}
117
+ >
118
+ <Circle cx="11" cy="11" r="8" />
119
+ <Line x1="21" y1="21" x2="16.65" y2="16.65" />
120
+ </Svg>
121
+ );
122
+
123
+ const STAR_PATH =
124
+ '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';
125
+
126
+ export interface IconStarProps {
127
+ size?: number;
128
+ fillPercentage?: number;
129
+ fillColor?: string;
130
+ emptyColor?: string;
131
+ /**
132
+ * Unique per-instance gradient id seed. `react-native-svg` gradient ids are
133
+ * global, so two stars sharing a `uid` would share a fill — callers MUST
134
+ * pass a stable per-row/per-position value (e.g. `c-{reviewId}-{index}`).
135
+ */
136
+ uid?: string;
137
+ style?: StyleProp<ViewStyle>;
138
+ }
139
+
140
+ export function IconStar({
141
+ size = 16,
142
+ fillPercentage = 100,
143
+ fillColor = '#e8a808',
144
+ emptyColor = '#eef0f3',
145
+ uid = 'star',
146
+ style,
147
+ }: IconStarProps) {
148
+ const p = Math.max(0, Math.min(100, fillPercentage));
149
+ const gradId = `br-half-${uid}-${size}-${p}`;
150
+
151
+ if (p >= 100) {
152
+ return (
153
+ <Svg width={size} height={size} viewBox="0 0 24 24" style={style}>
154
+ <Path d={STAR_PATH} fill={fillColor} />
155
+ </Svg>
156
+ );
157
+ }
158
+ if (p <= 0) {
159
+ return (
160
+ <Svg width={size} height={size} viewBox="0 0 24 24" style={style}>
161
+ <Path d={STAR_PATH} fill={emptyColor} />
162
+ </Svg>
163
+ );
164
+ }
165
+ return (
166
+ <Svg width={size} height={size} viewBox="0 0 24 24" style={style}>
167
+ <Defs>
168
+ <LinearGradient id={gradId} x1="0" y1="0" x2="1" y2="0">
169
+ <Stop offset={`${p}%`} stopColor={fillColor} />
170
+ <Stop offset={`${p}%`} stopColor={emptyColor} />
171
+ </LinearGradient>
172
+ </Defs>
173
+ <Path d={STAR_PATH} fill={`url(#${gradId})`} />
174
+ </Svg>
175
+ );
176
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `@betterreviews/react-native` — public API.
3
+ *
4
+ * Partner host apps:
5
+ * 1. Fetch the three `betterreviews_reactiv.*` metafield bodies
6
+ * from Shopify (storefront API or partner's backend proxy).
7
+ * 2. Wrap their navigation tree with `<BetterReviewsProvider>`
8
+ * supplying the merchant-resolved `theme` + `config` +
9
+ * `onTelemetryEvent` callback.
10
+ * 3. Render `<ProductContentBlock block={...}>` on PDP screens.
11
+ *
12
+ * See README.md for integration + SECURITY.md for security
13
+ * obligations on the host-app side.
14
+ */
15
+
16
+ export {
17
+ BetterReviewsProvider,
18
+ useBetterReviews,
19
+ type BetterReviewsProviderProps,
20
+ type BetterReviewsContextValue,
21
+ } from './BetterReviewsProvider.js';
22
+
23
+ export { ProductContentBlock, type ProductContentBlockProps } from './ProductContentBlock.js';
24
+
25
+ export { FeaturesSection, type FeaturesSectionProps } from './sections/FeaturesSection.js';
26
+ export { ReviewsSummarySection } from './sections/ReviewsSummarySection.js';
27
+
28
+ export { WebViewHost, type WebViewHostProps } from './WebViewHost.js';
29
+
30
+ // Compact aggregate rating badge (stars + score + count) for near the title.
31
+ export { StarRating, type StarRatingProps } from './StarRating.js';
32
+
33
+ // Review-browsing widget (list / filter / sort / read-more / media viewer / vote).
34
+ export { ReviewWidget, type ReviewWidgetProps } from './widget/ReviewWidget.js';
35
+ export {
36
+ createBetterReviewsClient,
37
+ createMemoryVoteStore,
38
+ } from './client/createBetterReviewsClient.js';
39
+ export type {
40
+ BetterReviewsClient,
41
+ BetterReviewsClientConfig,
42
+ Fetcher,
43
+ FetcherRequest,
44
+ ListParams,
45
+ ReviewListResult,
46
+ SchemaViolationInfo,
47
+ VoteType,
48
+ VoteStateStore,
49
+ } from './client/types.js';
50
+
51
+ export { applyTheme, type ResolvedTheme } from './theme/applyTheme.js';
52
+ export { meetsFloor, SDK_VERSION } from './minSdkVersion.js';
53
+ export { resolveBridge, type ResolvedBridge } from './bridge.js';
54
+ export {
55
+ safeTelemetry,
56
+ noopTelemetry,
57
+ type TelemetryEvent,
58
+ type TelemetryHandler,
59
+ } from './telemetry.js';
60
+
61
+ // Re-export schema types so partner host apps don't need a separate
62
+ // @betterreviews/schemas install — single dep surface for the partner.
63
+ export type {
64
+ ProductContentBlock as ProductContentBlockSchema,
65
+ Theme,
66
+ Config,
67
+ WidgetReview,
68
+ WidgetMediaItem,
69
+ WidgetMerchantReply,
70
+ WidgetPagination,
71
+ WidgetVoteCounts,
72
+ WidgetSortValue,
73
+ WidgetSummary,
74
+ } from '../../schemas/src/index.js';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Semver floor compare. The metafield's optional `config.min_sdk_version`
3
+ * declares the minimum SDK version the merchant expects. The RN
4
+ * package compares against its own version on mount; if below the
5
+ * floor, ProductContentBlock renders nothing (degrade-gracefully).
6
+ *
7
+ * v1 of the resolver does NOT emit `min_sdk_version` — this is
8
+ * forward-compat plumbing.
9
+ */
10
+
11
+ import packageJson from '../package.json' assert { type: 'json' };
12
+
13
+ export const SDK_VERSION: string = packageJson.version;
14
+
15
+ interface ParsedSemver {
16
+ major: number;
17
+ minor: number;
18
+ patch: number;
19
+ }
20
+
21
+ function parse(version: string): ParsedSemver | null {
22
+ // Accepts MAJOR.MINOR.PATCH with optional -pre.release suffix; we
23
+ // only compare core for the floor check (pre-release is treated as
24
+ // equivalent to the same MAJOR.MINOR.PATCH for compat purposes).
25
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
26
+ if (!match) return null;
27
+
28
+ return {
29
+ major: parseInt(match[1]!, 10),
30
+ minor: parseInt(match[2]!, 10),
31
+ patch: parseInt(match[3]!, 10),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Returns true if the SDK's current version meets the configured
37
+ * floor, false otherwise. A null/undefined floor always passes (no
38
+ * floor declared). Malformed floor strings are treated as "no floor"
39
+ * — the package errs on the side of rendering rather than refusing.
40
+ */
41
+ export function meetsFloor(floor: string | undefined): boolean {
42
+ if (!floor) return true;
43
+
44
+ const required = parse(floor);
45
+ const have = parse(SDK_VERSION);
46
+
47
+ if (!required || !have) return true;
48
+
49
+ if (have.major !== required.major) return have.major > required.major;
50
+ if (have.minor !== required.minor) return have.minor > required.minor;
51
+ return have.patch >= required.patch;
52
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Renders the `features` section — a bullet list capped by the schema
3
+ * at 6 bullets / 80 chars each. The schema's strict-overlay validator
4
+ * on the Elixir side has already enforced those caps; this component
5
+ * renders defensively in case a partial fallback path slips through.
6
+ */
7
+
8
+ import React from 'react';
9
+ import { View, Text, StyleSheet } from 'react-native';
10
+ import { useBetterReviews } from '../BetterReviewsProvider.js';
11
+ import { applyTheme } from '../theme/applyTheme.js';
12
+
13
+ export interface FeaturesSectionProps {
14
+ bullets: string[];
15
+ }
16
+
17
+ export function FeaturesSection({ bullets }: FeaturesSectionProps): React.ReactElement {
18
+ const { theme } = useBetterReviews();
19
+ const resolved = applyTheme(theme);
20
+
21
+ return (
22
+ // Renders flat — like normal page text, no background fill / box. Inherits
23
+ // the host's surrounding layout (no own horizontal padding).
24
+ <View style={styles.container}>
25
+ {bullets.map((bullet, index) => (
26
+ <View key={index} style={styles.row}>
27
+ <Text
28
+ style={[
29
+ styles.bullet,
30
+ resolved.textColor ? { color: resolved.textColor } : null,
31
+ ]}
32
+ >
33
+
34
+ </Text>
35
+ <Text
36
+ style={[
37
+ styles.text,
38
+ resolved.textColor ? { color: resolved.textColor } : null,
39
+ resolved.fontFamily ? { fontFamily: resolved.fontFamily } : null,
40
+ ]}
41
+ >
42
+ {bullet}
43
+ </Text>
44
+ </View>
45
+ ))}
46
+ </View>
47
+ );
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ container: {
52
+ paddingVertical: 8,
53
+ },
54
+ row: {
55
+ flexDirection: 'row',
56
+ alignItems: 'flex-start',
57
+ paddingVertical: 4,
58
+ },
59
+ bullet: {
60
+ fontSize: 16,
61
+ lineHeight: 22,
62
+ marginRight: 8,
63
+ },
64
+ text: {
65
+ flex: 1,
66
+ fontSize: 14,
67
+ lineHeight: 22,
68
+ },
69
+ });