@cartbase/storefront 0.19.0 → 0.20.1

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 (105) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/reviews.ts +22 -1
  20. package/src/api/search.ts +163 -163
  21. package/src/api/store.ts +35 -35
  22. package/src/api/types.ts +91 -91
  23. package/src/cart-drawer/context.tsx +778 -778
  24. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  25. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  26. package/src/cart-drawer/gift-wrap.tsx +82 -82
  27. package/src/cart-drawer/item/index.tsx +162 -162
  28. package/src/cart-drawer/item/upsell.tsx +110 -110
  29. package/src/cart-drawer/labels.ts +123 -123
  30. package/src/cart-drawer/mutation-queue.ts +78 -78
  31. package/src/cart-drawer/notes.tsx +131 -131
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/index.ts +20 -20
  49. package/src/common/market-select.tsx +57 -57
  50. package/src/index.ts +12 -12
  51. package/src/lib/country-name.ts +59 -59
  52. package/src/lib/get-product-price.ts +133 -133
  53. package/src/lib/media-image.tsx +39 -39
  54. package/src/lib/payment-constants.ts +53 -53
  55. package/src/lib/platform.ts +13 -13
  56. package/src/lib/price.tsx +39 -39
  57. package/src/lib/store-api-error.ts +36 -36
  58. package/src/lib/variant-caption.ts +32 -32
  59. package/src/locales/bg.ts +3 -2
  60. package/src/locales/context.ts +37 -37
  61. package/src/locales/en.ts +26 -26
  62. package/src/locales/es.ts +3 -2
  63. package/src/locales/index.ts +19 -19
  64. package/src/locales/provider.tsx +59 -59
  65. package/src/locales/types.ts +77 -77
  66. package/src/order/index.ts +62 -62
  67. package/src/order/labels.ts +79 -79
  68. package/src/order/order-totals.tsx +250 -250
  69. package/src/primitives/select-field.tsx +93 -93
  70. package/src/products/image-gallery.tsx +43 -43
  71. package/src/products/option-select.tsx +67 -67
  72. package/src/products/product-info.tsx +5 -5
  73. package/src/products/product-price.tsx +69 -69
  74. package/src/products/product-promises.tsx +61 -61
  75. package/src/products/product-specs.tsx +65 -65
  76. package/src/products/product-tabs.tsx +123 -123
  77. package/src/products/purchase-options.tsx +130 -130
  78. package/src/products/sets.ts +110 -110
  79. package/src/products/variant-matching.ts +71 -71
  80. package/src/products/variant-url.ts +74 -74
  81. package/src/reviews-ui/index.ts +12 -0
  82. package/src/reviews-ui/labels.ts +5 -2
  83. package/src/reviews-ui/lightbox-state.ts +46 -46
  84. package/src/reviews-ui/review-header.tsx +166 -0
  85. package/src/reviews-ui/review-lightbox.tsx +271 -271
  86. package/src/reviews-ui/review-list.tsx +193 -191
  87. package/src/reviews-ui/review-widget.tsx +63 -71
  88. package/src/reviews-ui/star-badge.tsx +6 -4
  89. package/src/reviews-ui/widget-options.ts +55 -0
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
@@ -1,191 +1,193 @@
1
- "use client"
2
-
3
- import { useState } from "react"
4
- import { ShieldCheck } from "lucide-react"
5
- import type { PublicReview } from "../api/reviews"
6
- import { useLocaleArea } from "../locales/context"
7
- import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
8
- import { formatReviewDate, reviewDisplayName } from "./helpers"
9
- import { lightboxMedia, type LightboxPosition } from "./lightbox-state"
10
- import { ReviewLightbox } from "./review-lightbox"
11
- import { StarRow } from "./star-badge"
12
-
13
- /**
14
- * Review cards (masonry) + the review lightbox. Ported from the Alenika PDP
15
- * reviews section (src/components/product/reviews-list.tsx, the card
16
- * grid), presentational half: the fetch/sort/paging orchestration lives in
17
- * `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden media is
18
- * filtered out server-side, the client just renders what it gets).
19
- *
20
- * A click anywhere on a card opens that review in `ReviewLightbox`; a click
21
- * on one of its photos opens it on that photo.
22
- *
23
- * Verified-purchaser badge is unconditional every Cartbase review comes
24
- * from a tokenized post-purchase email, so the badge is a tautology of
25
- * the system, not an opt-in flag.
26
- *
27
- * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
28
- * (short) pack together without empty gaps; `break-inside-avoid` keeps
29
- * each card whole. Images-first ordering comes from the API's default
30
- * sort, so photo cards lead the flow. `layout="list"` (the store's
31
- * widget_layout setting) renders a single column instead.
32
- */
33
- export interface ReviewListProps {
34
- reviews: PublicReview[]
35
- /** Store display option (`getWidget().options.layout`). Default masonry. */
36
- layout?: "masonry" | "list"
37
- labels?: Partial<ReviewsUiLabels>
38
- className?: string
39
- }
40
-
41
- export function ReviewList({
42
- reviews,
43
- layout = "masonry",
44
- labels,
45
- className,
46
- }: ReviewListProps) {
47
- const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
48
- const [open, setOpen] = useState<LightboxPosition | null>(null)
49
-
50
- return (
51
- <>
52
- <ul
53
- className={
54
- className ??
55
- (layout === "list"
56
- ? "columns-1 [column-gap:1rem]"
57
- : "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
58
- }
59
- >
60
- {reviews.map((r, index) => {
61
- const media = lightboxMedia(r)
62
- const hero = media[0]
63
- const rest = media.slice(1)
64
- return (
65
- <li
66
- key={r.id}
67
- className="relative break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
68
- >
69
- {/* The whole card is the button; the photos sit above it. */}
70
- <button
71
- type="button"
72
- onClick={() => setOpen({ review: index, media: 0 })}
73
- aria-label={`${l.openReview}: ${reviewDisplayName(r.customer_name)}`}
74
- className="absolute inset-0 z-0 cursor-pointer"
75
- />
76
-
77
- {/* Hero media on top */}
78
- {hero && (
79
- <button
80
- type="button"
81
- onClick={() => setOpen({ review: index, media: 0 })}
82
- aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
83
- className="relative z-10 block w-full group"
84
- >
85
- {hero.type === "image" ? (
86
- <img
87
- src={hero.thumb || hero.url}
88
- alt=""
89
- loading="lazy"
90
- className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
91
- />
92
- ) : (
93
- <>
94
- {/* `#t=0.001`: iOS Safari paints no first frame from
95
- `preload="metadata"` alone; the seek makes it. */}
96
- <video
97
- src={`${hero.url}#t=0.001`}
98
- muted
99
- playsInline
100
- preload="metadata"
101
- className="block w-full h-auto object-cover bg-black"
102
- />
103
- <span className="absolute inset-0 flex items-center justify-center">
104
- <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
105
- <span
106
- aria-hidden
107
- className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
108
- />
109
- </span>
110
- </span>
111
- </>
112
- )}
113
- </button>
114
- )}
115
-
116
- <div className="p-4 sm:p-5">
117
- <StarRow rating={r.rating} />
118
-
119
- <div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
120
- <span className="text-sm font-medium text-foreground">
121
- {reviewDisplayName(r.customer_name)}
122
- </span>
123
- <span
124
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
125
- title={l.verifiedTitle}
126
- >
127
- <ShieldCheck className="w-3.5 h-3.5" />
128
- {l.verified}
129
- </span>
130
- <span className="text-xs text-muted-foreground ml-auto">
131
- {formatReviewDate(r.created_at, l)}
132
- </span>
133
- </div>
134
-
135
- {r.title && (
136
- <h3 className="text-base font-semibold text-foreground leading-snug mt-3">
137
- {r.title}
138
- </h3>
139
- )}
140
- {r.body && (
141
- <p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
142
- {r.body}
143
- </p>
144
- )}
145
-
146
- {/* Extra media beyond the hero */}
147
- {rest.length > 0 && (
148
- <div className="relative z-10 grid grid-cols-4 gap-2 mt-3">
149
- {rest.map((m, i) => (
150
- <button
151
- key={i}
152
- type="button"
153
- onClick={() => setOpen({ review: index, media: i + 1 })}
154
- aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
155
- className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
156
- >
157
- {m.type === "image" ? (
158
- <img
159
- src={m.thumb || m.url}
160
- alt=""
161
- loading="lazy"
162
- className="absolute inset-0 w-full h-full object-cover"
163
- />
164
- ) : (
165
- <video
166
- src={`${m.url}#t=0.001`}
167
- muted
168
- playsInline
169
- preload="metadata"
170
- className="absolute inset-0 w-full h-full object-cover bg-black"
171
- />
172
- )}
173
- </button>
174
- ))}
175
- </div>
176
- )}
177
- </div>
178
- </li>
179
- )
180
- })}
181
- </ul>
182
-
183
- <ReviewLightbox
184
- reviews={reviews}
185
- position={open}
186
- onPositionChange={setOpen}
187
- labels={labels}
188
- />
189
- </>
190
- )
191
- }
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { CircleCheck } from "lucide-react"
5
+ import type { PublicReview } from "../api/reviews"
6
+ import { useLocaleArea } from "../locales/context"
7
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
8
+ import { formatReviewDate, reviewDisplayName } from "./helpers"
9
+ import { lightboxMedia, type LightboxPosition } from "./lightbox-state"
10
+ import { ReviewLightbox } from "./review-lightbox"
11
+ import { StarRow } from "./star-badge"
12
+
13
+ /**
14
+ * Review cards (masonry) + the review lightbox. Ported from the Alenika PDP
15
+ * reviews section (src/components/product/reviews-list.tsx, the card
16
+ * grid), presentational half: the fetch/sort/paging orchestration lives in
17
+ * `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden media is
18
+ * filtered out server-side, the client just renders what it gets).
19
+ *
20
+ * A card (Loox's, as evoo.bg runs it): the first photo on top at its own
21
+ * height, then the reviewer with the verified badge, the date, the stars and
22
+ * the text. A click anywhere on a card opens that review in
23
+ * `ReviewLightbox`; a click on one of its photos opens it on that photo.
24
+ *
25
+ * Verified-purchaser badge is unconditional every Cartbase review comes
26
+ * from a tokenized post-purchase email, so the badge is a tautology of
27
+ * the system, not an opt-in flag.
28
+ *
29
+ * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
30
+ * (short) pack together without empty gaps; `break-inside-avoid` keeps
31
+ * each card whole. Images-first ordering comes from the API's default
32
+ * sort, so photo cards lead the flow. `layout="list"` (the store's
33
+ * widget_layout setting) renders a single column instead.
34
+ */
35
+ export interface ReviewListProps {
36
+ reviews: PublicReview[]
37
+ /** Store display option (`getWidget().options.layout`). Default masonry. */
38
+ layout?: "masonry" | "list"
39
+ /** The date on each card (`getWidget().options.show_date`). Default on. */
40
+ showDate?: boolean
41
+ labels?: Partial<ReviewsUiLabels>
42
+ className?: string
43
+ }
44
+
45
+ export function ReviewList({
46
+ reviews,
47
+ layout = "masonry",
48
+ showDate = true,
49
+ labels,
50
+ className,
51
+ }: ReviewListProps) {
52
+ const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
53
+ const [open, setOpen] = useState<LightboxPosition | null>(null)
54
+
55
+ return (
56
+ <>
57
+ <ul
58
+ className={
59
+ className ??
60
+ (layout === "list"
61
+ ? "columns-1 [column-gap:1rem]"
62
+ : "columns-2 sm:columns-[14rem] [column-gap:1rem]")
63
+ }
64
+ >
65
+ {reviews.map((r, index) => {
66
+ const media = lightboxMedia(r)
67
+ const hero = media[0]
68
+ const rest = media.slice(1)
69
+ return (
70
+ <li
71
+ key={r.id}
72
+ className="relative mb-4 break-inside-avoid overflow-hidden rounded-lg bg-card shadow-card"
73
+ >
74
+ {/* The whole card is the button; the photos sit above it. */}
75
+ <button
76
+ type="button"
77
+ onClick={() => setOpen({ review: index, media: 0 })}
78
+ aria-label={`${l.openReview}: ${reviewDisplayName(r.customer_name)}`}
79
+ className="absolute inset-0 z-0 cursor-pointer"
80
+ />
81
+
82
+ {/* Hero media on top */}
83
+ {hero && (
84
+ <button
85
+ type="button"
86
+ onClick={() => setOpen({ review: index, media: 0 })}
87
+ aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
88
+ className="relative z-10 block w-full group"
89
+ >
90
+ {hero.type === "image" ? (
91
+ <img
92
+ src={hero.thumb || hero.url}
93
+ alt=""
94
+ loading="lazy"
95
+ className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
96
+ />
97
+ ) : (
98
+ <>
99
+ {/* `#t=0.001`: iOS Safari paints no first frame from
100
+ `preload="metadata"` alone; the seek makes it. */}
101
+ <video
102
+ src={`${hero.url}#t=0.001`}
103
+ muted
104
+ playsInline
105
+ preload="metadata"
106
+ className="block w-full h-auto object-cover bg-black"
107
+ />
108
+ <span className="absolute inset-0 flex items-center justify-center">
109
+ <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
110
+ <span
111
+ aria-hidden
112
+ className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
113
+ />
114
+ </span>
115
+ </span>
116
+ </>
117
+ )}
118
+ </button>
119
+ )}
120
+
121
+ <div className="p-4">
122
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
123
+ <span className="text-body font-semibold text-foreground">
124
+ {reviewDisplayName(r.customer_name)}
125
+ </span>
126
+ <span
127
+ className="inline-flex items-center gap-1 text-caption text-foreground"
128
+ title={l.verifiedTitle}
129
+ >
130
+ <CircleCheck aria-hidden className="size-4 fill-foreground text-background" />
131
+ {l.verified}
132
+ </span>
133
+ </div>
134
+ {showDate ? (
135
+ <p className="mt-0.5 text-caption text-muted-foreground">
136
+ {formatReviewDate(r.created_at, l)}
137
+ </p>
138
+ ) : null}
139
+ <StarRow rating={r.rating} size="md" className="mt-2" />
140
+
141
+ {r.title && (
142
+ <h3 className="mt-2 text-h6 text-foreground">{r.title}</h3>
143
+ )}
144
+ {r.body && (
145
+ <p className="mt-2 whitespace-pre-wrap text-body text-foreground">{r.body}</p>
146
+ )}
147
+
148
+ {/* Extra media beyond the hero */}
149
+ {rest.length > 0 && (
150
+ <div className="relative z-10 mt-3 grid grid-cols-4 gap-2">
151
+ {rest.map((m, i) => (
152
+ <button
153
+ key={i}
154
+ type="button"
155
+ onClick={() => setOpen({ review: index, media: i + 1 })}
156
+ aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
157
+ className="relative aspect-square overflow-hidden rounded-md bg-muted"
158
+ >
159
+ {m.type === "image" ? (
160
+ <img
161
+ src={m.thumb || m.url}
162
+ alt=""
163
+ loading="lazy"
164
+ className="absolute inset-0 w-full h-full object-cover"
165
+ />
166
+ ) : (
167
+ <video
168
+ src={`${m.url}#t=0.001`}
169
+ muted
170
+ playsInline
171
+ preload="metadata"
172
+ className="absolute inset-0 w-full h-full object-cover bg-black"
173
+ />
174
+ )}
175
+ </button>
176
+ ))}
177
+ </div>
178
+ )}
179
+ </div>
180
+ </li>
181
+ )
182
+ })}
183
+ </ul>
184
+
185
+ <ReviewLightbox
186
+ reviews={reviews}
187
+ position={open}
188
+ onPositionChange={setOpen}
189
+ labels={labels}
190
+ />
191
+ </>
192
+ )
193
+ }
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useEffect, useState } from "react"
3
+ import { useCallback, useEffect, useRef, useState } from "react"
4
4
  import type { StorefrontClient } from "../api/http"
