@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,68 @@
1
+ /**
2
+ * Helpful/unhelpful voting for one review. Optimistic update with rollback on
3
+ * any failure (network, 422 nothing-to-undo, 404). The POST is fire-once — no
4
+ * retry (a retried non-idempotent vote double-counts). The server's returned
5
+ * counts are authoritative and reconcile the optimistic guess.
6
+ */
7
+ import { useCallback, useRef, useState } from 'react';
8
+ import type { VoteType, WidgetReview } from '../../client/types.js';
9
+ import { useWidget } from '../WidgetContext.js';
10
+
11
+ export interface VoteState {
12
+ vote: VoteType | null;
13
+ helpfulCount: number;
14
+ unhelpfulCount: number;
15
+ press: (type: VoteType) => void;
16
+ }
17
+
18
+ export function useVote(review: WidgetReview): VoteState {
19
+ const { client, voteStore, emit } = useWidget();
20
+ const [vote, setVote] = useState<VoteType | null>(() => voteStore.get(review.id) ?? null);
21
+ const [helpfulCount, setHelpful] = useState(review.helpful_count);
22
+ const [unhelpfulCount, setUnhelpful] = useState(review.unhelpful_count);
23
+ const pendingRef = useRef(false);
24
+
25
+ const press = useCallback(
26
+ (type: VoteType) => {
27
+ if (pendingRef.current) return;
28
+ // The server vote counters have no per-user tracking — a "switch" would
29
+ // just +1 the other side and leave the first vote standing (double count,
30
+ // and a "you voted" indicator that lies). So this is single-selection:
31
+ // you can vote one side, and the only follow-up is undoing THAT side. To
32
+ // change your mind, undo first (the opposite button is disabled meanwhile).
33
+ if (vote !== null && vote !== type) return;
34
+ const snapshot = { vote, helpfulCount, unhelpfulCount };
35
+ const isUndo = vote === type;
36
+
37
+ // Optimistic: bump the pressed counter, set the new direction.
38
+ setVote(isUndo ? null : type);
39
+ if (type === 'helpful') setHelpful((n) => n + (isUndo ? -1 : 1));
40
+ else setUnhelpful((n) => n + (isUndo ? -1 : 1));
41
+
42
+ pendingRef.current = true;
43
+ client
44
+ .voteReview(review.id, type, isUndo)
45
+ .then((counts) => {
46
+ voteStore.set(review.id, isUndo ? null : type);
47
+ // Server counts are authoritative (reconciles e.g. switching sides).
48
+ if (counts) {
49
+ setHelpful(counts.helpful_count);
50
+ setUnhelpful(counts.unhelpful_count);
51
+ }
52
+ })
53
+ .catch(() => {
54
+ // Roll back to the pre-press snapshot (covers non-2xx incl. 422/404).
55
+ setVote(snapshot.vote);
56
+ setHelpful(snapshot.helpfulCount);
57
+ setUnhelpful(snapshot.unhelpfulCount);
58
+ emit({ type: 'betterreviews.fetch.failure', error_code: 'vote_failed' });
59
+ })
60
+ .finally(() => {
61
+ pendingRef.current = false;
62
+ });
63
+ },
64
+ [vote, helpfulCount, unhelpfulCount, review.id, client, voteStore, emit],
65
+ );
66
+
67
+ return { vote, helpfulCount, unhelpfulCount, press };
68
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Widget StyleSheet — a faithful port of the prototype's single `StyleSheet`
3
+ * (`App.js` L1601-2321), parameterized by the resolved `WidgetTheme` so the
4
+ * merchant theme (bg/text/accent) flows through. The prototype's static
5
+ * `THEME` const became `WidgetTheme` because the SDK resolves theme at
6
+ * runtime; everything else (the fixed neutrals) is unchanged.
7
+ *
8
+ * `1rem = 16px` (parity with the storefront `--br-rem`).
9
+ */
10
+ import { Dimensions, StyleSheet } from 'react-native';
11
+ import type { WidgetTheme } from '../theme/widgetTheme.js';
12
+
13
+ const R = 16;
14
+
15
+ export const SCREEN_W = Dimensions.get('window').width;
16
+ export const SCREEN_H = Dimensions.get('window').height;
17
+ // Media zone ~60% of viewport, card sheet ~40% — matches Loox/Okendo.
18
+ export const MEDIA_ZONE_H = Math.round(SCREEN_H * 0.6);
19
+ export const CARD_ZONE_H = SCREEN_H - MEDIA_ZONE_H;
20
+
21
+ export type WidgetStyles = ReturnType<typeof makeWidgetStyles>;
22
+
23
+ export function makeWidgetStyles(t: WidgetTheme) {
24
+ return StyleSheet.create({
25
+ root: { flex: 1, backgroundColor: t.bg },
26
+ list: { paddingHorizontal: R, paddingBottom: R * 1.5 },
27
+ center: { padding: R * 2, alignItems: 'center', justifyContent: 'center' },
28
+ muted: { color: t.mutedFg, marginTop: 8, fontSize: 14 },
29
+ errorLine: { color: t.mutedFg, fontSize: 14, marginBottom: 8 },
30
+ retryLink: { color: t.accent, fontSize: 14, textDecorationLine: 'underline' },
31
+
32
+ sectionTitle: {
33
+ fontSize: R * 1.5,
34
+ fontWeight: '700',
35
+ color: t.text,
36
+ marginTop: R,
37
+ marginBottom: R * 0.75,
38
+ },
39
+
40
+ pulse: { paddingTop: R * 0.5, marginBottom: R },
41
+ pulseSummary: { flexDirection: 'column', gap: R * 0.75 },
42
+ pulseRating: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: R * 0.5 },
43
+ pulseScore: {
44
+ fontSize: R * 2.5,
45
+ fontWeight: '700',
46
+ color: t.text,
47
+ letterSpacing: -1,
48
+ marginRight: R * 0.25,
49
+ lineHeight: R * 2.5,
50
+ },
51
+ pulseCount: { fontSize: 13, color: t.mutedFg },
52
+ pulseBars: { gap: R * 0.375, marginTop: R * 0.25 },
53
+ barRow: { flexDirection: 'row', alignItems: 'center', gap: R * 0.5 },
54
+ barLabel: { width: R * 1.5, flexDirection: 'row', alignItems: 'center', gap: 2 },
55
+ barLabelText: { fontSize: 13, color: t.text },
56
+ barTrack: { flex: 1, height: 8, backgroundColor: t.muted, borderRadius: 4, overflow: 'hidden' },
57
+ barFill: { height: '100%', backgroundColor: t.star, borderRadius: 4 },
58
+ barCount: { width: R * 2, textAlign: 'right', fontSize: 12, color: t.mutedFg },
59
+
60
+ mediaGallery: { marginTop: R },
61
+ mediaGalleryLabel: {
62
+ fontSize: 12,
63
+ fontWeight: '600',
64
+ color: t.mutedFg,
65
+ textTransform: 'uppercase',
66
+ letterSpacing: 0.6,
67
+ marginBottom: 8,
68
+ },
69
+ mediaGalleryGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
70
+ galleryTile: {
71
+ width: '31.5%',
72
+ aspectRatio: 1,
73
+ borderRadius: 16,
74
+ borderWidth: 1,
75
+ borderColor: t.border,
76
+ overflow: 'hidden',
77
+ backgroundColor: t.muted,
78
+ },
79
+ galleryTileImg: { width: '100%', height: '100%' },
80
+ galleryOverflow: {
81
+ position: 'absolute',
82
+ top: 0,
83
+ left: 0,
84
+ right: 0,
85
+ bottom: 0,
86
+ backgroundColor: t.overflowScrim,
87
+ alignItems: 'center',
88
+ justifyContent: 'center',
89
+ },
90
+ galleryOverflowText: { color: '#fff', fontSize: 18, fontWeight: '700' },
91
+
92
+ writeReviewBtn: {
93
+ width: '100%',
94
+ paddingHorizontal: R * 1.5,
95
+ paddingVertical: R * 0.625,
96
+ backgroundColor: t.accent,
97
+ borderRadius: 9999,
98
+ alignItems: 'center',
99
+ justifyContent: 'center',
100
+ marginTop: R * 0.75,
101
+ },
102
+ writeReviewBtnText: { color: '#ffffff', fontWeight: '600', fontSize: 14 },
103
+
104
+ metrics: { flexDirection: 'row', gap: R * 0.5, marginTop: R },
105
+ metric: { flex: 1, alignItems: 'center' },
106
+ metricValue: { fontSize: R * 1.25, fontWeight: '700', color: t.text, lineHeight: R * 1.5 },
107
+ metricLabel: {
108
+ fontSize: 11,
109
+ color: t.mutedFg,
110
+ textTransform: 'uppercase',
111
+ letterSpacing: 0.5,
112
+ marginTop: 2,
113
+ },
114
+
115
+ toolbar: {
116
+ paddingTop: R * 0.75,
117
+ paddingBottom: R * 0.75,
118
+ borderBottomWidth: 1,
119
+ borderBottomColor: t.border,
120
+ marginBottom: R * 0.75,
121
+ gap: R * 0.5,
122
+ },
123
+ pillsRow: { flexDirection: 'row', gap: R * 0.375, paddingRight: R, alignItems: 'center' },
124
+ pill: {
125
+ paddingHorizontal: R * 0.75,
126
+ paddingVertical: R * 0.375,
127
+ borderRadius: 9999,
128
+ borderWidth: 1,
129
+ borderColor: t.border,
130
+ backgroundColor: t.bg,
131
+ flexDirection: 'row',
132
+ alignItems: 'center',
133
+ gap: 4,
134
+ },
135
+ pillWithIcon: { paddingHorizontal: R * 0.625 },
136
+ pillActive: { backgroundColor: t.text, borderColor: t.text },
137
+ pillText: { color: t.text, fontSize: 13, fontWeight: '500' },
138
+ pillTextActive: { color: t.bg },
139
+ clearBtn: {
140
+ paddingHorizontal: R * 0.5,
141
+ paddingVertical: R * 0.375,
142
+ flexDirection: 'row',
143
+ alignItems: 'center',
144
+ gap: 4,
145
+ },
146
+ clearBtnText: { color: t.mutedFg, fontSize: 13, fontWeight: '500' },
147
+
148
+ searchWrap: {
149
+ flexDirection: 'row',
150
+ alignItems: 'center',
151
+ borderWidth: 1,
152
+ borderColor: t.border,
153
+ borderRadius: 9999,
154
+ backgroundColor: t.bg,
155
+ paddingLeft: R * 0.75,
156
+ paddingRight: R * 0.5,
157
+ height: 36,
158
+ },
159
+ searchInput: { flex: 1, fontSize: 13, color: t.text, paddingVertical: 0 },
160
+ searchClear: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center' },
161
+ searchHint: { fontSize: 12, color: t.mutedFg, paddingLeft: R * 0.25, paddingTop: 4 },
162
+
163
+ sortBtn: {
164
+ flexDirection: 'row',
165
+ alignItems: 'center',
166
+ justifyContent: 'center',
167
+ alignSelf: 'flex-start',
168
+ paddingHorizontal: R * 0.75,
169
+ paddingVertical: R * 0.375,
170
+ borderRadius: 9999,
171
+ borderWidth: 1,
172
+ borderColor: t.border,
173
+ backgroundColor: t.bg,
174
+ marginBottom: R * 0.5,
175
+ gap: R * 0.375,
176
+ },
177
+ sortBtnText: { color: t.text, fontSize: 13, fontWeight: '500' },
178
+
179
+ // 2px top progress bar — faint neutral track (the fill uses the accent).
180
+ progressTrack: {
181
+ height: 2,
182
+ width: '100%',
183
+ backgroundColor: 'rgba(0, 0, 0, 0.06)',
184
+ overflow: 'hidden',
185
+ marginBottom: -2,
186
+ },
187
+ progressFill: { width: '30%', height: '100%', backgroundColor: t.accent, borderRadius: 2 },
188
+
189
+ card: { paddingVertical: R * 1.25, borderBottomWidth: 1, borderBottomColor: t.border },
190
+ cardHeader: { flexDirection: 'row', alignItems: 'flex-start', gap: R * 0.75, marginBottom: R * 0.5 },
191
+ avatar: {
192
+ width: 40,
193
+ height: 40,
194
+ borderRadius: 20,
195
+ backgroundColor: t.muted,
196
+ alignItems: 'center',
197
+ justifyContent: 'center',
198
+ },
199
+ avatarText: { fontWeight: '600', fontSize: 14, color: t.mutedFg, textTransform: 'uppercase' },
200
+ cardInfo: { flex: 1, minWidth: 0 },
201
+ cardTopRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: R * 0.5 },
202
+ cardAuthor: { fontWeight: '600', fontSize: 15, color: t.text, flexShrink: 1 },
203
+ cardDate: { fontSize: 13, color: t.mutedFg },
204
+ cardRatingRow: { flexDirection: 'row', alignItems: 'center', gap: R * 0.5, marginTop: 2 },
205
+ verifiedBadge: {
206
+ flexDirection: 'row',
207
+ alignItems: 'center',
208
+ gap: 4,
209
+ paddingHorizontal: R * 0.5,
210
+ paddingVertical: 2,
211
+ borderRadius: 9999,
212
+ backgroundColor: t.verifiedBg,
213
+ },
214
+ verifiedBadgeText: { fontSize: 11, color: t.verified, fontWeight: '500' },
215
+ cardTitle: { fontSize: 15, fontWeight: '700', color: t.text, marginTop: R * 0.5, marginBottom: R * 0.375 },
216
+ cardBody: { fontSize: 15, lineHeight: 15 * 1.7, color: t.text },
217
+ readMoreBtn: { paddingVertical: 4, marginTop: 2, alignSelf: 'flex-start' },
218
+ readMoreBtnText: { fontSize: 13, color: t.accent, fontWeight: '500', textDecorationLine: 'underline' },
219
+ readMoreErr: { fontSize: 12, color: t.mutedFg, marginTop: 2 },
220
+ cardMedia: { marginTop: R * 0.75 },
221
+ mediaThumb: {
222
+ width: 64,
223
+ height: 64,
224
+ borderRadius: R,
225
+ borderWidth: 1,
226
+ borderColor: t.border,
227
+ overflow: 'hidden',
228
+ backgroundColor: t.muted,
229
+ },
230
+ mediaThumbImg: { width: '100%', height: '100%' },
231
+ tagsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: R * 0.375, marginTop: R * 0.75 },
232
+ tag: {
233
+ paddingHorizontal: R * 0.5,
234
+ paddingVertical: 2,
235
+ borderRadius: 9999,
236
+ borderWidth: 1,
237
+ borderColor: t.border,
238
+ backgroundColor: t.muted,
239
+ },
240
+ tagText: { fontSize: 12, color: t.mutedFg },
241
+ actionsRow: { flexDirection: 'row', gap: R, marginTop: R * 0.75 },
242
+ actionBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: R * 0.25 },
243
+ actionBtnText: { fontSize: 13, color: t.mutedFg },
244
+ actionBtnTextActive: { color: t.accent, fontWeight: '600' },
245
+
246
+ merchantReply: {
247
+ marginTop: R * 0.75,
248
+ padding: R,
249
+ borderRadius: R,
250
+ backgroundColor: t.muted,
251
+ borderWidth: 1,
252
+ borderColor: t.border,
253
+ },
254
+ merchantReplyHeader: { flexDirection: 'row', alignItems: 'baseline', gap: R * 0.5, marginBottom: R * 0.25 },
255
+ merchantReplyAuthor: { fontWeight: '600', fontSize: 13, color: t.text },
256
+ merchantReplyDate: { fontSize: 12, color: t.mutedFg },
257
+ merchantReplyBody: { fontSize: 14, lineHeight: 22, color: t.mutedFg },
258
+
259
+ footerWrap: { alignItems: 'center', paddingVertical: R },
260
+ showMore: {
261
+ paddingHorizontal: R * 2,
262
+ paddingVertical: R * 0.625,
263
+ borderRadius: 9999,
264
+ borderWidth: 1,
265
+ borderColor: t.border,
266
+ backgroundColor: 'transparent',
267
+ },
268
+ showMoreDisabled: { opacity: 0.5 },
269
+ showMoreText: { fontSize: 14, color: t.text },
270
+
271
+ emptyState: {
272
+ alignItems: 'center',
273
+ padding: R * 2,
274
+ borderRadius: R,
275
+ borderWidth: 1,
276
+ borderColor: t.border,
277
+ marginVertical: R,
278
+ },
279
+ emptyStateText: { fontSize: 15, color: t.mutedFg, marginBottom: R * 0.5 },
280
+ emptyStateClear: { fontSize: 14, color: t.text, textDecorationLine: 'underline' },
281
+
282
+ modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' },
283
+ drawer: {
284
+ backgroundColor: t.bg,
285
+ borderTopLeftRadius: R,
286
+ borderTopRightRadius: R,
287
+ paddingBottom: R * 1.5,
288
+ paddingTop: R * 0.75,
289
+ },
290
+ drawerHandle: {
291
+ alignSelf: 'center',
292
+ width: 36,
293
+ height: 4,
294
+ borderRadius: 2,
295
+ backgroundColor: t.border,
296
+ marginBottom: R,
297
+ },
298
+ drawerTitle: {
299
+ paddingHorizontal: R,
300
+ paddingBottom: R * 0.75,
301
+ fontWeight: '600',
302
+ fontSize: 16,
303
+ color: t.text,
304
+ borderBottomWidth: 1,
305
+ borderBottomColor: t.border,
306
+ },
307
+ drawerOption: {
308
+ flexDirection: 'row',
309
+ alignItems: 'center',
310
+ justifyContent: 'space-between',
311
+ paddingHorizontal: R,
312
+ paddingVertical: R * 0.75,
313
+ },
314
+ drawerOptionText: { fontSize: 14, color: t.text },
315
+ drawerOptionTextActive: { fontWeight: '600' },
316
+ drawerActiveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: t.accent },
317
+
318
+ viewerRoot: { flex: 1, backgroundColor: '#000' },
319
+ viewerClose: {
320
+ position: 'absolute',
321
+ top: 50,
322
+ right: 20,
323
+ zIndex: 20,
324
+ backgroundColor: 'rgba(0,0,0,0.45)',
325
+ width: 36,
326
+ height: 36,
327
+ borderRadius: 18,
328
+ alignItems: 'center',
329
+ justifyContent: 'center',
330
+ },
331
+ viewerMediaZone: { width: '100%', height: MEDIA_ZONE_H, backgroundColor: '#000' },
332
+ viewerMediaPage: { height: MEDIA_ZONE_H, alignItems: 'center', justifyContent: 'center' },
333
+ viewerImage: { width: '100%', height: '100%' },
334
+ viewerTopOverlay: {
335
+ position: 'absolute',
336
+ top: 50,
337
+ left: 16,
338
+ right: 64,
339
+ flexDirection: 'row',
340
+ alignItems: 'center',
341
+ justifyContent: 'space-between',
342
+ gap: 12,
343
+ },
344
+ viewerDots: {
345
+ flexDirection: 'row',
346
+ alignItems: 'center',
347
+ gap: 4,
348
+ backgroundColor: 'rgba(0,0,0,0.35)',
349
+ paddingHorizontal: 8,
350
+ paddingVertical: 5,
351
+ borderRadius: 9999,
352
+ },
353
+ viewerDot: { width: 5, height: 5, borderRadius: 3, backgroundColor: 'rgba(255,255,255,0.5)' },
354
+ viewerDotActive: { backgroundColor: '#fff', width: 7, height: 7, borderRadius: 4 },
355
+ viewerPosition: {
356
+ backgroundColor: 'rgba(0,0,0,0.35)',
357
+ paddingHorizontal: 10,
358
+ paddingVertical: 4,
359
+ borderRadius: 9999,
360
+ },
361
+ viewerPositionText: { color: '#fff', fontSize: 12, fontWeight: '500' },
362
+ viewerCard: {
363
+ height: CARD_ZONE_H,
364
+ backgroundColor: t.bg,
365
+ borderTopLeftRadius: R * 1.25,
366
+ borderTopRightRadius: R * 1.25,
367
+ paddingTop: 0,
368
+ },
369
+ viewerHandleHit: { alignItems: 'center', paddingTop: 8, paddingBottom: 6 },
370
+ viewerHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: t.border },
371
+ viewerCardScroll: { flex: 1 },
372
+ viewerCardContent: { paddingHorizontal: R, paddingTop: R * 0.5, paddingBottom: R * 1.5 },
373
+ viewerCardHeader: { flexDirection: 'row', alignItems: 'flex-start', gap: R * 0.75, marginBottom: R * 0.5 },
374
+ viewerAvatar: {
375
+ width: 36,
376
+ height: 36,
377
+ borderRadius: 18,
378
+ backgroundColor: t.muted,
379
+ alignItems: 'center',
380
+ justifyContent: 'center',
381
+ },
382
+ viewerAvatarText: { fontWeight: '600', fontSize: 13, color: t.mutedFg, textTransform: 'uppercase' },
383
+ viewerHeaderTopRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: R * 0.5 },
384
+ viewerAuthor: { fontWeight: '600', fontSize: 15, color: t.text, flexShrink: 1 },
385
+ viewerDate: { fontSize: 13, color: t.mutedFg },
386
+ viewerStarsRow: { flexDirection: 'row', alignItems: 'center', gap: R * 0.5, marginTop: 2 },
387
+ viewerTitle: { fontSize: 15, fontWeight: '700', color: t.text, marginTop: R * 0.25, marginBottom: R * 0.375 },
388
+ viewerBody: { fontSize: 14, lineHeight: 14 * 1.6, color: t.text },
389
+ viewerActionsRow: { flexDirection: 'row', gap: R, marginTop: R * 0.75 },
390
+ viewerReplyToggle: { marginTop: R * 0.5, paddingVertical: 6, alignSelf: 'flex-start' },
391
+ viewerReplyToggleText: { fontSize: 13, color: t.accent, fontWeight: '500' },
392
+ });
393
+ }
@@ -0,0 +1,21 @@
1
+ import type { WidgetSortValue } from '../client/types.js';
2
+
3
+ export interface SortOption {
4
+ value: WidgetSortValue;
5
+ label: string;
6
+ }
7
+
8
+ // Matches `validated_sort/1` in `widget.ex` + `PPO.Reviews.Sort`.
9
+ export const SORT_OPTIONS: SortOption[] = [
10
+ { value: 'most_relevant', label: 'Most relevant' },
11
+ { value: 'most_helpful', label: 'Most helpful' },
12
+ { value: 'newest', label: 'Newest' },
13
+ { value: 'highest', label: 'Highest rated' },
14
+ { value: 'lowest', label: 'Lowest rated' },
15
+ ];
16
+
17
+ export function getInitials(name: string | null | undefined): string {
18
+ if (!name) return '?';
19
+ const parts = name.trim().split(/\s+/);
20
+ return ((parts[0]?.[0] || '') + (parts[1]?.[0] || '')).toUpperCase() || '?';
21
+ }