@revova/hydrogen 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/dist/index.js ADDED
@@ -0,0 +1,1533 @@
1
+ // src/components/ReviewWidget.tsx
2
+ import { useState as useState4 } from "react";
3
+
4
+ // src/hooks/useReviews.ts
5
+ import { useEffect, useState, useCallback } from "react";
6
+ function useReviews({
7
+ proxyUrl,
8
+ productId,
9
+ page: initialPage = 1,
10
+ limit = 10,
11
+ sort: initialSort = "recent",
12
+ locale
13
+ }) {
14
+ const [page, setPage] = useState(initialPage);
15
+ const [sort, setSort] = useState(initialSort);
16
+ const [state, setState] = useState({
17
+ data: null,
18
+ loading: true,
19
+ error: null
20
+ });
21
+ const [tick, setTick] = useState(0);
22
+ const refetch = useCallback(() => setTick((t) => t + 1), []);
23
+ useEffect(() => {
24
+ let cancelled = false;
25
+ setState((s) => ({ ...s, loading: true, error: null }));
26
+ const params = new URLSearchParams({
27
+ productId,
28
+ page: String(page),
29
+ limit: String(limit),
30
+ sort,
31
+ ...locale ? { locale } : {}
32
+ });
33
+ fetch(`${proxyUrl}/reviews?${params.toString()}`).then((res) => {
34
+ if (!res.ok) throw new Error(`Revova: reviews fetch failed (${res.status})`);
35
+ return res.json();
36
+ }).then((data) => {
37
+ if (!cancelled) setState({ data, loading: false, error: null });
38
+ }).catch((err) => {
39
+ if (!cancelled)
40
+ setState({ data: null, loading: false, error: err instanceof Error ? err : new Error(String(err)) });
41
+ });
42
+ return () => {
43
+ cancelled = true;
44
+ };
45
+ }, [proxyUrl, productId, page, limit, sort, locale, tick]);
46
+ return {
47
+ ...state,
48
+ refetch,
49
+ setPage,
50
+ setSort,
51
+ currentPage: page,
52
+ currentSort: sort
53
+ };
54
+ }
55
+
56
+ // src/components/StarRating.tsx
57
+ import { jsx, jsxs } from "react/jsx-runtime";
58
+ function StarRating({ rating, max = 5, size = 16, color = "#f59e0b", className }) {
59
+ const stars = Array.from({ length: max }, (_, i) => {
60
+ const fill = Math.min(1, Math.max(0, rating - i));
61
+ return { index: i, fill };
62
+ });
63
+ return /* @__PURE__ */ jsx(
64
+ "span",
65
+ {
66
+ className,
67
+ style: { display: "inline-flex", gap: 2 },
68
+ "aria-label": `${rating} out of ${max} stars`,
69
+ role: "img",
70
+ children: stars.map(({ index, fill }) => /* @__PURE__ */ jsxs(
71
+ "svg",
72
+ {
73
+ width: size,
74
+ height: size,
75
+ viewBox: "0 0 24 24",
76
+ "aria-hidden": "true",
77
+ children: [
78
+ /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsxs("linearGradient", { id: `star-fill-${index}`, x1: "0", x2: "1", y1: "0", y2: "0", children: [
79
+ /* @__PURE__ */ jsx("stop", { offset: `${fill * 100}%`, stopColor: color }),
80
+ /* @__PURE__ */ jsx("stop", { offset: `${fill * 100}%`, stopColor: "#e5e7eb" })
81
+ ] }) }),
82
+ /* @__PURE__ */ jsx(
83
+ "path",
84
+ {
85
+ d: "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",
86
+ fill: `url(#star-fill-${index})`,
87
+ stroke: color,
88
+ strokeWidth: "1"
89
+ }
90
+ )
91
+ ]
92
+ },
93
+ index
94
+ ))
95
+ }
96
+ );
97
+ }
98
+
99
+ // src/components/ReviewForm.tsx
100
+ import { useState as useState3 } from "react";
101
+
102
+ // src/hooks/useSubmitReview.ts
103
+ import { useState as useState2, useCallback as useCallback2 } from "react";
104
+ var INITIAL_STATE = {
105
+ submitting: false,
106
+ success: false,
107
+ error: null,
108
+ result: null
109
+ };
110
+ function useSubmitReview(proxyUrl) {
111
+ const [state, setState] = useState2(INITIAL_STATE);
112
+ const submit = useCallback2(
113
+ async (payload) => {
114
+ setState({ submitting: true, success: false, error: null, result: null });
115
+ try {
116
+ const res = await fetch(`${proxyUrl}/reviews`, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify(payload)
120
+ });
121
+ const json = await res.json();
122
+ if (!res.ok) {
123
+ setState({
124
+ submitting: false,
125
+ success: false,
126
+ error: json.error ?? `Submission failed (${res.status})`,
127
+ result: null
128
+ });
129
+ return;
130
+ }
131
+ setState({ submitting: false, success: true, error: null, result: json });
132
+ } catch (err) {
133
+ setState({
134
+ submitting: false,
135
+ success: false,
136
+ error: err instanceof Error ? err.message : "An unexpected error occurred.",
137
+ result: null
138
+ });
139
+ }
140
+ },
141
+ [proxyUrl]
142
+ );
143
+ const reset = useCallback2(() => setState(INITIAL_STATE), []);
144
+ return { ...state, submit, reset };
145
+ }
146
+
147
+ // src/components/ReviewForm.tsx
148
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
149
+ function cfg(field) {
150
+ return field.config ?? {};
151
+ }
152
+ function AttributeRatingField({ field, value, onChange }) {
153
+ const { max = 5 } = cfg(field);
154
+ const current = Number(value ?? 0);
155
+ return /* @__PURE__ */ jsxs2("div", { children: [
156
+ /* @__PURE__ */ jsx2("label", { children: field.label }),
157
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", gap: 4, alignItems: "center" }, children: [
158
+ Array.from({ length: max }, (_, i) => i + 1).map((val) => /* @__PURE__ */ jsx2(
159
+ "button",
160
+ {
161
+ type: "button",
162
+ onClick: () => onChange(val),
163
+ "aria-label": `${val} out of ${max}`,
164
+ style: { background: "none", border: "none", cursor: "pointer", padding: 2 },
165
+ children: /* @__PURE__ */ jsx2(StarRating, { rating: val <= current ? 1 : 0, size: 22 })
166
+ },
167
+ val
168
+ )),
169
+ current > 0 && /* @__PURE__ */ jsxs2("span", { style: { fontSize: 12, color: "#6b7280", marginLeft: 4 }, children: [
170
+ current,
171
+ "/",
172
+ max
173
+ ] })
174
+ ] })
175
+ ] });
176
+ }
177
+ function SingleSelectField({ field, value, onChange }) {
178
+ const { options = [] } = cfg(field);
179
+ const id = `revova-${field.id}`;
180
+ return /* @__PURE__ */ jsxs2("div", { children: [
181
+ /* @__PURE__ */ jsx2("label", { htmlFor: id, children: field.label }),
182
+ /* @__PURE__ */ jsx2("div", { role: "radiogroup", "aria-labelledby": id, style: { display: "flex", flexDirection: "column", gap: 6 }, children: options.map((opt) => /* @__PURE__ */ jsxs2("label", { style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 14 }, children: [
183
+ /* @__PURE__ */ jsx2(
184
+ "input",
185
+ {
186
+ type: "radio",
187
+ name: id,
188
+ value: opt,
189
+ checked: value === opt,
190
+ onChange: () => onChange(opt),
191
+ required: field.required && !value
192
+ }
193
+ ),
194
+ opt
195
+ ] }, opt)) })
196
+ ] });
197
+ }
198
+ function MultiSelectField({ field, value, onChange }) {
199
+ const { options = [], maxSelections } = cfg(field);
200
+ const selected = Array.isArray(value) ? value : [];
201
+ function toggle(opt) {
202
+ if (selected.includes(opt)) {
203
+ onChange(selected.filter((v) => v !== opt));
204
+ } else if (!maxSelections || selected.length < maxSelections) {
205
+ onChange([...selected, opt]);
206
+ }
207
+ }
208
+ return /* @__PURE__ */ jsxs2("div", { children: [
209
+ /* @__PURE__ */ jsxs2("label", { children: [
210
+ field.label,
211
+ maxSelections && /* @__PURE__ */ jsxs2("span", { style: { fontSize: 12, color: "#6b7280", marginLeft: 6 }, children: [
212
+ "(pick up to ",
213
+ maxSelections,
214
+ ")"
215
+ ] })
216
+ ] }),
217
+ /* @__PURE__ */ jsx2("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: options.map((opt) => {
218
+ const checked = selected.includes(opt);
219
+ const disabled = !checked && !!maxSelections && selected.length >= maxSelections;
220
+ return /* @__PURE__ */ jsxs2("label", { style: { display: "flex", alignItems: "center", gap: 8, cursor: disabled ? "not-allowed" : "pointer", fontSize: 14, opacity: disabled ? 0.5 : 1 }, children: [
221
+ /* @__PURE__ */ jsx2(
222
+ "input",
223
+ {
224
+ type: "checkbox",
225
+ checked,
226
+ disabled,
227
+ onChange: () => toggle(opt)
228
+ }
229
+ ),
230
+ opt
231
+ ] }, opt);
232
+ }) })
233
+ ] });
234
+ }
235
+ function ScaleField({ field, value, onChange }) {
236
+ const { min = 1, max = 10, minLabel, maxLabel } = cfg(field);
237
+ const current = value;
238
+ const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i);
239
+ return /* @__PURE__ */ jsxs2("div", { children: [
240
+ /* @__PURE__ */ jsx2("label", { children: field.label }),
241
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [
242
+ /* @__PURE__ */ jsx2("div", { style: { display: "flex", gap: 4, flexWrap: "wrap" }, children: steps.map((val) => /* @__PURE__ */ jsx2(
243
+ "button",
244
+ {
245
+ type: "button",
246
+ onClick: () => onChange(val),
247
+ "aria-label": String(val),
248
+ "aria-pressed": current === val,
249
+ style: {
250
+ width: 36,
251
+ height: 36,
252
+ border: "1px solid",
253
+ borderColor: current === val ? "#111827" : "#d1d5db",
254
+ borderRadius: 6,
255
+ background: current === val ? "#111827" : "#fff",
256
+ color: current === val ? "#fff" : "#374151",
257
+ cursor: "pointer",
258
+ fontSize: 13,
259
+ fontWeight: current === val ? 600 : 400
260
+ },
261
+ children: val
262
+ },
263
+ val
264
+ )) }),
265
+ (minLabel || maxLabel) && /* @__PURE__ */ jsxs2("div", { style: { display: "flex", justifyContent: "space-between", fontSize: 11, color: "#6b7280" }, children: [
266
+ /* @__PURE__ */ jsx2("span", { children: minLabel }),
267
+ /* @__PURE__ */ jsx2("span", { children: maxLabel })
268
+ ] })
269
+ ] })
270
+ ] });
271
+ }
272
+ function TextShortField({ field, value, onChange }) {
273
+ const { placeholder } = cfg(field);
274
+ const id = `revova-${field.id}`;
275
+ return /* @__PURE__ */ jsxs2("div", { children: [
276
+ /* @__PURE__ */ jsx2("label", { htmlFor: id, children: field.label }),
277
+ /* @__PURE__ */ jsx2(
278
+ "input",
279
+ {
280
+ id,
281
+ type: "text",
282
+ placeholder,
283
+ required: field.required,
284
+ value: String(value ?? ""),
285
+ onChange: (e) => onChange(e.target.value)
286
+ }
287
+ )
288
+ ] });
289
+ }
290
+ function TextLongField({ field, value, onChange }) {
291
+ const { placeholder, minLength } = cfg(field);
292
+ const id = `revova-${field.id}`;
293
+ return /* @__PURE__ */ jsxs2("div", { children: [
294
+ /* @__PURE__ */ jsx2("label", { htmlFor: id, children: field.label }),
295
+ /* @__PURE__ */ jsx2(
296
+ "textarea",
297
+ {
298
+ id,
299
+ rows: 4,
300
+ placeholder,
301
+ required: field.required,
302
+ minLength,
303
+ value: String(value ?? ""),
304
+ onChange: (e) => onChange(e.target.value)
305
+ }
306
+ )
307
+ ] });
308
+ }
309
+ function BooleanField({ field, value, onChange }) {
310
+ const id = `revova-${field.id}`;
311
+ return /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [
312
+ /* @__PURE__ */ jsx2(
313
+ "button",
314
+ {
315
+ id,
316
+ type: "button",
317
+ role: "switch",
318
+ "aria-checked": value === true,
319
+ onClick: () => onChange(value === true ? false : true),
320
+ style: {
321
+ width: 44,
322
+ height: 24,
323
+ borderRadius: 999,
324
+ border: "none",
325
+ background: value === true ? "#111827" : "#d1d5db",
326
+ cursor: "pointer",
327
+ position: "relative",
328
+ transition: "background 0.2s",
329
+ flexShrink: 0
330
+ },
331
+ children: /* @__PURE__ */ jsx2(
332
+ "span",
333
+ {
334
+ style: {
335
+ position: "absolute",
336
+ top: 3,
337
+ left: value === true ? 23 : 3,
338
+ width: 18,
339
+ height: 18,
340
+ borderRadius: "50%",
341
+ background: "#fff",
342
+ transition: "left 0.2s"
343
+ }
344
+ }
345
+ )
346
+ }
347
+ ),
348
+ /* @__PURE__ */ jsx2("label", { htmlFor: id, style: { cursor: "pointer", fontSize: 14 }, children: field.label })
349
+ ] });
350
+ }
351
+ function MediaUploadField({ field, value, onChange }) {
352
+ const { maxFiles = 1, maxSizeMb = 2, allowVideo = false } = cfg(field);
353
+ const files = Array.isArray(value) ? value : [];
354
+ const id = `revova-${field.id}`;
355
+ const accept = allowVideo ? "image/*,video/*" : "image/*";
356
+ function handleChange(e) {
357
+ const selected = Array.from(e.target.files ?? []).slice(0, maxFiles);
358
+ const tooBig = selected.filter((f) => f.size > maxSizeMb * 1024 * 1024);
359
+ if (tooBig.length > 0) {
360
+ alert(`Each file must be under ${maxSizeMb}MB.`);
361
+ return;
362
+ }
363
+ onChange(selected);
364
+ }
365
+ return /* @__PURE__ */ jsxs2("div", { children: [
366
+ /* @__PURE__ */ jsxs2("label", { htmlFor: id, children: [
367
+ field.label,
368
+ /* @__PURE__ */ jsxs2("span", { style: { fontSize: 12, color: "#6b7280", marginLeft: 6 }, children: [
369
+ "(max ",
370
+ maxFiles,
371
+ " file",
372
+ maxFiles > 1 ? "s" : "",
373
+ ", ",
374
+ maxSizeMb,
375
+ "MB each",
376
+ allowVideo ? ", photos & videos" : ", photos only",
377
+ ")"
378
+ ] })
379
+ ] }),
380
+ /* @__PURE__ */ jsx2(
381
+ "input",
382
+ {
383
+ id,
384
+ type: "file",
385
+ accept,
386
+ multiple: maxFiles > 1,
387
+ required: field.required,
388
+ onChange: handleChange
389
+ }
390
+ ),
391
+ files.length > 0 && /* @__PURE__ */ jsx2("ul", { style: { listStyle: "none", padding: 0, margin: "6px 0 0", display: "flex", gap: 6, flexWrap: "wrap" }, children: files.map((file, i) => /* @__PURE__ */ jsx2("li", { style: { fontSize: 12, color: "#374151" }, children: file.name }, i)) })
392
+ ] });
393
+ }
394
+ function FieldRenderer(props) {
395
+ switch (props.field.type) {
396
+ case "ATTRIBUTE_RATING":
397
+ return /* @__PURE__ */ jsx2(AttributeRatingField, { ...props });
398
+ case "SINGLE_SELECT":
399
+ return /* @__PURE__ */ jsx2(SingleSelectField, { ...props });
400
+ case "MULTI_SELECT":
401
+ return /* @__PURE__ */ jsx2(MultiSelectField, { ...props });
402
+ case "SCALE":
403
+ return /* @__PURE__ */ jsx2(ScaleField, { ...props });
404
+ case "TEXT_SHORT":
405
+ return /* @__PURE__ */ jsx2(TextShortField, { ...props });
406
+ case "TEXT_LONG":
407
+ return /* @__PURE__ */ jsx2(TextLongField, { ...props });
408
+ case "BOOLEAN":
409
+ return /* @__PURE__ */ jsx2(BooleanField, { ...props });
410
+ case "MEDIA_UPLOAD":
411
+ return /* @__PURE__ */ jsx2(MediaUploadField, { ...props });
412
+ // VERIFIED_ONLY is a wrapper — render its inner fields inline
413
+ case "VERIFIED_ONLY": {
414
+ const inner = cfg(props.field).fields ?? [];
415
+ return /* @__PURE__ */ jsx2(Fragment, { children: inner.map((f) => /* @__PURE__ */ jsx2(
416
+ FieldRenderer,
417
+ {
418
+ field: f,
419
+ value: props.value,
420
+ onChange: props.onChange
421
+ },
422
+ f.id
423
+ )) });
424
+ }
425
+ default:
426
+ return null;
427
+ }
428
+ }
429
+ function ReviewForm({ proxyUrl, productId, form, onSuccess, className }) {
430
+ const { submit, submitting, success, error, result } = useSubmitReview(proxyUrl);
431
+ const [answers, setAnswers] = useState3({});
432
+ const [email, setEmail] = useState3("");
433
+ const starField = form.fields.find((f) => f.type === "STAR_RATING");
434
+ const titleField = form.fields.find((f) => f.type === "TITLE");
435
+ const descField = form.fields.find((f) => f.type === "DESCRIPTION");
436
+ const customFields = form.fields.filter(
437
+ (f) => !["STAR_RATING", "TITLE", "DESCRIPTION"].includes(f.type)
438
+ );
439
+ function setAnswer(fieldId, value) {
440
+ setAnswers((prev) => ({ ...prev, [fieldId]: value }));
441
+ }
442
+ async function handleSubmit(e) {
443
+ e.preventDefault();
444
+ const fieldAnswers = Object.entries(answers).map(([fieldId, value]) => ({ fieldId, value }));
445
+ await submit({ productId, email, formId: form.id, fieldAnswers });
446
+ onSuccess?.();
447
+ }
448
+ if (success && result) {
449
+ return /* @__PURE__ */ jsx2("div", { className, children: /* @__PURE__ */ jsx2("p", { children: result.requiresEmailVerification ? result.message ?? "Check your email to verify your review." : "Thanks for your review!" }) });
450
+ }
451
+ return /* @__PURE__ */ jsxs2("form", { className, onSubmit: handleSubmit, style: { display: "flex", flexDirection: "column", gap: 16 }, children: [
452
+ starField && /* @__PURE__ */ jsxs2("div", { children: [
453
+ /* @__PURE__ */ jsxs2("label", { children: [
454
+ "Rating ",
455
+ starField.required && /* @__PURE__ */ jsx2("span", { "aria-hidden": true, children: "*" })
456
+ ] }),
457
+ /* @__PURE__ */ jsx2("div", { style: { display: "flex", gap: 4 }, children: [1, 2, 3, 4, 5].map((val) => /* @__PURE__ */ jsx2(
458
+ "button",
459
+ {
460
+ type: "button",
461
+ onClick: () => setAnswer(starField.id, val),
462
+ "aria-label": `${val} star`,
463
+ style: { background: "none", border: "none", cursor: "pointer", padding: 2 },
464
+ children: /* @__PURE__ */ jsx2(
465
+ StarRating,
466
+ {
467
+ rating: val <= Number(answers[starField.id] ?? 0) ? 1 : 0,
468
+ size: 28
469
+ }
470
+ )
471
+ },
472
+ val
473
+ )) })
474
+ ] }),
475
+ /* @__PURE__ */ jsxs2("div", { children: [
476
+ /* @__PURE__ */ jsx2("label", { htmlFor: "revova-email", children: "Email *" }),
477
+ /* @__PURE__ */ jsx2(
478
+ "input",
479
+ {
480
+ id: "revova-email",
481
+ type: "email",
482
+ required: true,
483
+ value: email,
484
+ onChange: (e) => setEmail(e.target.value)
485
+ }
486
+ )
487
+ ] }),
488
+ titleField && /* @__PURE__ */ jsxs2("div", { children: [
489
+ /* @__PURE__ */ jsxs2("label", { htmlFor: `revova-${titleField.id}`, children: [
490
+ titleField.label ?? "Title",
491
+ titleField.required && /* @__PURE__ */ jsx2("span", { "aria-hidden": true, children: " *" })
492
+ ] }),
493
+ /* @__PURE__ */ jsx2(
494
+ "input",
495
+ {
496
+ id: `revova-${titleField.id}`,
497
+ type: "text",
498
+ required: titleField.required,
499
+ value: String(answers[titleField.id] ?? ""),
500
+ onChange: (e) => setAnswer(titleField.id, e.target.value)
501
+ }
502
+ )
503
+ ] }),
504
+ descField && /* @__PURE__ */ jsxs2("div", { children: [
505
+ /* @__PURE__ */ jsxs2("label", { htmlFor: `revova-${descField.id}`, children: [
506
+ descField.label ?? "Review",
507
+ descField.required && /* @__PURE__ */ jsx2("span", { "aria-hidden": true, children: " *" })
508
+ ] }),
509
+ /* @__PURE__ */ jsx2(
510
+ "textarea",
511
+ {
512
+ id: `revova-${descField.id}`,
513
+ required: descField.required,
514
+ rows: 4,
515
+ value: String(answers[descField.id] ?? ""),
516
+ onChange: (e) => setAnswer(descField.id, e.target.value)
517
+ }
518
+ )
519
+ ] }),
520
+ customFields.map((field) => /* @__PURE__ */ jsx2(
521
+ FieldRenderer,
522
+ {
523
+ field,
524
+ value: answers[field.id],
525
+ onChange: (val) => setAnswer(field.id, val)
526
+ },
527
+ field.id
528
+ )),
529
+ error && /* @__PURE__ */ jsx2("p", { style: { color: "red", margin: 0 }, children: error }),
530
+ /* @__PURE__ */ jsx2("button", { type: "submit", disabled: submitting, children: submitting ? "Submitting\u2026" : "Submit Review" })
531
+ ] });
532
+ }
533
+
534
+ // src/components/ReviewWidget.tsx
535
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
536
+ function ReviewWidget({
537
+ proxyUrl,
538
+ productId,
539
+ locale,
540
+ pageSize = 10,
541
+ showForm = true,
542
+ starColor,
543
+ className
544
+ }) {
545
+ const [showingForm, setShowingForm] = useState4(false);
546
+ const { data, loading, error, setPage, setSort, currentPage, currentSort } = useReviews({
547
+ proxyUrl,
548
+ productId,
549
+ limit: pageSize,
550
+ ...locale !== void 0 ? { locale } : {}
551
+ });
552
+ if (loading) return /* @__PURE__ */ jsx3("div", { className, children: "Loading reviews\u2026" });
553
+ if (error) return /* @__PURE__ */ jsx3("div", { className, children: "Could not load reviews." });
554
+ if (!data) return null;
555
+ const { reviews, pagination, form } = data;
556
+ return /* @__PURE__ */ jsxs3("div", { className, children: [
557
+ /* @__PURE__ */ jsxs3("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }, children: [
558
+ /* @__PURE__ */ jsx3("div", { children: /* @__PURE__ */ jsxs3("strong", { children: [
559
+ pagination.total,
560
+ " review",
561
+ pagination.total !== 1 ? "s" : ""
562
+ ] }) }),
563
+ /* @__PURE__ */ jsxs3(
564
+ "select",
565
+ {
566
+ value: currentSort,
567
+ onChange: (e) => setSort(e.target.value),
568
+ "aria-label": "Sort reviews",
569
+ children: [
570
+ /* @__PURE__ */ jsx3("option", { value: "recent", children: "Most Recent" }),
571
+ /* @__PURE__ */ jsx3("option", { value: "helpful", children: "Most Helpful" }),
572
+ /* @__PURE__ */ jsx3("option", { value: "rating_high", children: "Highest Rated" }),
573
+ /* @__PURE__ */ jsx3("option", { value: "rating_low", children: "Lowest Rated" })
574
+ ]
575
+ }
576
+ )
577
+ ] }),
578
+ showForm && form && !showingForm && /* @__PURE__ */ jsx3("button", { onClick: () => setShowingForm(true), style: { marginBottom: 16 }, children: "Write a Review" }),
579
+ showingForm && form && /* @__PURE__ */ jsx3("div", { style: { marginBottom: 24 }, children: /* @__PURE__ */ jsx3(
580
+ ReviewForm,
581
+ {
582
+ proxyUrl,
583
+ productId,
584
+ form,
585
+ onSuccess: () => setShowingForm(false)
586
+ }
587
+ ) }),
588
+ reviews.length === 0 ? /* @__PURE__ */ jsx3("p", { children: "No reviews yet. Be the first!" }) : /* @__PURE__ */ jsx3("ul", { style: { listStyle: "none", padding: 0, margin: 0 }, children: reviews.map((review) => /* @__PURE__ */ jsxs3("li", { style: { borderBottom: "1px solid #e5e7eb", paddingBottom: 16, marginBottom: 16 }, children: [
589
+ /* @__PURE__ */ jsx3(StarRating, { rating: review.rating, ...starColor !== void 0 ? { color: starColor } : {} }),
590
+ review.isPinned && /* @__PURE__ */ jsx3("span", { children: "Pinned" }),
591
+ review.verifiedBuyer && /* @__PURE__ */ jsx3("span", { children: "Verified Buyer" }),
592
+ review.title && /* @__PURE__ */ jsx3("strong", { style: { display: "block", marginTop: 4 }, children: review.translatedTitle ?? review.title }),
593
+ review.body && /* @__PURE__ */ jsx3("p", { style: { margin: "4px 0" }, children: review.translatedBody ?? review.body }),
594
+ /* @__PURE__ */ jsxs3("small", { children: [
595
+ review.isAnonymous ? "Anonymous" : review.email,
596
+ " \xB7",
597
+ " ",
598
+ new Date(review.createdAt).toLocaleDateString(),
599
+ review.variantTitle ? ` \xB7 ${review.variantTitle}` : ""
600
+ ] }),
601
+ review.media.length > 0 && /* @__PURE__ */ jsx3("div", { style: { display: "flex", gap: 8, marginTop: 8 }, children: review.media.map(
602
+ (m, i) => m.mimeType.startsWith("image") ? /* @__PURE__ */ jsx3(
603
+ "img",
604
+ {
605
+ src: m.url,
606
+ alt: `Review image ${i + 1}`,
607
+ width: 80,
608
+ height: 80,
609
+ style: { objectFit: "cover", borderRadius: 4 }
610
+ },
611
+ i
612
+ ) : null
613
+ ) }),
614
+ review.reply && /* @__PURE__ */ jsxs3("div", { style: { marginTop: 8, paddingLeft: 12, borderLeft: "2px solid #e5e7eb" }, children: [
615
+ /* @__PURE__ */ jsxs3("small", { children: [
616
+ /* @__PURE__ */ jsx3("strong", { children: "Store Reply" }),
617
+ " \xB7 ",
618
+ new Date(review.reply.createdAt).toLocaleDateString()
619
+ ] }),
620
+ /* @__PURE__ */ jsx3("p", { style: { margin: "2px 0" }, children: review.reply.body })
621
+ ] })
622
+ ] }, review.id)) }),
623
+ pagination.totalPages > 1 && /* @__PURE__ */ jsxs3("div", { style: { display: "flex", gap: 8, alignItems: "center", marginTop: 16 }, children: [
624
+ /* @__PURE__ */ jsx3("button", { onClick: () => setPage(currentPage - 1), disabled: currentPage <= 1, children: "Previous" }),
625
+ /* @__PURE__ */ jsxs3("span", { children: [
626
+ "Page ",
627
+ currentPage,
628
+ " of ",
629
+ pagination.totalPages
630
+ ] }),
631
+ /* @__PURE__ */ jsx3("button", { onClick: () => setPage(currentPage + 1), disabled: currentPage >= pagination.totalPages, children: "Next" })
632
+ ] })
633
+ ] });
634
+ }
635
+
636
+ // src/hooks/useWidgetGlobals.ts
637
+ import { useEffect as useEffect2, useState as useState5 } from "react";
638
+ function useWidgetGlobals({ proxyUrl, limit = 20 }) {
639
+ const [state, setState] = useState5({
640
+ data: null,
641
+ loading: true,
642
+ error: null
643
+ });
644
+ useEffect2(() => {
645
+ let cancelled = false;
646
+ const params = new URLSearchParams({ limit: String(limit) });
647
+ fetch(`${proxyUrl}/widget-globals?${params.toString()}`).then((res) => {
648
+ if (!res.ok) throw new Error(`Revova: widget-globals fetch failed (${res.status})`);
649
+ return res.json();
650
+ }).then((data) => {
651
+ if (!cancelled) setState({ data, loading: false, error: null });
652
+ }).catch((err) => {
653
+ if (!cancelled)
654
+ setState({ data: null, loading: false, error: err instanceof Error ? err : new Error(String(err)) });
655
+ });
656
+ return () => {
657
+ cancelled = true;
658
+ };
659
+ }, [proxyUrl, limit]);
660
+ return state;
661
+ }
662
+
663
+ // src/components/ReviewCount.tsx
664
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
665
+ function ReviewCount({ proxyUrl, starColor, starSize, className }) {
666
+ const { data, loading } = useWidgetGlobals({ proxyUrl });
667
+ if (loading || !data?.stats?.averageRating) return null;
668
+ const avg = parseFloat(data.stats.averageRating);
669
+ return /* @__PURE__ */ jsxs4("span", { className, style: { display: "inline-flex", alignItems: "center", gap: 6 }, children: [
670
+ /* @__PURE__ */ jsx4(StarRating, { rating: avg, color: starColor, size: starSize }),
671
+ /* @__PURE__ */ jsx4("span", { children: data.stats.averageRating }),
672
+ /* @__PURE__ */ jsxs4("span", { children: [
673
+ "(",
674
+ data.stats.totalReviews,
675
+ ")"
676
+ ] })
677
+ ] });
678
+ }
679
+
680
+ // src/components/ReviewCarousel.tsx
681
+ import React3, { useState as useState6 } from "react";
682
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
683
+ function ReviewCarousel({
684
+ proxyUrl,
685
+ limit = 10,
686
+ autoPlay = true,
687
+ intervalMs = 4e3,
688
+ starColor,
689
+ className
690
+ }) {
691
+ const { data, loading } = useWidgetGlobals({ proxyUrl, limit });
692
+ const [index, setIndex] = useState6(0);
693
+ const reviews = data?.reviews ?? [];
694
+ React3.useEffect(() => {
695
+ if (!autoPlay || reviews.length < 2) return;
696
+ const id = setInterval(() => setIndex((i) => (i + 1) % reviews.length), intervalMs);
697
+ return () => clearInterval(id);
698
+ }, [autoPlay, intervalMs, reviews.length]);
699
+ if (loading) return /* @__PURE__ */ jsx5("div", { className, children: "Loading\u2026" });
700
+ if (reviews.length === 0) return null;
701
+ const prev = () => setIndex((i) => (i - 1 + reviews.length) % reviews.length);
702
+ const next = () => setIndex((i) => (i + 1) % reviews.length);
703
+ const review = reviews[index];
704
+ return /* @__PURE__ */ jsxs5("div", { className, style: { position: "relative", overflow: "hidden" }, children: [
705
+ /* @__PURE__ */ jsxs5("div", { style: { display: "flex", alignItems: "center", gap: 12 }, children: [
706
+ /* @__PURE__ */ jsx5("button", { onClick: prev, "aria-label": "Previous review", style: { flexShrink: 0 }, children: "\u2039" }),
707
+ /* @__PURE__ */ jsxs5("div", { style: { flex: 1, textAlign: "center", padding: "8px 0" }, children: [
708
+ /* @__PURE__ */ jsx5(StarRating, { rating: review.rating, color: starColor }),
709
+ review.title && /* @__PURE__ */ jsx5("strong", { style: { display: "block", marginTop: 8 }, children: review.title }),
710
+ review.body && /* @__PURE__ */ jsx5("p", { style: { margin: "6px 0" }, children: review.body }),
711
+ /* @__PURE__ */ jsx5("small", { children: review.authorName })
712
+ ] }),
713
+ /* @__PURE__ */ jsx5("button", { onClick: next, "aria-label": "Next review", style: { flexShrink: 0 }, children: "\u203A" })
714
+ ] }),
715
+ /* @__PURE__ */ jsx5("div", { style: { display: "flex", justifyContent: "center", gap: 6, marginTop: 8 }, children: reviews.map((_, i) => /* @__PURE__ */ jsx5(
716
+ "button",
717
+ {
718
+ onClick: () => setIndex(i),
719
+ "aria-label": `Go to review ${i + 1}`,
720
+ style: {
721
+ width: 8,
722
+ height: 8,
723
+ borderRadius: "50%",
724
+ border: "none",
725
+ cursor: "pointer",
726
+ background: i === index ? "#374151" : "#d1d5db",
727
+ padding: 0
728
+ }
729
+ },
730
+ i
731
+ )) })
732
+ ] });
733
+ }
734
+
735
+ // src/components/ReviewGallery.tsx
736
+ import { useState as useState7 } from "react";
737
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
738
+ function ReviewGallery({
739
+ proxyUrl,
740
+ limit = 20,
741
+ columns = 3,
742
+ starColor,
743
+ className
744
+ }) {
745
+ const { data, loading } = useWidgetGlobals({ proxyUrl, limit });
746
+ const [lightbox, setLightbox] = useState7(null);
747
+ const items = (data?.reviews ?? []).filter((r) => r.image).map((r) => ({ url: r.image, review: r })).slice(0, limit);
748
+ if (loading) return /* @__PURE__ */ jsx6("div", { className, children: "Loading gallery\u2026" });
749
+ if (items.length === 0) return null;
750
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
751
+ /* @__PURE__ */ jsx6(
752
+ "div",
753
+ {
754
+ className,
755
+ style: { columns, columnGap: 8 },
756
+ children: items.map((item, i) => /* @__PURE__ */ jsx6(
757
+ "button",
758
+ {
759
+ onClick: () => setLightbox(item),
760
+ style: {
761
+ display: "block",
762
+ width: "100%",
763
+ marginBottom: 8,
764
+ border: "none",
765
+ padding: 0,
766
+ cursor: "pointer",
767
+ background: "none",
768
+ breakInside: "avoid"
769
+ },
770
+ "aria-label": `View review photo ${i + 1}`,
771
+ children: /* @__PURE__ */ jsx6(
772
+ "img",
773
+ {
774
+ src: item.url,
775
+ alt: `Review photo ${i + 1}`,
776
+ style: { width: "100%", display: "block", borderRadius: 4 },
777
+ loading: "lazy"
778
+ }
779
+ )
780
+ },
781
+ i
782
+ ))
783
+ }
784
+ ),
785
+ lightbox && /* @__PURE__ */ jsx6(
786
+ "div",
787
+ {
788
+ role: "dialog",
789
+ "aria-modal": "true",
790
+ "aria-label": "Review photo",
791
+ style: {
792
+ position: "fixed",
793
+ inset: 0,
794
+ background: "rgba(0,0,0,0.85)",
795
+ zIndex: 99999,
796
+ display: "flex",
797
+ alignItems: "center",
798
+ justifyContent: "center",
799
+ padding: 16
800
+ },
801
+ onClick: () => setLightbox(null),
802
+ children: /* @__PURE__ */ jsxs6(
803
+ "div",
804
+ {
805
+ style: {
806
+ background: "#fff",
807
+ borderRadius: 12,
808
+ maxWidth: 540,
809
+ width: "100%",
810
+ overflow: "hidden"
811
+ },
812
+ onClick: (e) => e.stopPropagation(),
813
+ children: [
814
+ /* @__PURE__ */ jsx6(
815
+ "img",
816
+ {
817
+ src: lightbox.url,
818
+ alt: "Review",
819
+ style: { width: "100%", display: "block", maxHeight: 320, objectFit: "cover" }
820
+ }
821
+ ),
822
+ /* @__PURE__ */ jsxs6("div", { style: { padding: 16 }, children: [
823
+ /* @__PURE__ */ jsx6(StarRating, { rating: lightbox.review.rating, color: starColor }),
824
+ lightbox.review.title && /* @__PURE__ */ jsx6("strong", { style: { display: "block", marginTop: 6 }, children: lightbox.review.title }),
825
+ lightbox.review.body && /* @__PURE__ */ jsx6("p", { style: { margin: "6px 0" }, children: lightbox.review.body }),
826
+ /* @__PURE__ */ jsx6("small", { children: lightbox.review.authorName })
827
+ ] }),
828
+ /* @__PURE__ */ jsx6(
829
+ "button",
830
+ {
831
+ onClick: () => setLightbox(null),
832
+ style: {
833
+ position: "absolute",
834
+ top: 12,
835
+ right: 12,
836
+ background: "#fff",
837
+ border: "none",
838
+ borderRadius: "50%",
839
+ width: 32,
840
+ height: 32,
841
+ cursor: "pointer",
842
+ fontSize: 18
843
+ },
844
+ "aria-label": "Close",
845
+ children: "\xD7"
846
+ }
847
+ )
848
+ ]
849
+ }
850
+ )
851
+ }
852
+ )
853
+ ] });
854
+ }
855
+
856
+ // src/components/QnAWidget.tsx
857
+ import { useState as useState9 } from "react";
858
+
859
+ // src/hooks/useQnA.ts
860
+ import { useEffect as useEffect3, useState as useState8, useCallback as useCallback3 } from "react";
861
+ var INITIAL_SUBMIT = { submitting: false, success: false, error: null };
862
+ function useQnA({ proxyUrl, productId, page: initialPage = 1, sort = "recent" }) {
863
+ const [page, setPage] = useState8(initialPage);
864
+ const [tick, setTick] = useState8(0);
865
+ const [state, setState] = useState8({ data: null, loading: true, error: null });
866
+ const [submitState, setSubmitState] = useState8(INITIAL_SUBMIT);
867
+ const refetch = useCallback3(() => setTick((t) => t + 1), []);
868
+ useEffect3(() => {
869
+ let cancelled = false;
870
+ setState((s) => ({ ...s, loading: true, error: null }));
871
+ const params = new URLSearchParams({ productId, page: String(page), sort });
872
+ fetch(`${proxyUrl}/qna?${params.toString()}`).then((res) => {
873
+ if (!res.ok) throw new Error(`Revova: qna fetch failed (${res.status})`);
874
+ return res.json();
875
+ }).then((data) => {
876
+ if (!cancelled) setState({ data, loading: false, error: null });
877
+ }).catch((err) => {
878
+ if (!cancelled)
879
+ setState({ data: null, loading: false, error: err instanceof Error ? err : new Error(String(err)) });
880
+ });
881
+ return () => {
882
+ cancelled = true;
883
+ };
884
+ }, [proxyUrl, productId, page, sort, tick]);
885
+ const submitQuestion = useCallback3(async (payload) => {
886
+ setSubmitState({ submitting: true, success: false, error: null });
887
+ try {
888
+ const res = await fetch(`${proxyUrl}/qna`, {
889
+ method: "POST",
890
+ headers: { "Content-Type": "application/json" },
891
+ body: JSON.stringify(payload)
892
+ });
893
+ const json = await res.json();
894
+ if (!res.ok) {
895
+ setSubmitState({ submitting: false, success: false, error: json.error ?? `Failed (${res.status})` });
896
+ return;
897
+ }
898
+ setSubmitState({ submitting: false, success: true, error: null });
899
+ refetch();
900
+ } catch (err) {
901
+ setSubmitState({ submitting: false, success: false, error: err instanceof Error ? err.message : "An error occurred." });
902
+ }
903
+ }, [proxyUrl, refetch]);
904
+ const submitAnswer = useCallback3(async (payload) => {
905
+ setSubmitState({ submitting: true, success: false, error: null });
906
+ try {
907
+ const res = await fetch(`${proxyUrl}/qna`, {
908
+ method: "POST",
909
+ headers: { "Content-Type": "application/json" },
910
+ body: JSON.stringify(payload)
911
+ });
912
+ const json = await res.json();
913
+ if (!res.ok) {
914
+ setSubmitState({ submitting: false, success: false, error: json.error ?? `Failed (${res.status})` });
915
+ return;
916
+ }
917
+ setSubmitState({ submitting: false, success: true, error: null });
918
+ refetch();
919
+ } catch (err) {
920
+ setSubmitState({ submitting: false, success: false, error: err instanceof Error ? err.message : "An error occurred." });
921
+ }
922
+ }, [proxyUrl, refetch]);
923
+ const resetSubmit = useCallback3(() => setSubmitState(INITIAL_SUBMIT), []);
924
+ return { ...state, setPage, currentPage: page, refetch, submitQuestion, submitAnswer, submitState, resetSubmit };
925
+ }
926
+
927
+ // src/components/QnAWidget.tsx
928
+ import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
929
+ function QnAWidget({ proxyUrl, productId, className }) {
930
+ const { data, loading, error, setPage, currentPage, submitQuestion, submitState, resetSubmit } = useQnA({
931
+ proxyUrl,
932
+ productId
933
+ });
934
+ const [showForm, setShowForm] = useState9(false);
935
+ const [email, setEmail] = useState9("");
936
+ const [displayName, setDisplayName] = useState9("");
937
+ const [body, setBody] = useState9("");
938
+ const [isAnonymous, setIsAnonymous] = useState9(false);
939
+ async function handleAsk(e) {
940
+ e.preventDefault();
941
+ await submitQuestion({ intent: "question", productId, email, displayName, body, isAnonymous });
942
+ setBody("");
943
+ setEmail("");
944
+ setDisplayName("");
945
+ setShowForm(false);
946
+ }
947
+ if (loading) return /* @__PURE__ */ jsx7("div", { className, children: "Loading Q&A\u2026" });
948
+ if (error) return /* @__PURE__ */ jsx7("div", { className, children: "Could not load Q&A." });
949
+ if (!data) return null;
950
+ const { questions, pagination } = data;
951
+ return /* @__PURE__ */ jsxs7("div", { className, children: [
952
+ /* @__PURE__ */ jsxs7("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }, children: [
953
+ /* @__PURE__ */ jsx7("h3", { style: { margin: 0 }, children: "Questions & Answers" }),
954
+ /* @__PURE__ */ jsx7("button", { onClick: () => {
955
+ setShowForm((s) => !s);
956
+ resetSubmit();
957
+ }, children: "Ask a Question" })
958
+ ] }),
959
+ showForm && /* @__PURE__ */ jsx7("form", { onSubmit: handleAsk, style: { marginBottom: 24 }, children: submitState.success ? /* @__PURE__ */ jsx7("p", { children: "Thanks! Your question has been submitted." }) : /* @__PURE__ */ jsxs7(Fragment3, { children: [
960
+ /* @__PURE__ */ jsxs7("div", { children: [
961
+ /* @__PURE__ */ jsx7("label", { htmlFor: "qna-email", children: "Email" }),
962
+ /* @__PURE__ */ jsx7("input", { id: "qna-email", type: "email", required: true, value: email, onChange: (e) => setEmail(e.target.value) })
963
+ ] }),
964
+ /* @__PURE__ */ jsxs7("div", { children: [
965
+ /* @__PURE__ */ jsx7("label", { htmlFor: "qna-name", children: "Name (optional)" }),
966
+ /* @__PURE__ */ jsx7("input", { id: "qna-name", type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value) })
967
+ ] }),
968
+ /* @__PURE__ */ jsxs7("div", { children: [
969
+ /* @__PURE__ */ jsx7("label", { htmlFor: "qna-body", children: "Your Question" }),
970
+ /* @__PURE__ */ jsx7(
971
+ "textarea",
972
+ {
973
+ id: "qna-body",
974
+ required: true,
975
+ rows: 3,
976
+ value: body,
977
+ onChange: (e) => setBody(e.target.value)
978
+ }
979
+ )
980
+ ] }),
981
+ /* @__PURE__ */ jsxs7("label", { children: [
982
+ /* @__PURE__ */ jsx7("input", { type: "checkbox", checked: isAnonymous, onChange: (e) => setIsAnonymous(e.target.checked) }),
983
+ " ",
984
+ "Post anonymously"
985
+ ] }),
986
+ submitState.error && /* @__PURE__ */ jsx7("p", { style: { color: "red" }, children: submitState.error }),
987
+ /* @__PURE__ */ jsx7("button", { type: "submit", disabled: submitState.submitting, children: submitState.submitting ? "Submitting\u2026" : "Submit Question" })
988
+ ] }) }),
989
+ questions.length === 0 ? /* @__PURE__ */ jsx7("p", { children: "No questions yet. Be the first to ask!" }) : /* @__PURE__ */ jsx7("ul", { style: { listStyle: "none", padding: 0, margin: 0 }, children: questions.map((q) => /* @__PURE__ */ jsxs7("li", { style: { borderBottom: "1px solid #e5e7eb", paddingBottom: 16, marginBottom: 16 }, children: [
990
+ /* @__PURE__ */ jsxs7("p", { style: { fontWeight: 600, margin: "0 0 4px" }, children: [
991
+ "Q: ",
992
+ q.body
993
+ ] }),
994
+ /* @__PURE__ */ jsxs7("small", { children: [
995
+ q.displayName,
996
+ q.verifiedBuyer ? " \xB7 Verified Buyer" : ""
997
+ ] }),
998
+ q.answers.length > 0 && /* @__PURE__ */ jsx7("ul", { style: { listStyle: "none", padding: "8px 0 0 16px", margin: 0 }, children: q.answers.map((a) => /* @__PURE__ */ jsxs7("li", { style: { marginBottom: 8 }, children: [
999
+ /* @__PURE__ */ jsxs7("p", { style: { margin: "0 0 2px" }, children: [
1000
+ a.isAdminReply && /* @__PURE__ */ jsx7("strong", { children: "Store: " }),
1001
+ a.body
1002
+ ] }),
1003
+ /* @__PURE__ */ jsx7("small", { children: a.displayName })
1004
+ ] }, a.id)) })
1005
+ ] }, q.id)) }),
1006
+ pagination.totalPages > 1 && /* @__PURE__ */ jsxs7("div", { style: { display: "flex", gap: 8, alignItems: "center", marginTop: 16 }, children: [
1007
+ /* @__PURE__ */ jsx7("button", { onClick: () => setPage(currentPage - 1), disabled: currentPage <= 1, children: "Previous" }),
1008
+ /* @__PURE__ */ jsxs7("span", { children: [
1009
+ "Page ",
1010
+ currentPage,
1011
+ " of ",
1012
+ pagination.totalPages
1013
+ ] }),
1014
+ /* @__PURE__ */ jsx7("button", { onClick: () => setPage(currentPage + 1), disabled: currentPage >= pagination.totalPages, children: "Next" })
1015
+ ] })
1016
+ ] });
1017
+ }
1018
+
1019
+ // src/components/SocialProofPopup.tsx
1020
+ import { useEffect as useEffect4, useState as useState10 } from "react";
1021
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
1022
+ function SocialProofPopup({
1023
+ proxyUrl,
1024
+ position = "bottom-left",
1025
+ intervalMs = 8e3,
1026
+ displayMs = 5e3,
1027
+ starColor,
1028
+ className
1029
+ }) {
1030
+ const { data } = useWidgetGlobals({ proxyUrl });
1031
+ const [current, setCurrent] = useState10(null);
1032
+ const [visible, setVisible] = useState10(false);
1033
+ const [dismissed, setDismissed] = useState10(false);
1034
+ useEffect4(() => {
1035
+ const reviews = data?.reviews;
1036
+ if (!reviews || reviews.length === 0 || dismissed) return;
1037
+ let idx = 0;
1038
+ function show() {
1039
+ const review = reviews[idx % reviews.length];
1040
+ if (review) {
1041
+ setCurrent(review);
1042
+ setVisible(true);
1043
+ idx++;
1044
+ setTimeout(() => setVisible(false), displayMs);
1045
+ }
1046
+ }
1047
+ const initial = setTimeout(show, 2e3);
1048
+ const interval = setInterval(show, intervalMs);
1049
+ return () => {
1050
+ clearTimeout(initial);
1051
+ clearInterval(interval);
1052
+ };
1053
+ }, [data, intervalMs, displayMs, dismissed]);
1054
+ if (!current || !visible) return null;
1055
+ const posStyle = position === "bottom-right" ? { bottom: 20, right: 20 } : { bottom: 20, left: 20 };
1056
+ return /* @__PURE__ */ jsxs8(
1057
+ "div",
1058
+ {
1059
+ className,
1060
+ style: {
1061
+ position: "fixed",
1062
+ zIndex: 99999,
1063
+ ...posStyle,
1064
+ background: "#fff",
1065
+ borderRadius: 12,
1066
+ boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
1067
+ padding: "12px 16px",
1068
+ maxWidth: 280,
1069
+ display: "flex",
1070
+ gap: 10,
1071
+ alignItems: "flex-start",
1072
+ animation: "fadeInUp 0.3s ease"
1073
+ },
1074
+ role: "status",
1075
+ "aria-live": "polite",
1076
+ children: [
1077
+ current.image ? /* @__PURE__ */ jsx8(
1078
+ "img",
1079
+ {
1080
+ src: current.image,
1081
+ alt: "",
1082
+ width: 44,
1083
+ height: 44,
1084
+ style: { borderRadius: "50%", objectFit: "cover", flexShrink: 0 }
1085
+ }
1086
+ ) : /* @__PURE__ */ jsx8(
1087
+ "div",
1088
+ {
1089
+ style: {
1090
+ width: 44,
1091
+ height: 44,
1092
+ borderRadius: "50%",
1093
+ background: "#e5e7eb",
1094
+ flexShrink: 0,
1095
+ display: "flex",
1096
+ alignItems: "center",
1097
+ justifyContent: "center",
1098
+ fontSize: 18
1099
+ },
1100
+ children: current.authorName[0]?.toUpperCase() ?? "?"
1101
+ }
1102
+ ),
1103
+ /* @__PURE__ */ jsxs8("div", { style: { flex: 1, minWidth: 0 }, children: [
1104
+ /* @__PURE__ */ jsx8(StarRating, { rating: current.rating, size: 13, color: starColor }),
1105
+ current.body && /* @__PURE__ */ jsx8("p", { style: { margin: "4px 0", fontSize: 13, lineHeight: 1.4, overflow: "hidden", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }, children: current.body }),
1106
+ /* @__PURE__ */ jsxs8("small", { style: { fontSize: 11, color: "#6b7280" }, children: [
1107
+ current.authorName,
1108
+ current.verifiedBuyer ? " \xB7 Verified Buyer" : ""
1109
+ ] })
1110
+ ] }),
1111
+ /* @__PURE__ */ jsx8(
1112
+ "button",
1113
+ {
1114
+ onClick: () => {
1115
+ setVisible(false);
1116
+ setDismissed(true);
1117
+ },
1118
+ "aria-label": "Dismiss",
1119
+ style: { background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "#9ca3af", padding: 0, flexShrink: 0 },
1120
+ children: "\xD7"
1121
+ }
1122
+ )
1123
+ ]
1124
+ }
1125
+ );
1126
+ }
1127
+
1128
+ // src/components/ReviewTicker.tsx
1129
+ import { useRef } from "react";
1130
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1131
+ function ReviewTicker({
1132
+ proxyUrl,
1133
+ limit = 20,
1134
+ speedSeconds = 30,
1135
+ starColor,
1136
+ className
1137
+ }) {
1138
+ const { data, loading } = useWidgetGlobals({ proxyUrl, limit });
1139
+ const trackRef = useRef(null);
1140
+ const reviews = data?.reviews ?? [];
1141
+ if (loading || reviews.length === 0) return null;
1142
+ const items = [...reviews, ...reviews];
1143
+ return /* @__PURE__ */ jsxs9(
1144
+ "div",
1145
+ {
1146
+ className,
1147
+ style: { overflow: "hidden", position: "relative" },
1148
+ "aria-label": "Review ticker",
1149
+ children: [
1150
+ /* @__PURE__ */ jsx9("style", { children: `
1151
+ @keyframes rvTicker {
1152
+ from { transform: translateX(0); }
1153
+ to { transform: translateX(-50%); }
1154
+ }
1155
+ ` }),
1156
+ /* @__PURE__ */ jsx9(
1157
+ "div",
1158
+ {
1159
+ ref: trackRef,
1160
+ style: {
1161
+ display: "flex",
1162
+ gap: 32,
1163
+ animation: `rvTicker ${speedSeconds}s linear infinite`,
1164
+ width: "max-content"
1165
+ },
1166
+ children: items.map((review, i) => /* @__PURE__ */ jsxs9(
1167
+ "div",
1168
+ {
1169
+ style: {
1170
+ display: "flex",
1171
+ flexDirection: "column",
1172
+ gap: 4,
1173
+ minWidth: 220,
1174
+ flexShrink: 0
1175
+ },
1176
+ children: [
1177
+ /* @__PURE__ */ jsx9(StarRating, { rating: review.rating, size: 14, color: starColor }),
1178
+ review.body && /* @__PURE__ */ jsxs9("p", { style: { margin: 0, fontSize: 13, overflow: "hidden", whiteSpace: "nowrap", maxWidth: 220, textOverflow: "ellipsis" }, children: [
1179
+ "\u201C",
1180
+ review.body,
1181
+ "\u201D"
1182
+ ] }),
1183
+ /* @__PURE__ */ jsx9("small", { style: { fontSize: 11, color: "#6b7280" }, children: review.authorName })
1184
+ ]
1185
+ },
1186
+ `${review.id}-${i}`
1187
+ ))
1188
+ }
1189
+ )
1190
+ ]
1191
+ }
1192
+ );
1193
+ }
1194
+
1195
+ // src/components/FloatingReviewsTab.tsx
1196
+ import { useState as useState11 } from "react";
1197
+ import { Fragment as Fragment4, jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
1198
+ function FloatingReviewsTab({
1199
+ proxyUrl,
1200
+ label = "Reviews",
1201
+ position = "right",
1202
+ color = "#111827",
1203
+ limit = 5,
1204
+ starColor,
1205
+ className
1206
+ }) {
1207
+ const { data } = useWidgetGlobals({ proxyUrl, limit });
1208
+ const [open, setOpen] = useState11(false);
1209
+ const reviews = data?.reviews ?? [];
1210
+ const stats = data?.stats;
1211
+ const sideStyle = position === "right" ? { right: 0, top: "50%", transform: "translateY(-50%)" } : { left: 0, top: "50%", transform: "translateY(-50%)" };
1212
+ const panelStyle = position === "right" ? { right: 0, top: "50%", transform: "translateY(-50%)" } : { left: 0, top: "50%", transform: "translateY(-50%)" };
1213
+ return /* @__PURE__ */ jsxs10(Fragment4, { children: [
1214
+ /* @__PURE__ */ jsx10(
1215
+ "button",
1216
+ {
1217
+ className,
1218
+ onClick: () => setOpen((s) => !s),
1219
+ "aria-expanded": open,
1220
+ "aria-label": `${label} panel`,
1221
+ style: {
1222
+ position: "fixed",
1223
+ zIndex: 9999,
1224
+ ...sideStyle,
1225
+ background: color,
1226
+ color: "#fff",
1227
+ border: "none",
1228
+ cursor: "pointer",
1229
+ padding: "10px 14px",
1230
+ writingMode: "vertical-rl",
1231
+ textOrientation: "mixed",
1232
+ transform: `translateY(-50%) rotate(${position === "right" ? 180 : 0}deg)`,
1233
+ borderRadius: position === "right" ? "8px 0 0 8px" : "0 8px 8px 0",
1234
+ fontSize: 13,
1235
+ fontWeight: 600
1236
+ },
1237
+ children: label
1238
+ }
1239
+ ),
1240
+ open && /* @__PURE__ */ jsxs10(
1241
+ "div",
1242
+ {
1243
+ role: "dialog",
1244
+ "aria-label": label,
1245
+ style: {
1246
+ position: "fixed",
1247
+ zIndex: 99999,
1248
+ ...panelStyle,
1249
+ background: "#fff",
1250
+ boxShadow: "0 8px 32px rgba(0,0,0,0.16)",
1251
+ width: 300,
1252
+ maxHeight: "80vh",
1253
+ overflowY: "auto",
1254
+ padding: 20,
1255
+ borderRadius: 8
1256
+ },
1257
+ children: [
1258
+ /* @__PURE__ */ jsxs10("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }, children: [
1259
+ /* @__PURE__ */ jsx10("strong", { children: label }),
1260
+ /* @__PURE__ */ jsx10("button", { onClick: () => setOpen(false), "aria-label": "Close", style: { background: "none", border: "none", cursor: "pointer", fontSize: 18 }, children: "\xD7" })
1261
+ ] }),
1262
+ stats && /* @__PURE__ */ jsxs10("div", { style: { marginBottom: 12, display: "flex", alignItems: "center", gap: 8 }, children: [
1263
+ /* @__PURE__ */ jsx10(StarRating, { rating: parseFloat(stats.averageRating ?? "0"), color: starColor }),
1264
+ /* @__PURE__ */ jsxs10("span", { style: { fontSize: 13 }, children: [
1265
+ stats.averageRating,
1266
+ " (",
1267
+ stats.totalReviews,
1268
+ ")"
1269
+ ] })
1270
+ ] }),
1271
+ /* @__PURE__ */ jsx10("ul", { style: { listStyle: "none", padding: 0, margin: 0 }, children: reviews.map((r) => /* @__PURE__ */ jsxs10("li", { style: { borderBottom: "1px solid #f3f4f6", paddingBottom: 12, marginBottom: 12 }, children: [
1272
+ /* @__PURE__ */ jsx10(StarRating, { rating: r.rating, size: 13, color: starColor }),
1273
+ r.body && /* @__PURE__ */ jsx10("p", { style: { margin: "4px 0", fontSize: 13 }, children: r.body }),
1274
+ /* @__PURE__ */ jsx10("small", { style: { fontSize: 11, color: "#6b7280" }, children: r.authorName })
1275
+ ] }, r.id)) })
1276
+ ]
1277
+ }
1278
+ )
1279
+ ] });
1280
+ }
1281
+
1282
+ // src/components/TrustBadge.tsx
1283
+ import { jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
1284
+ function TrustBadge({ proxyUrl, style: badgeStyle = "pill", starColor, className }) {
1285
+ const { data, loading } = useWidgetGlobals({ proxyUrl });
1286
+ if (loading || !data?.stats?.averageRating) return null;
1287
+ const avg = parseFloat(data.stats.averageRating);
1288
+ const count = data.stats.totalReviews;
1289
+ if (badgeStyle === "inline") {
1290
+ return /* @__PURE__ */ jsxs11("span", { className, style: { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 14 }, children: [
1291
+ /* @__PURE__ */ jsx11(StarRating, { rating: avg, color: starColor, size: 14 }),
1292
+ /* @__PURE__ */ jsx11("strong", { children: data.stats.averageRating }),
1293
+ /* @__PURE__ */ jsxs11("span", { style: { color: "#6b7280" }, children: [
1294
+ "(",
1295
+ count,
1296
+ " review",
1297
+ count !== 1 ? "s" : "",
1298
+ ")"
1299
+ ] })
1300
+ ] });
1301
+ }
1302
+ if (badgeStyle === "card") {
1303
+ return /* @__PURE__ */ jsxs11(
1304
+ "div",
1305
+ {
1306
+ className,
1307
+ style: {
1308
+ display: "inline-flex",
1309
+ flexDirection: "column",
1310
+ alignItems: "center",
1311
+ gap: 6,
1312
+ padding: "16px 24px",
1313
+ background: "#fff",
1314
+ borderRadius: 12,
1315
+ boxShadow: "0 2px 12px rgba(0,0,0,0.08)",
1316
+ textAlign: "center"
1317
+ },
1318
+ children: [
1319
+ /* @__PURE__ */ jsx11("strong", { style: { fontSize: 32, lineHeight: 1 }, children: data.stats.averageRating }),
1320
+ /* @__PURE__ */ jsx11(StarRating, { rating: avg, size: 20, color: starColor }),
1321
+ /* @__PURE__ */ jsxs11("span", { style: { fontSize: 13, color: "#6b7280" }, children: [
1322
+ count,
1323
+ " review",
1324
+ count !== 1 ? "s" : ""
1325
+ ] })
1326
+ ]
1327
+ }
1328
+ );
1329
+ }
1330
+ return /* @__PURE__ */ jsxs11(
1331
+ "span",
1332
+ {
1333
+ className,
1334
+ style: {
1335
+ display: "inline-flex",
1336
+ alignItems: "center",
1337
+ gap: 6,
1338
+ background: "#fff",
1339
+ border: "1px solid #e5e7eb",
1340
+ borderRadius: 999,
1341
+ padding: "4px 12px",
1342
+ fontSize: 13,
1343
+ fontWeight: 600
1344
+ },
1345
+ children: [
1346
+ /* @__PURE__ */ jsx11(StarRating, { rating: avg, size: 13, color: starColor }),
1347
+ data.stats.averageRating,
1348
+ " \xB7 ",
1349
+ count,
1350
+ " review",
1351
+ count !== 1 ? "s" : ""
1352
+ ]
1353
+ }
1354
+ );
1355
+ }
1356
+
1357
+ // src/components/FloatingReviewButton.tsx
1358
+ import { useState as useState13 } from "react";
1359
+
1360
+ // src/hooks/useForm.ts
1361
+ import { useEffect as useEffect5, useState as useState12 } from "react";
1362
+ function useForm(proxyUrl, productId) {
1363
+ const [state, setState] = useState12({ form: null, loading: true, error: null });
1364
+ useEffect5(() => {
1365
+ let cancelled = false;
1366
+ const params = new URLSearchParams({ productId, limit: "1", page: "1" });
1367
+ fetch(`${proxyUrl}/reviews?${params.toString()}`).then((res) => {
1368
+ if (!res.ok) throw new Error(`Revova: form fetch failed (${res.status})`);
1369
+ return res.json();
1370
+ }).then(({ form }) => {
1371
+ if (!cancelled) setState({ form, loading: false, error: null });
1372
+ }).catch((err) => {
1373
+ if (!cancelled)
1374
+ setState({
1375
+ form: null,
1376
+ loading: false,
1377
+ error: err instanceof Error ? err : new Error(String(err))
1378
+ });
1379
+ });
1380
+ return () => {
1381
+ cancelled = true;
1382
+ };
1383
+ }, [proxyUrl, productId]);
1384
+ return state;
1385
+ }
1386
+
1387
+ // src/components/FloatingReviewButton.tsx
1388
+ import { Fragment as Fragment5, jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
1389
+ function FloatingReviewButton({
1390
+ proxyUrl,
1391
+ productId,
1392
+ text = "Write a Review",
1393
+ color = "#111827",
1394
+ position = "bottom-right",
1395
+ className
1396
+ }) {
1397
+ const [open, setOpen] = useState13(false);
1398
+ const { form, loading } = useForm(proxyUrl, productId);
1399
+ const posStyle = position === "bottom-right" ? { bottom: 24, right: 24 } : { bottom: 24, left: 24 };
1400
+ if (!loading && !form) return null;
1401
+ return /* @__PURE__ */ jsxs12(Fragment5, { children: [
1402
+ /* @__PURE__ */ jsxs12(
1403
+ "button",
1404
+ {
1405
+ className,
1406
+ onClick: () => setOpen(true),
1407
+ disabled: loading,
1408
+ "aria-label": text,
1409
+ style: {
1410
+ position: "fixed",
1411
+ zIndex: 9998,
1412
+ ...posStyle,
1413
+ background: color,
1414
+ color: "#fff",
1415
+ border: "none",
1416
+ borderRadius: 999,
1417
+ padding: "12px 20px",
1418
+ fontSize: 14,
1419
+ fontWeight: 600,
1420
+ cursor: loading ? "wait" : "pointer",
1421
+ boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
1422
+ opacity: loading ? 0.7 : 1
1423
+ },
1424
+ children: [
1425
+ "\u270E ",
1426
+ text
1427
+ ]
1428
+ }
1429
+ ),
1430
+ open && form && /* @__PURE__ */ jsx12(
1431
+ "div",
1432
+ {
1433
+ role: "dialog",
1434
+ "aria-modal": "true",
1435
+ "aria-label": "Write a Review",
1436
+ style: {
1437
+ position: "fixed",
1438
+ inset: 0,
1439
+ background: "rgba(0,0,0,0.5)",
1440
+ zIndex: 99999,
1441
+ display: "flex",
1442
+ alignItems: "center",
1443
+ justifyContent: "center",
1444
+ padding: 16
1445
+ },
1446
+ onClick: () => setOpen(false),
1447
+ children: /* @__PURE__ */ jsxs12(
1448
+ "div",
1449
+ {
1450
+ style: {
1451
+ background: "#fff",
1452
+ borderRadius: 12,
1453
+ padding: 24,
1454
+ maxWidth: 480,
1455
+ width: "100%",
1456
+ maxHeight: "90vh",
1457
+ overflowY: "auto"
1458
+ },
1459
+ onClick: (e) => e.stopPropagation(),
1460
+ children: [
1461
+ /* @__PURE__ */ jsxs12("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }, children: [
1462
+ /* @__PURE__ */ jsx12("h2", { style: { margin: 0, fontSize: 18 }, children: "Write a Review" }),
1463
+ /* @__PURE__ */ jsx12(
1464
+ "button",
1465
+ {
1466
+ onClick: () => setOpen(false),
1467
+ "aria-label": "Close",
1468
+ style: { background: "none", border: "none", cursor: "pointer", fontSize: 20, lineHeight: 1 },
1469
+ children: "\xD7"
1470
+ }
1471
+ )
1472
+ ] }),
1473
+ /* @__PURE__ */ jsx12(
1474
+ ReviewForm,
1475
+ {
1476
+ proxyUrl,
1477
+ productId,
1478
+ form,
1479
+ onSuccess: () => setOpen(false)
1480
+ }
1481
+ )
1482
+ ]
1483
+ }
1484
+ )
1485
+ }
1486
+ )
1487
+ ] });
1488
+ }
1489
+
1490
+ // src/hooks/useHelpfulVote.ts
1491
+ import { useState as useState14, useCallback as useCallback4 } from "react";
1492
+ function useHelpfulVote(proxyUrl) {
1493
+ const [loading, setLoading] = useState14(false);
1494
+ const [voted, setVoted] = useState14(false);
1495
+ const vote = useCallback4(
1496
+ async (payload) => {
1497
+ if (voted || loading) return;
1498
+ setLoading(true);
1499
+ try {
1500
+ await fetch(`${proxyUrl}/helpful`, {
1501
+ method: "POST",
1502
+ headers: { "Content-Type": "application/json" },
1503
+ body: JSON.stringify(payload)
1504
+ });
1505
+ setVoted(true);
1506
+ } finally {
1507
+ setLoading(false);
1508
+ }
1509
+ },
1510
+ [proxyUrl, voted, loading]
1511
+ );
1512
+ return { vote, loading, voted };
1513
+ }
1514
+ export {
1515
+ FloatingReviewButton,
1516
+ FloatingReviewsTab,
1517
+ QnAWidget,
1518
+ ReviewCarousel,
1519
+ ReviewCount,
1520
+ ReviewForm,
1521
+ ReviewGallery,
1522
+ ReviewTicker,
1523
+ ReviewWidget,
1524
+ SocialProofPopup,
1525
+ StarRating,
1526
+ TrustBadge,
1527
+ useForm,
1528
+ useHelpfulVote,
1529
+ useQnA,
1530
+ useReviews,
1531
+ useSubmitReview,
1532
+ useWidgetGlobals
1533
+ };