5
5
  import {
6
6
  getWidget,
@@ -12,32 +12,35 @@ import { cn } from "../lib/utils"
12
12
  import { useLocaleArea } from "../locales/context"
13
13
  import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
14
14
  import { sortParamsFor, type ReviewSortKey } from "./helpers"
15
- import { RatingDistribution, StarBadge } from "./star-badge"
15
+ import { ReviewSummaryHeader } from "./review-header"
16
16
  import { ReviewList } from "./review-list"
17
+ import { reviewWidgetOptions } from "./widget-options"
17
18
 
18
19
  /**
19
- * PDP reviews widget: aggregate header (score + distribution + sort) +
20
- * masonry card list + load-more. Ported from the Alenika PDP section
20
+ * The product reviews widget, the reviews app's grid for the bottom of a
21
+ * product page (Loox's Reviews Widget): a header, the review cards, photo
22
+ * reviews first, and "Show more reviews" under them; a card opens its
23
+ * review in the lightbox. Ported from the Alenika PDP section
21
24
  * (src/components/product/reviews-list.tsx) with the Cartbase data seam:
22
25
  *
23
26
  * - Bootstrap is ONE call — `api/reviews.getWidget(client, productId)`
24
- * (aggregate + first page sized/sorted per the store's Settings
25
- * Reviews display options; edge-cached 60s). Server-fetch it and pass
26
- * `initialData` (recommended no client waterfall, mirrors the
27
- * production server-fetched first page); when omitted the widget
28
- * fetches it on mount.
29
- * - Sort changes and load-more re-fetch client-side via `listReviews`
30
- * (the API enforces ordering; the client just passes params the
31
- * default sort is with-media-first then newest).
27
+ * (aggregate + first page + the store's widget settings; edge-cached).
28
+ * Server-fetch it and pass `initialData` (recommended — no client
29
+ * waterfall); when omitted the widget fetches it on mount.
30
+ * - The store's settings (admin, Reviews, Widgets) arrive in
31
+ * `options` and shape it: the header (`minimal`, `compact`,
32
+ * `expanded`, and what the minimal one shows), the rating breakdown,
33
+ * the sort menu, the date on the cards, masonry or list, and the page
34
+ * size. A field an older platform does not send takes Loox's default
35
+ * (`reviewWidgetOptions`).
36
+ * - Sort changes and "Show more reviews" re-fetch client-side via
37
+ * `listReviews` (the API orders; the default sort is photo reviews
38
+ * first, then newest).
32
39
  * - Aggregate fallback preserved (production fix): if the aggregate is
33
40
  * missing but reviews loaded, derive avg + distribution from the
34
41
  * loaded page rather than showing a misleading "0.0" next to a filled
35
42
  * star. The whole section renders null only when there are genuinely
36
43
  * zero reviews.
37
- *
38
- * Settings that shape it (admin → Settings → Reviews): `widget_layout`
39
- * (masonry|list), `widget_page_size`, `widget_photo_first` — all arrive
40
- * via `getWidget().options`; moderation decides what is visible at all.
41
44
  */
42
45
  export interface ReviewWidgetProps {
43
46
  client: StorefrontClient
@@ -58,18 +61,19 @@ export function ReviewWidget({
58
61
  // The mounted language, then the store's overrides (2026-09-14): a store
59
62
  // that mounts StorefrontLocaleProvider passes nothing here.
60
63
  const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
61
- const [data, setData] = useState<ReviewWidgetPayload | null>(
62
- initialData ?? null
63
- )
64
- const [sort, setSort] = useState<ReviewSortKey>("default")
65
- const [reviews, setReviews] = useState<PublicReview[]>(
66
- initialData?.reviews ?? []
64
+ const [data, setData] = useState<ReviewWidgetPayload | null>(initialData ?? null)
65
+ // The first page is photo reviews first unless the store turned that off.
66
+ const [sort, setSort] = useState<ReviewSortKey>(
67
+ initialData?.options.photo_first === false ? "date" : "default"
67
68
  )
69
+ const [reviews, setReviews] = useState<PublicReview[]>(initialData?.reviews ?? [])
68
70
  const [count, setCount] = useState<number>(initialData?.count ?? 0)
69
71
  const [hasMore, setHasMore] = useState<boolean>(initialData?.has_more ?? false)
70
72
  const [loading, setLoading] = useState(false)
73
+ const sortChanged = useRef(false)
71
74
 
72
- const pageSize = data?.options.page_size ?? 10
75
+ const options = reviewWidgetOptions(data?.options)
76
+ const pageSize = options.pageSize
73
77
 
74
78
  // No server-provided bootstrap → fetch it on mount.
75
79
  useEffect(() => {
@@ -83,6 +87,7 @@ export function ReviewWidget({
83
87
  setReviews(payload.reviews)
84
88
  setCount(payload.count)
85
89
  setHasMore(payload.has_more)
90
+ if (payload.options.photo_first === false) setSort("date")
86
91
  } catch {
87
92
  /* widget is decorative — a failed bootstrap renders nothing */
88
93
  }
@@ -93,18 +98,17 @@ export function ReviewWidget({
93
98
  // eslint-disable-next-line react-hooks/exhaustive-deps
94
99
  }, [client, productId])
95
100
 
96
- // Re-fetch the first page whenever the sort changes (post-mount only —
97
- // the bootstrap page covers sort='default', so no extra round-trip).
101
+ // Re-fetch the first page when the shopper picks a sort (the bootstrap
102
+ // page already is the first sort, so the first run is skipped).
98
103
  useEffect(() => {
99
- if (sort === "default") return
104
+ if (!sortChanged.current) return
100
105
  let cancelled = false
101
106
  setLoading(true)
102
107
  ;(async () => {
103
108
  try {
104
- const params = sortParamsFor(sort)
105
109
  const res = await listReviews(client, {
106
110
  product_id: productId,
107
- ...params,
111
+ ...sortParamsFor(sort),
108
112
  limit: pageSize,
109
113
  offset: 0,
110
114
  })
@@ -122,13 +126,17 @@ export function ReviewWidget({
122
126
  // eslint-disable-next-line react-hooks/exhaustive-deps
123
127
  }, [sort, productId, pageSize])
124
128
 
129
+ const chooseSort = (next: ReviewSortKey) => {
130
+ sortChanged.current = true
131
+ setSort(next)
132
+ }
133
+
125
134
  const loadMore = useCallback(async () => {
126
135
  setLoading(true)
127
136
  try {
128
- const params = sortParamsFor(sort)
129
137
  const res = await listReviews(client, {
130
138
  product_id: productId,
131
- ...params,
139
+ ...sortParamsFor(sort),
132
140
  limit: pageSize,
133
141
  offset: reviews.length,
134
142
  })
@@ -146,9 +154,7 @@ export function ReviewWidget({
146
154
  const total = aggregate?.count ?? count
147
155
  const avg =
148
156
  aggregate?.avg_rating ??
149
- (reviews.length
150
- ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length
151
- : 0)
157
+ (reviews.length ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length : 0)
152
158
  const distribution =
153
159
  aggregate?.distribution ??
154
160
  reviews.reduce(
@@ -157,10 +163,7 @@ export function ReviewWidget({
157
163
  if (key in d) d[key]++
158
164
  return d
159
165
  },
160
- { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<
161
- "1" | "2" | "3" | "4" | "5",
162
- number
163
- >
166
+ { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<"1" | "2" | "3" | "4" | "5", number>
164
167
  )
165
168
 
166
169
  if (total === 0) {
@@ -170,41 +173,33 @@ export function ReviewWidget({
170
173
  return (
171
174
  <section
172
175
  id="reviews"
173
- className={
174
- className ?? "max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-10 my-16 sm:my-24"
175
- }
176
+ className={className ?? "max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-10 my-16 sm:my-24"}
176
177
  >
177
- <header className="mb-10">
178
- <h2 className="text-2xl sm:text-3xl font-semibold text-foreground mb-6">
179
- {l.sectionTitle}
180
- </h2>
181
-
182
- <div className="flex flex-col md:flex-row md:items-center gap-6 md:gap-12">
183
- <StarBadge
184
- aggregate={{ count: total, avg_rating: avg }}
185
- labels={l}
186
- />
187
-
188
- <RatingDistribution distribution={distribution} total={total} />
189
-
190
- <select
191
- value={sort}
192
- onChange={(e) => setSort(e.target.value as ReviewSortKey)}
193
- aria-label={l.sortAria}
194
- className="md:ml-auto md:self-start px-3 py-2 rounded-[2px] border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
195
- >
196
- <option value="default">{l.sortDefault}</option>
197
- <option value="date">{l.sortNewest}</option>
198
- <option value="rating-desc">{l.sortRatingDesc}</option>
199
- <option value="rating-asc">{l.sortRatingAsc}</option>
200
- </select>
201
- </div>
202
- </header>
178
+ <ReviewSummaryHeader
179
+ layout={options.header}
180
+ content={options.headerContent}
181
+ total={total}
182
+ average={avg}
183
+ distribution={distribution}
184
+ showDistribution={options.showDistribution}
185
+ showSort={options.showSort}
186
+ sort={sort}
187
+ onSortChange={chooseSort}
188
+ labels={l}
189
+ />
203
190
 
204
191
  <ReviewList
205
192
  reviews={reviews}
206
- layout={data?.options.layout ?? "masonry"}
193
+ layout={options.layout}
194
+ showDate={options.showDate}
207
195
  labels={l}
196
+ className={cn(
197
+ options.layout === "list"
198
+ ? "columns-1 [column-gap:1rem]"
199
+ : // Two on a phone, then as many ~14rem columns as the page is wide.
200
+ "columns-2 sm:columns-[14rem] [column-gap:1rem]",
201
+ loading && "opacity-60 transition-opacity"
202
+ )}
208
203
  />
209
204
 
210
205
  {hasMore && (
@@ -213,10 +208,7 @@ export function ReviewWidget({
213
208
  type="button"
214
209
  onClick={loadMore}
215
210
  disabled={loading}
216
- className={cn(
217
- "inline-flex items-center justify-center px-6 py-2.5 rounded-[2px] border border-border text-sm font-medium transition-colors",
218
- loading ? "opacity-50 cursor-not-allowed" : "hover:bg-muted"
219
- )}
211
+ className="inline-flex items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-body-small text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
220
212
  >
221
213
  {loading ? l.loading : l.loadMore}
222
214
  </button>