@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.
- package/LICENSE +145 -0
- package/README.md +189 -0
- package/SECURITY.md +238 -0
- package/dist/index.d.mts +581 -0
- package/dist/index.d.ts +581 -0
- package/dist/index.js +2384 -0
- package/dist/index.mjs +2346 -0
- package/package.json +78 -0
- package/src/BetterReviewsProvider.tsx +62 -0
- package/src/ProductContentBlock.tsx +143 -0
- package/src/StarRating.tsx +85 -0
- package/src/WebViewHost.tsx +164 -0
- package/src/bridge.ts +48 -0
- package/src/client/createBetterReviewsClient.ts +211 -0
- package/src/client/types.ts +101 -0
- package/src/icons/BRIcons.tsx +176 -0
- package/src/index.ts +74 -0
- package/src/minSdkVersion.ts +52 -0
- package/src/sections/FeaturesSection.tsx +69 -0
- package/src/sections/ReviewsSummarySection.tsx +47 -0
- package/src/telemetry.ts +52 -0
- package/src/theme/applyTheme.ts +72 -0
- package/src/theme/widgetTheme.ts +67 -0
- package/src/webviewMessage.ts +23 -0
- package/src/widget/ReviewWidget.tsx +230 -0
- package/src/widget/WidgetContext.tsx +43 -0
- package/src/widget/components/FilterToolbar.tsx +146 -0
- package/src/widget/components/MediaGallery.tsx +53 -0
- package/src/widget/components/PulseSection.tsx +69 -0
- package/src/widget/components/RatingStars.tsx +40 -0
- package/src/widget/components/ReviewCard.tsx +114 -0
- package/src/widget/components/SortDrawer.tsx +49 -0
- package/src/widget/components/StaleListOverlay.tsx +51 -0
- package/src/widget/components/VoteButtons.tsx +55 -0
- package/src/widget/hooks/useReviewDetail.ts +55 -0
- package/src/widget/hooks/useReviewList.ts +136 -0
- package/src/widget/hooks/useReviewSummary.ts +24 -0
- package/src/widget/hooks/useVote.ts +68 -0
- package/src/widget/styles.ts +393 -0
- package/src/widget/util.ts +21 -0
- package/src/widget/viewer/MediaReviewViewer.tsx +350 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the `reviews_summary` section. v1 displays a placeholder
|
|
3
|
+
* — the partner host app is expected to supply the live aggregate
|
|
4
|
+
* (star rating, count, top quote) by mounting its own review-summary
|
|
5
|
+
* component as a child or sibling.
|
|
6
|
+
*
|
|
7
|
+
* The presence of this section in the metafield blob is the merchant's
|
|
8
|
+
* "show me the reviews summary on this product" toggle. Absence means
|
|
9
|
+
* "don't render this section." A future PR may expand this to render
|
|
10
|
+
* `betterreviews.summary` data directly when supplied via
|
|
11
|
+
* `BetterReviewsProvider`'s `summary` prop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
16
|
+
import { useBetterReviews } from '../BetterReviewsProvider.js';
|
|
17
|
+
import { applyTheme } from '../theme/applyTheme.js';
|
|
18
|
+
|
|
19
|
+
export function ReviewsSummarySection(): React.ReactElement {
|
|
20
|
+
const { theme } = useBetterReviews();
|
|
21
|
+
const resolved = applyTheme(theme);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
// Flat — no background fill / box; reads as normal page text.
|
|
25
|
+
<View style={styles.container}>
|
|
26
|
+
<Text
|
|
27
|
+
style={[
|
|
28
|
+
styles.label,
|
|
29
|
+
resolved.textColor ? { color: resolved.textColor } : null,
|
|
30
|
+
resolved.fontFamily ? { fontFamily: resolved.fontFamily } : null,
|
|
31
|
+
]}
|
|
32
|
+
>
|
|
33
|
+
Customer reviews
|
|
34
|
+
</Text>
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const styles = StyleSheet.create({
|
|
40
|
+
container: {
|
|
41
|
+
paddingVertical: 8,
|
|
42
|
+
},
|
|
43
|
+
label: {
|
|
44
|
+
fontSize: 16,
|
|
45
|
+
fontWeight: '600',
|
|
46
|
+
},
|
|
47
|
+
});
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry event types emitted by the package. Per SECURITY.md, all
|
|
3
|
+
* events are pre-redacted — no customer email, name, IP, transcript,
|
|
4
|
+
* or review content appears in any payload.
|
|
5
|
+
*
|
|
6
|
+
* Consumers (partner host apps) wire `onTelemetryEvent` on the
|
|
7
|
+
* `<BetterReviewsProvider>` to forward events to their own
|
|
8
|
+
* observability stack.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type TelemetryEvent =
|
|
12
|
+
| { type: 'betterreviews.fetch.success'; block_id?: string; latency_ms?: number; cache_hit?: boolean }
|
|
13
|
+
| { type: 'betterreviews.fetch.failure'; block_id?: string; error_code: string; retry_attempt?: number }
|
|
14
|
+
| { type: 'betterreviews.signature.invalid'; endpoint?: string; source_ip_hash?: string }
|
|
15
|
+
| {
|
|
16
|
+
type: 'betterreviews.schema.violation';
|
|
17
|
+
schema_version: number;
|
|
18
|
+
violation_path?: string;
|
|
19
|
+
// Count of rows/items dropped in one response (one event per response,
|
|
20
|
+
// not one per row). Paths + counts only — never row contents.
|
|
21
|
+
dropped_count?: number;
|
|
22
|
+
}
|
|
23
|
+
| { type: 'betterreviews.webview.error'; error_code: string; url_origin?: string };
|
|
24
|
+
|
|
25
|
+
export type TelemetryHandler = (event: TelemetryEvent) => void;
|
|
26
|
+
|
|
27
|
+
const noopHandler: TelemetryHandler = () => {};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns a handler that swallows all events. Used as the default
|
|
31
|
+
* when the partner host app doesn't supply `onTelemetryEvent`.
|
|
32
|
+
*/
|
|
33
|
+
export function noopTelemetry(): TelemetryHandler {
|
|
34
|
+
return noopHandler;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wraps a handler so synchronous exceptions in the partner's
|
|
39
|
+
* implementation can't crash the renderer. Partner-side bugs become
|
|
40
|
+
* silent failures rather than UI outages.
|
|
41
|
+
*/
|
|
42
|
+
export function safeTelemetry(handler: TelemetryHandler | undefined): TelemetryHandler {
|
|
43
|
+
if (!handler) return noopHandler;
|
|
44
|
+
|
|
45
|
+
return (event) => {
|
|
46
|
+
try {
|
|
47
|
+
handler(event);
|
|
48
|
+
} catch {
|
|
49
|
+
// Telemetry must never crash render. Swallow.
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a `betterreviews_reactiv.theme` JSON blob to a flat StyleSheet
|
|
3
|
+
* record consumed by the section renderers. All fields are optional —
|
|
4
|
+
* absent fields fall back to RN's platform defaults.
|
|
5
|
+
*
|
|
6
|
+
* The shape matches `PPO.Reactiv.ContentResolver.build_theme/1` —
|
|
7
|
+
* flat color keys + corner_style enum + font_family enum.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Theme } from '../../../schemas/src/theme.js';
|
|
11
|
+
|
|
12
|
+
// Maps `corner_style` enum to a numeric `borderRadius` value usable
|
|
13
|
+
// directly in RN style props.
|
|
14
|
+
const cornerRadiusMap: Record<string, number> = {
|
|
15
|
+
sharp: 0,
|
|
16
|
+
'slightly-rounded': 8,
|
|
17
|
+
rounded: 20,
|
|
18
|
+
'extra-rounded': 28,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Maps `font_family` enum to an RN-friendly font family string.
|
|
22
|
+
// `'system'` resolves to platform default at render time (RN's null
|
|
23
|
+
// `fontFamily` already does this, so we omit the key entirely for
|
|
24
|
+
// 'system' to let RN fall back naturally).
|
|
25
|
+
const fontFamilyMap: Record<string, string | null> = {
|
|
26
|
+
system: null,
|
|
27
|
+
serif: 'Georgia',
|
|
28
|
+
'sans-serif': 'System',
|
|
29
|
+
mono: 'Courier',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface ResolvedTheme {
|
|
33
|
+
primaryColor?: string;
|
|
34
|
+
backgroundColor?: string;
|
|
35
|
+
textColor?: string;
|
|
36
|
+
accentColor?: string;
|
|
37
|
+
borderRadius?: number;
|
|
38
|
+
fontFamily?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pure function — no React, no platform calls. Maps a Theme blob to a
|
|
43
|
+
* ResolvedTheme suitable for spreading into style props.
|
|
44
|
+
*
|
|
45
|
+
* Unknown enum values for `corner_style` / `font_family` resolve to
|
|
46
|
+
* `undefined` (the schema validator at the write boundary already
|
|
47
|
+
* rejects unknown values, but defensive in case of a malformed
|
|
48
|
+
* payload landing in the read path).
|
|
49
|
+
*/
|
|
50
|
+
export function applyTheme(theme: Theme | null | undefined): ResolvedTheme {
|
|
51
|
+
if (!theme) return {};
|
|
52
|
+
|
|
53
|
+
const out: ResolvedTheme = {};
|
|
54
|
+
|
|
55
|
+
if (theme.primary_color) out.primaryColor = theme.primary_color;
|
|
56
|
+
if (theme.background_color) out.backgroundColor = theme.background_color;
|
|
57
|
+
if (theme.text_color) out.textColor = theme.text_color;
|
|
58
|
+
if (theme.accent_color) out.accentColor = theme.accent_color;
|
|
59
|
+
|
|
60
|
+
if (theme.corner_style && theme.corner_style in cornerRadiusMap) {
|
|
61
|
+
out.borderRadius = cornerRadiusMap[theme.corner_style];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (theme.font_family && theme.font_family in fontFamilyMap) {
|
|
65
|
+
const resolved = fontFamilyMap[theme.font_family];
|
|
66
|
+
if (resolved !== null && resolved !== undefined) {
|
|
67
|
+
out.fontFamily = resolved;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved theme for the review widget.
|
|
3
|
+
*
|
|
4
|
+
* v1 policy (locked): the merchant's `betterreviews_reactiv.theme` supplies
|
|
5
|
+
* background / text / accent / corner-radius / font (via the existing
|
|
6
|
+
* `applyTheme`); everything else — star, verified, muted, border, scrim — is
|
|
7
|
+
* a FIXED neutral. Per-surface theming of those is a later additive schema
|
|
8
|
+
* bump, so widening this resolver is non-breaking.
|
|
9
|
+
*
|
|
10
|
+
* Color set mirrors the prototype's `THEME` constant.
|
|
11
|
+
*/
|
|
12
|
+
import { applyTheme } from './applyTheme.js';
|
|
13
|
+
import type { Theme } from '../../../schemas/src/theme.js';
|
|
14
|
+
|
|
15
|
+
// Neutral defaults — zinc grayscale + a black CTA. The widget shows NO brand
|
|
16
|
+
// color unless the host passes `accent_color`/`background_color`/`text_color`.
|
|
17
|
+
// The only non-grayscale defaults are the gold star/bars and the green
|
|
18
|
+
// "Verified Buyer" badge (universal review conventions, fixed in v1).
|
|
19
|
+
export const WIDGET_NEUTRALS = {
|
|
20
|
+
bg: '#ffffff',
|
|
21
|
+
text: '#18181b', // zinc-900
|
|
22
|
+
accent: '#18181b', // black CTA
|
|
23
|
+
card: '#fafafa', // zinc-50
|
|
24
|
+
muted: '#f4f4f5', // zinc-100
|
|
25
|
+
mutedFg: '#71717a', // zinc-500
|
|
26
|
+
border: '#e4e4e7', // zinc-200
|
|
27
|
+
star: '#e8a808', // gold — fixed
|
|
28
|
+
verified: '#1c8c4d', // green — fixed Verified Buyer badge
|
|
29
|
+
verifiedBg: '#e2f1e9',
|
|
30
|
+
mark: 'rgba(228,228,231,0.6)', // zinc highlight
|
|
31
|
+
overflowScrim: 'rgba(0,0,0,0.55)',
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export interface WidgetTheme {
|
|
35
|
+
bg: string;
|
|
36
|
+
text: string;
|
|
37
|
+
accent: string;
|
|
38
|
+
card: string;
|
|
39
|
+
muted: string;
|
|
40
|
+
mutedFg: string;
|
|
41
|
+
border: string;
|
|
42
|
+
star: string;
|
|
43
|
+
verified: string;
|
|
44
|
+
verifiedBg: string;
|
|
45
|
+
mark: string;
|
|
46
|
+
overflowScrim: string;
|
|
47
|
+
/** From `corner_style` — applied to cards/buttons; undefined = RN default. */
|
|
48
|
+
cornerRadius?: number;
|
|
49
|
+
/** From `font_family`; undefined = platform default. */
|
|
50
|
+
fontFamily?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge the merchant theme (bg/text/accent/corner/font) over the fixed
|
|
55
|
+
* neutrals. Pure — safe to call during render.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveWidgetTheme(theme: Theme | null | undefined): WidgetTheme {
|
|
58
|
+
const r = applyTheme(theme);
|
|
59
|
+
return {
|
|
60
|
+
...WIDGET_NEUTRALS,
|
|
61
|
+
bg: r.backgroundColor ?? WIDGET_NEUTRALS.bg,
|
|
62
|
+
text: r.textColor ?? WIDGET_NEUTRALS.text,
|
|
63
|
+
accent: r.accentColor ?? WIDGET_NEUTRALS.accent,
|
|
64
|
+
cornerRadius: r.borderRadius,
|
|
65
|
+
fontFamily: r.fontFamily,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for messages posted from the embedded WebView page. Kept free
|
|
3
|
+
* of `react-native` / `react-native-webview` imports so it is unit-testable in
|
|
4
|
+
* plain Node (vitest), unlike `WebViewHost.tsx` itself.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* True when the embedded page posted `{ "type": "close" }` (the chat's
|
|
9
|
+
* "Back to store" / done signal). Safe on any string — non-JSON, `null`, and
|
|
10
|
+
* primitives all return false.
|
|
11
|
+
*/
|
|
12
|
+
export function isCloseSignal(data: string): boolean {
|
|
13
|
+
try {
|
|
14
|
+
const parsed: unknown = JSON.parse(data);
|
|
15
|
+
return (
|
|
16
|
+
typeof parsed === 'object' &&
|
|
17
|
+
parsed !== null &&
|
|
18
|
+
(parsed as { type?: unknown }).type === 'close'
|
|
19
|
+
);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ReviewWidget>` — the public, stateful review-browsing surface.
|
|
3
|
+
*
|
|
4
|
+
* Renders INLINE — it owns no scroll container, so it drops into a host's
|
|
5
|
+
* product-page `ScrollView` as one section (below the product info /
|
|
6
|
+
* `ProductContentBlock`) and scrolls with the page. Paging is a "Load more"
|
|
7
|
+
* button, so no virtualization is needed.
|
|
8
|
+
*
|
|
9
|
+
* Theme + telemetry flow from `<BetterReviewsProvider>` context (same
|
|
10
|
+
* convention as `ProductContentBlock`); transport/auth flow from the host via
|
|
11
|
+
* the injected `client` (or `fetcher` + ids). Schema violations are emitted as
|
|
12
|
+
* telemetry from the client's async callback (post-render, so the b9357c30
|
|
13
|
+
* "no emit during render" rule holds).
|
|
14
|
+
*/
|
|
15
|
+
import React, { useMemo, useState } from 'react';
|
|
16
|
+
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
|
|
17
|
+
import { useBetterReviews } from '../BetterReviewsProvider.js';
|
|
18
|
+
import { createBetterReviewsClient, createMemoryVoteStore } from '../client/createBetterReviewsClient.js';
|
|
19
|
+
import type {
|
|
20
|
+
BetterReviewsClient,
|
|
21
|
+
Fetcher,
|
|
22
|
+
VoteStateStore,
|
|
23
|
+
WidgetReview,
|
|
24
|
+
WidgetSortValue,
|
|
25
|
+
} from '../client/types.js';
|
|
26
|
+
import { resolveWidgetTheme } from '../theme/widgetTheme.js';
|
|
27
|
+
import { MediaGallery, type GalleryPhoto } from './components/MediaGallery.js';
|
|
28
|
+
import { FilterToolbar, SortBar, WriteReviewBtn } from './components/FilterToolbar.js';
|
|
29
|
+
import { PulseMetrics, PulseSummary, SectionTitle } from './components/PulseSection.js';
|
|
30
|
+
import { ReviewCard } from './components/ReviewCard.js';
|
|
31
|
+
import { SortDrawer } from './components/SortDrawer.js';
|
|
32
|
+
import { StaleListOverlay } from './components/StaleListOverlay.js';
|
|
33
|
+
import { useReviewList } from './hooks/useReviewList.js';
|
|
34
|
+
import { useReviewSummary } from './hooks/useReviewSummary.js';
|
|
35
|
+
import { makeWidgetStyles } from './styles.js';
|
|
36
|
+
import { MediaReviewViewer } from './viewer/MediaReviewViewer.js';
|
|
37
|
+
import { WidgetProvider, type WidgetContextValue } from './WidgetContext.js';
|
|
38
|
+
|
|
39
|
+
export interface ReviewWidgetProps {
|
|
40
|
+
/** Primary: a client built via `createBetterReviewsClient`. */
|
|
41
|
+
client?: BetterReviewsClient;
|
|
42
|
+
/** Convenience: supply transport + ids and the widget builds the client. */
|
|
43
|
+
fetcher?: Fetcher;
|
|
44
|
+
storeId?: string;
|
|
45
|
+
productId?: string;
|
|
46
|
+
/** Persist "already voted" across launches; defaults to in-memory. */
|
|
47
|
+
voteStateStore?: VoteStateStore;
|
|
48
|
+
/** Host-owned write-review action; the CTA hides if omitted. */
|
|
49
|
+
onWriteReview?: () => void;
|
|
50
|
+
initialSort?: WidgetSortValue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ReviewWidget(props: ReviewWidgetProps): React.ReactElement {
|
|
54
|
+
const { client: clientProp, fetcher, storeId, productId, voteStateStore, onWriteReview, initialSort } = props;
|
|
55
|
+
const { theme: rawTheme, emit } = useBetterReviews();
|
|
56
|
+
|
|
57
|
+
const client = useMemo<BetterReviewsClient>(() => {
|
|
58
|
+
if (clientProp) return clientProp;
|
|
59
|
+
if (!fetcher || !storeId || !productId) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'<ReviewWidget> requires either a `client` or `fetcher` + `storeId` + `productId`.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return createBetterReviewsClient({
|
|
65
|
+
fetcher,
|
|
66
|
+
storeId,
|
|
67
|
+
productId,
|
|
68
|
+
onSchemaViolation: (info) =>
|
|
69
|
+
emit({
|
|
70
|
+
type: 'betterreviews.schema.violation',
|
|
71
|
+
schema_version: 1,
|
|
72
|
+
violation_path: info.firstBadPath,
|
|
73
|
+
dropped_count: info.droppedCount,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
}, [clientProp, fetcher, storeId, productId, emit]);
|
|
77
|
+
|
|
78
|
+
const theme = useMemo(() => resolveWidgetTheme(rawTheme), [rawTheme]);
|
|
79
|
+
const styles = useMemo(() => makeWidgetStyles(theme), [theme]);
|
|
80
|
+
const fallbackStore = useMemo(() => createMemoryVoteStore(), []);
|
|
81
|
+
const voteStore = voteStateStore ?? fallbackStore;
|
|
82
|
+
|
|
83
|
+
const list = useReviewList(client, emit, initialSort);
|
|
84
|
+
const summary = useReviewSummary(client);
|
|
85
|
+
const [viewer, setViewer] = useState<{ reviewId: number; mediaIndex: number } | null>(null);
|
|
86
|
+
const [sortOpen, setSortOpen] = useState(false);
|
|
87
|
+
|
|
88
|
+
const ctx = useMemo<WidgetContextValue>(
|
|
89
|
+
() => ({ client, voteStore, theme, styles, emit, onWriteReview }),
|
|
90
|
+
[client, voteStore, theme, styles, emit, onWriteReview],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const onOpenPhoto = (reviewId: number, mediaIndex: number) => setViewer({ reviewId, mediaIndex });
|
|
94
|
+
|
|
95
|
+
// Flatten reviews[].media → gallery photos (images, deduped by URL, capped).
|
|
96
|
+
const galleryPhotos = useMemo<GalleryPhoto[]>(() => {
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
const out: GalleryPhoto[] = [];
|
|
99
|
+
for (const r of list.reviews) {
|
|
100
|
+
const imgs = r.media.filter((m) => m.type === 'image');
|
|
101
|
+
imgs.forEach((m, i) => {
|
|
102
|
+
const key = m.full || m.thumbnail;
|
|
103
|
+
if (!key || seen.has(key)) return;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
out.push({ thumbnail: m.thumbnail, full: m.full, reviewId: r.id, mediaIndex: i });
|
|
106
|
+
});
|
|
107
|
+
if (out.length >= 24) break;
|
|
108
|
+
}
|
|
109
|
+
return out.slice(0, 24);
|
|
110
|
+
}, [list.reviews]);
|
|
111
|
+
|
|
112
|
+
const reviewsWithMedia = useMemo<WidgetReview[]>(
|
|
113
|
+
() => list.reviews.filter((r) => r.media.some((m) => m.type === 'image')),
|
|
114
|
+
[list.reviews],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const header = (
|
|
118
|
+
<View>
|
|
119
|
+
<SectionTitle>Customer Reviews</SectionTitle>
|
|
120
|
+
{summary && (
|
|
121
|
+
<View style={styles.pulse}>
|
|
122
|
+
<PulseSummary summary={summary} />
|
|
123
|
+
<MediaGallery photos={galleryPhotos} onOpen={onOpenPhoto} />
|
|
124
|
+
<WriteReviewBtn />
|
|
125
|
+
<PulseMetrics summary={summary} />
|
|
126
|
+
</View>
|
|
127
|
+
)}
|
|
128
|
+
<FilterToolbar
|
|
129
|
+
rating={list.rating}
|
|
130
|
+
mediaOnly={list.mediaOnly}
|
|
131
|
+
search={list.searchInput}
|
|
132
|
+
onChange={list.onFilterChange}
|
|
133
|
+
/>
|
|
134
|
+
<SortBar sort={list.sort} onOpenSort={() => setSortOpen(true)} />
|
|
135
|
+
<StaleListOverlay active={list.refetching} />
|
|
136
|
+
</View>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Initial spinner.
|
|
140
|
+
if (list.loading && list.reviews.length === 0) {
|
|
141
|
+
return (
|
|
142
|
+
<WidgetProvider value={ctx}>
|
|
143
|
+
<View style={styles.center}>
|
|
144
|
+
<ActivityIndicator color={theme.accent} />
|
|
145
|
+
<Text style={styles.muted}>Loading reviews…</Text>
|
|
146
|
+
</View>
|
|
147
|
+
</WidgetProvider>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (list.error && list.reviews.length === 0) {
|
|
152
|
+
return (
|
|
153
|
+
<WidgetProvider value={ctx}>
|
|
154
|
+
<View style={styles.center}>
|
|
155
|
+
<Text style={styles.errorLine}>Reviews unavailable</Text>
|
|
156
|
+
<Pressable onPress={list.reload} accessibilityHint="Tap to try loading reviews again">
|
|
157
|
+
<Text style={styles.retryLink}>Try again</Text>
|
|
158
|
+
</Pressable>
|
|
159
|
+
</View>
|
|
160
|
+
</WidgetProvider>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// total>0 but nothing rendered ⇒ rows failed to parse (contract break),
|
|
165
|
+
// distinct from a genuinely empty/filtered product.
|
|
166
|
+
const allDropped = list.reviews.length === 0 && (list.pagination?.total ?? 0) > 0;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<WidgetProvider value={ctx}>
|
|
170
|
+
{/* Inline — no scroll container. The host's product-page ScrollView owns
|
|
171
|
+
the scroll; this is one section that flows with the page. */}
|
|
172
|
+
<View style={styles.list}>
|
|
173
|
+
{header}
|
|
174
|
+
{list.reviews.length === 0 ? (
|
|
175
|
+
<View style={styles.emptyState}>
|
|
176
|
+
<Text style={styles.emptyStateText}>
|
|
177
|
+
{allDropped ? 'Reviews could not be displayed.' : 'No reviews match your filter.'}
|
|
178
|
+
</Text>
|
|
179
|
+
{!allDropped && (
|
|
180
|
+
<Pressable onPress={() => list.onFilterChange({ rating: null, mediaOnly: false, search: '' })}>
|
|
181
|
+
<Text style={styles.emptyStateClear}>Clear filters</Text>
|
|
182
|
+
</Pressable>
|
|
183
|
+
)}
|
|
184
|
+
</View>
|
|
185
|
+
) : (
|
|
186
|
+
list.reviews.map((item) => (
|
|
187
|
+
<View key={item.id} style={list.refetching ? { opacity: 0.5 } : undefined}>
|
|
188
|
+
<ReviewCard review={item} onOpenPhoto={onOpenPhoto} />
|
|
189
|
+
</View>
|
|
190
|
+
))
|
|
191
|
+
)}
|
|
192
|
+
{list.pagination?.has_next && (
|
|
193
|
+
<View style={styles.footerWrap}>
|
|
194
|
+
<Pressable
|
|
195
|
+
onPress={list.onLoadMore}
|
|
196
|
+
disabled={list.loadingMore}
|
|
197
|
+
style={({ pressed }: { pressed: boolean }) => [
|
|
198
|
+
styles.showMore,
|
|
199
|
+
list.loadingMore && styles.showMoreDisabled,
|
|
200
|
+
pressed && { borderColor: theme.text },
|
|
201
|
+
]}
|
|
202
|
+
accessibilityRole="button"
|
|
203
|
+
accessibilityLabel="Load more reviews"
|
|
204
|
+
>
|
|
205
|
+
{list.loadingMore ? (
|
|
206
|
+
<ActivityIndicator color={theme.text} />
|
|
207
|
+
) : (
|
|
208
|
+
<Text style={styles.showMoreText}>Load more</Text>
|
|
209
|
+
)}
|
|
210
|
+
</Pressable>
|
|
211
|
+
</View>
|
|
212
|
+
)}
|
|
213
|
+
</View>
|
|
214
|
+
<SortDrawer
|
|
215
|
+
visible={sortOpen}
|
|
216
|
+
current={list.sort}
|
|
217
|
+
onSelect={list.setSort}
|
|
218
|
+
onClose={() => setSortOpen(false)}
|
|
219
|
+
/>
|
|
220
|
+
{viewer && reviewsWithMedia.length > 0 && (
|
|
221
|
+
<MediaReviewViewer
|
|
222
|
+
reviews={reviewsWithMedia}
|
|
223
|
+
initialReviewId={viewer.reviewId}
|
|
224
|
+
initialMediaIndex={viewer.mediaIndex}
|
|
225
|
+
onClose={() => setViewer(null)}
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
</WidgetProvider>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal widget-scoped context. Threads the resolved client, vote-state
|
|
3
|
+
* store, resolved theme, the memoized StyleSheet, the telemetry emitter, and
|
|
4
|
+
* the host's write-review handler down to deeply-nested children (ReviewCard,
|
|
5
|
+
* ViewerPage) without prop-drilling through FlatList `renderItem`.
|
|
6
|
+
*
|
|
7
|
+
* This does NOT replace `BetterReviewsProvider` — it is mounted internally at
|
|
8
|
+
* the top of `<ReviewWidget>`.
|
|
9
|
+
*/
|
|
10
|
+
import React, { createContext, useContext } from 'react';
|
|
11
|
+
import type { BetterReviewsClient, VoteStateStore } from '../client/types.js';
|
|
12
|
+
import type { TelemetryHandler } from '../telemetry.js';
|
|
13
|
+
import type { WidgetTheme } from '../theme/widgetTheme.js';
|
|
14
|
+
import type { WidgetStyles } from './styles.js';
|
|
15
|
+
|
|
16
|
+
export interface WidgetContextValue {
|
|
17
|
+
client: BetterReviewsClient;
|
|
18
|
+
voteStore: VoteStateStore;
|
|
19
|
+
theme: WidgetTheme;
|
|
20
|
+
styles: WidgetStyles;
|
|
21
|
+
emit: TelemetryHandler;
|
|
22
|
+
onWriteReview?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const WidgetContext = createContext<WidgetContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
export function WidgetProvider({
|
|
28
|
+
value,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
value: WidgetContextValue;
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}) {
|
|
34
|
+
return <WidgetContext.Provider value={value}>{children}</WidgetContext.Provider>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useWidget(): WidgetContextValue {
|
|
38
|
+
const ctx = useContext(WidgetContext);
|
|
39
|
+
if (!ctx) {
|
|
40
|
+
throw new Error('useWidget must be used within <ReviewWidget>');
|
|
41
|
+
}
|
|
42
|
+
return ctx;
|
|
43
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
IconArrowUpDown,
|
|
5
|
+
IconCamera,
|
|
6
|
+
IconChevronDown,
|
|
7
|
+
IconSearch,
|
|
8
|
+
IconStar,
|
|
9
|
+
IconXClose,
|
|
10
|
+
} from '../../icons/BRIcons.js';
|
|
11
|
+
import type { WidgetSortValue } from '../../client/types.js';
|
|
12
|
+
import { useWidget } from '../WidgetContext.js';
|
|
13
|
+
import { SORT_OPTIONS } from '../util.js';
|
|
14
|
+
|
|
15
|
+
export interface FilterPatch {
|
|
16
|
+
rating?: 1 | 2 | 3 | 4 | 5 | null;
|
|
17
|
+
mediaOnly?: boolean;
|
|
18
|
+
search?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FilterToolbarProps {
|
|
22
|
+
rating: 1 | 2 | 3 | 4 | 5 | null;
|
|
23
|
+
mediaOnly: boolean;
|
|
24
|
+
search: string;
|
|
25
|
+
onChange: (patch: FilterPatch) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function FilterToolbar({ rating, mediaOnly, search, onChange }: FilterToolbarProps) {
|
|
29
|
+
const { styles, theme } = useWidget();
|
|
30
|
+
const ratings: Array<1 | 2 | 3 | 4 | 5> = [5, 4, 3, 2, 1];
|
|
31
|
+
const showClear = !!(rating || mediaOnly || search);
|
|
32
|
+
return (
|
|
33
|
+
<View style={styles.toolbar}>
|
|
34
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.pillsRow}>
|
|
35
|
+
{ratings.map((r) => {
|
|
36
|
+
const active = rating === r;
|
|
37
|
+
return (
|
|
38
|
+
<Pressable
|
|
39
|
+
key={r}
|
|
40
|
+
onPress={() => onChange({ rating: active ? null : r })}
|
|
41
|
+
style={({ pressed }: { pressed: boolean }) => [
|
|
42
|
+
styles.pill,
|
|
43
|
+
pressed && !active && { borderColor: theme.text },
|
|
44
|
+
active && styles.pillActive,
|
|
45
|
+
]}
|
|
46
|
+
accessibilityRole="button"
|
|
47
|
+
accessibilityState={{ selected: active }}
|
|
48
|
+
accessibilityLabel={`${r} stars filter`}
|
|
49
|
+
>
|
|
50
|
+
<Text style={[styles.pillText, active && styles.pillTextActive]}>{r}</Text>
|
|
51
|
+
<IconStar
|
|
52
|
+
size={10}
|
|
53
|
+
fillPercentage={100}
|
|
54
|
+
fillColor={active ? theme.bg : theme.star}
|
|
55
|
+
uid={`pill-${r}`}
|
|
56
|
+
/>
|
|
57
|
+
</Pressable>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
<Pressable
|
|
61
|
+
onPress={() => onChange({ mediaOnly: !mediaOnly })}
|
|
62
|
+
style={({ pressed }: { pressed: boolean }) => [
|
|
63
|
+
styles.pill,
|
|
64
|
+
styles.pillWithIcon,
|
|
65
|
+
pressed && !mediaOnly && { borderColor: theme.text },
|
|
66
|
+
mediaOnly && styles.pillActive,
|
|
67
|
+
]}
|
|
68
|
+
accessibilityRole="button"
|
|
69
|
+
accessibilityState={{ selected: mediaOnly }}
|
|
70
|
+
accessibilityLabel="Show only reviews with photos"
|
|
71
|
+
>
|
|
72
|
+
<IconCamera size={12} color={mediaOnly ? theme.bg : theme.text} />
|
|
73
|
+
<Text style={[styles.pillText, mediaOnly && styles.pillTextActive]}>Photos</Text>
|
|
74
|
+
</Pressable>
|
|
75
|
+
{showClear && (
|
|
76
|
+
<Pressable
|
|
77
|
+
onPress={() => onChange({ rating: null, mediaOnly: false, search: '' })}
|
|
78
|
+
style={({ pressed }: { pressed: boolean }) => [styles.clearBtn, pressed && { opacity: 0.6 }]}
|
|
79
|
+
>
|
|
80
|
+
<IconXClose size={12} color={theme.mutedFg} />
|
|
81
|
+
<Text style={styles.clearBtnText}>Clear</Text>
|
|
82
|
+
</Pressable>
|
|
83
|
+
)}
|
|
84
|
+
</ScrollView>
|
|
85
|
+
<View style={styles.searchWrap}>
|
|
86
|
+
<IconSearch size={14} color={theme.mutedFg} style={{ marginRight: 8 }} />
|
|
87
|
+
<TextInput
|
|
88
|
+
style={styles.searchInput}
|
|
89
|
+
placeholder="Search reviews..."
|
|
90
|
+
placeholderTextColor={theme.mutedFg}
|
|
91
|
+
value={search}
|
|
92
|
+
onChangeText={(t) => onChange({ search: t })}
|
|
93
|
+
maxLength={100}
|
|
94
|
+
autoCorrect={false}
|
|
95
|
+
autoCapitalize="none"
|
|
96
|
+
returnKeyType="search"
|
|
97
|
+
/>
|
|
98
|
+
{!!search && (
|
|
99
|
+
<Pressable
|
|
100
|
+
onPress={() => onChange({ search: '' })}
|
|
101
|
+
hitSlop={8}
|
|
102
|
+
style={({ pressed }: { pressed: boolean }) => [styles.searchClear, pressed && { opacity: 0.5 }]}
|
|
103
|
+
accessibilityLabel="Clear search"
|
|
104
|
+
>
|
|
105
|
+
<IconXClose size={14} color={theme.mutedFg} />
|
|
106
|
+
</Pressable>
|
|
107
|
+
)}
|
|
108
|
+
</View>
|
|
109
|
+
{search.length > 0 && search.length < 2 && <Text style={styles.searchHint}>Keep typing…</Text>}
|
|
110
|
+
</View>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function SortBar({ sort, onOpenSort }: { sort: WidgetSortValue; onOpenSort: () => void }) {
|
|
115
|
+
const { styles, theme } = useWidget();
|
|
116
|
+
const label = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Most relevant';
|
|
117
|
+
return (
|
|
118
|
+
<Pressable
|
|
119
|
+
onPress={onOpenSort}
|
|
120
|
+
style={({ pressed }: { pressed: boolean }) => [styles.sortBtn, pressed && { borderColor: theme.text }]}
|
|
121
|
+
accessibilityRole="button"
|
|
122
|
+
accessibilityLabel={`Sort: ${label}. Tap to change.`}
|
|
123
|
+
>
|
|
124
|
+
<IconArrowUpDown size={13} color={theme.text} />
|
|
125
|
+
<Text style={styles.sortBtnText}>{label}</Text>
|
|
126
|
+
<IconChevronDown size={12} color={theme.text} />
|
|
127
|
+
</Pressable>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Solid pill CTA. Renders only when the host supplies `onWriteReview` — no
|
|
132
|
+
// dead button if the host hasn't wired the write-review action.
|
|
133
|
+
export function WriteReviewBtn({ label = 'Write a review' }: { label?: string }) {
|
|
134
|
+
const { styles, onWriteReview } = useWidget();
|
|
135
|
+
if (!onWriteReview) return null;
|
|
136
|
+
return (
|
|
137
|
+
<Pressable
|
|
138
|
+
onPress={onWriteReview}
|
|
139
|
+
style={({ pressed }: { pressed: boolean }) => [styles.writeReviewBtn, pressed && { opacity: 0.85 }]}
|
|
140
|
+
accessibilityRole="button"
|
|
141
|
+
accessibilityLabel={label}
|
|
142
|
+
>
|
|
143
|
+
<Text style={styles.writeReviewBtnText}>{label}</Text>
|
|
144
|
+
</Pressable>
|
|
145
|
+
);
|
|
146
|
+
}
|