@cartbase/storefront 0.1.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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,249 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { ShieldCheck, X } from "lucide-react"
5
+ import type { PublicReview } from "../api/reviews"
6
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
7
+ import { formatReviewDate, reviewDisplayName } from "./helpers"
8
+ import { StarRow } from "./star-badge"
9
+
10
+ /**
11
+ * Review cards (masonry) + lightbox. Ported from the Alenika PDP reviews
12
+ * section (src/components/product/reviews-list.tsx — the card grid +
13
+ * `Lightbox`), presentational half: the fetch/sort/paging orchestration
14
+ * lives in `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden
15
+ * media is filtered out server-side — the client just renders what it
16
+ * gets).
17
+ *
18
+ * Verified-purchaser badge is unconditional — every barter review comes
19
+ * from a tokenized post-purchase email, so the badge is a tautology of
20
+ * the system, not an opt-in flag.
21
+ *
22
+ * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
23
+ * (short) pack together without empty gaps; `break-inside-avoid` keeps
24
+ * each card whole. Images-first ordering comes from the API's default
25
+ * sort, so photo cards lead the flow. `layout="list"` (the store's
26
+ * widget_layout setting) renders a single column instead.
27
+ */
28
+ export interface ReviewListProps {
29
+ reviews: PublicReview[]
30
+ /** Store display option (`getWidget().options.layout`). Default masonry. */
31
+ layout?: "masonry" | "list"
32
+ labels?: Partial<ReviewsUiLabels>
33
+ className?: string
34
+ }
35
+
36
+ export function ReviewList({
37
+ reviews,
38
+ layout = "masonry",
39
+ labels,
40
+ className,
41
+ }: ReviewListProps) {
42
+ const l = { ...defaultReviewsUiLabels, ...labels }
43
+ // Lightbox: when set, the indicated review's media at the given index
44
+ // is shown full-bleed.
45
+ const [lightbox, setLightbox] = useState<{
46
+ reviewId: string
47
+ mediaIdx: number
48
+ } | 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) => {
61
+ const media = (r.media ?? []).filter(Boolean)
62
+ const hero = media[0]
63
+ const rest = media.slice(1)
64
+ return (
65
+ <li
66
+ key={r.id}
67
+ className="break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
68
+ >
69
+ {/* Hero media on top */}
70
+ {hero && (
71
+ <button
72
+ type="button"
73
+ onClick={() => setLightbox({ reviewId: r.id, mediaIdx: 0 })}
74
+ aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
75
+ className="block w-full relative group"
76
+ >
77
+ {hero.type === "image" ? (
78
+ <img
79
+ src={hero.thumb || hero.url}
80
+ alt=""
81
+ loading="lazy"
82
+ className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
83
+ />
84
+ ) : (
85
+ <>
86
+ <video
87
+ src={hero.url}
88
+ muted
89
+ preload="metadata"
90
+ className="block w-full h-auto object-cover bg-black"
91
+ />
92
+ <span className="absolute inset-0 flex items-center justify-center">
93
+ <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
94
+ <span
95
+ aria-hidden
96
+ className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
97
+ />
98
+ </span>
99
+ </span>
100
+ </>
101
+ )}
102
+ </button>
103
+ )}
104
+
105
+ <div className="p-4 sm:p-5">
106
+ <StarRow rating={r.rating} />
107
+
108
+ <div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
109
+ <span className="text-sm font-medium text-foreground">
110
+ {reviewDisplayName(r.customer_name)}
111
+ </span>
112
+ <span
113
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
114
+ title={l.verifiedTitle}
115
+ >
116
+ <ShieldCheck className="w-3.5 h-3.5" />
117
+ {l.verified}
118
+ </span>
119
+ <span className="text-xs text-muted-foreground ml-auto">
120
+ {formatReviewDate(r.created_at, l)}
121
+ </span>
122
+ </div>
123
+
124
+ {r.title && (
125
+ <h3 className="text-base font-semibold text-foreground leading-snug mt-3">
126
+ {r.title}
127
+ </h3>
128
+ )}
129
+ {r.body && (
130
+ <p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
131
+ {r.body}
132
+ </p>
133
+ )}
134
+
135
+ {/* Extra media beyond the hero */}
136
+ {rest.length > 0 && (
137
+ <div className="grid grid-cols-4 gap-2 mt-3">
138
+ {rest.map((m, i) => (
139
+ <button
140
+ key={i}
141
+ type="button"
142
+ onClick={() =>
143
+ setLightbox({ reviewId: r.id, mediaIdx: i + 1 })
144
+ }
145
+ aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
146
+ className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
147
+ >
148
+ {m.type === "image" ? (
149
+ <img
150
+ src={m.thumb || m.url}
151
+ alt=""
152
+ loading="lazy"
153
+ className="absolute inset-0 w-full h-full object-cover"
154
+ />
155
+ ) : (
156
+ <video
157
+ src={m.url}
158
+ muted
159
+ preload="metadata"
160
+ className="absolute inset-0 w-full h-full object-cover bg-black"
161
+ />
162
+ )}
163
+ </button>
164
+ ))}
165
+ </div>
166
+ )}
167
+ </div>
168
+ </li>
169
+ )
170
+ })}
171
+ </ul>
172
+
173
+ {lightbox && (
174
+ <ReviewLightbox
175
+ review={reviews.find((r) => r.id === lightbox.reviewId)}
176
+ mediaIdx={lightbox.mediaIdx}
177
+ closeLabel={l.close}
178
+ onClose={() => setLightbox(null)}
179
+ />
180
+ )}
181
+ </>
182
+ )
183
+ }
184
+
185
+ /** Simple full-bleed overlay for the picked media. Closes on Escape/backdrop. */
186
+ export function ReviewLightbox({
187
+ review,
188
+ mediaIdx,
189
+ closeLabel,
190
+ onClose,
191
+ }: {
192
+ review: PublicReview | undefined
193
+ mediaIdx: number
194
+ closeLabel?: string
195
+ onClose: () => void
196
+ }) {
197
+ // Close on Escape
198
+ useEffect(() => {
199
+ const onKey = (e: KeyboardEvent) => {
200
+ if (e.key === "Escape") onClose()
201
+ }
202
+ window.addEventListener("keydown", onKey)
203
+ return () => window.removeEventListener("keydown", onKey)
204
+ }, [onClose])
205
+
206
+ if (!review) return null
207
+ // Index into the SAME filtered array the grid used to compute mediaIdx,
208
+ // so a falsy/hidden entry can't shift which item opens (production fix).
209
+ const items = (review.media ?? []).filter(Boolean)
210
+ const item = items[mediaIdx]
211
+ if (!item) return null
212
+
213
+ return (
214
+ <div
215
+ role="dialog"
216
+ aria-modal="true"
217
+ className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
218
+ onClick={onClose}
219
+ >
220
+ <button
221
+ type="button"
222
+ aria-label={closeLabel ?? defaultReviewsUiLabels.close}
223
+ onClick={onClose}
224
+ className="absolute top-4 right-4 w-10 h-10 rounded-full bg-background/10 hover:bg-background/20 text-background flex items-center justify-center"
225
+ >
226
+ <X className="w-5 h-5" />
227
+ </button>
228
+ <div
229
+ className="max-w-5xl max-h-[90vh] w-full flex items-center justify-center"
230
+ onClick={(e) => e.stopPropagation()}
231
+ >
232
+ {item.type === "image" ? (
233
+ <img
234
+ src={item.url}
235
+ alt=""
236
+ className="max-w-full max-h-[90vh] object-contain"
237
+ />
238
+ ) : (
239
+ <video
240
+ src={item.url}
241
+ controls
242
+ autoPlay
243
+ className="max-w-full max-h-[90vh]"
244
+ />
245
+ )}
246
+ </div>
247
+ </div>
248
+ )
249
+ }
@@ -0,0 +1,224 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useState } from "react"
4
+ import type { StorefrontClient } from "../api/http"
5
+ import {
6
+ getWidget,
7
+ listReviews,
8
+ type PublicReview,
9
+ type ReviewWidgetPayload,
10
+ } from "../api/reviews"
11
+ import { cn } from "../lib/utils"
12
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
13
+ import { sortParamsFor, type ReviewSortKey } from "./helpers"
14
+ import { RatingDistribution, StarBadge } from "./star-badge"
15
+ import { ReviewList } from "./review-list"
16
+
17
+ /**
18
+ * PDP reviews widget: aggregate header (score + distribution + sort) +
19
+ * masonry card list + load-more. Ported from the Alenika PDP section
20
+ * (src/components/product/reviews-list.tsx) with the barter data seam:
21
+ *
22
+ * - Bootstrap is ONE call — `api/reviews.getWidget(client, productId)`
23
+ * (aggregate + first page sized/sorted per the store's Settings →
24
+ * Reviews display options; edge-cached 60s). Server-fetch it and pass
25
+ * `initialData` (recommended — no client waterfall, mirrors the
26
+ * production server-fetched first page); when omitted the widget
27
+ * fetches it on mount.
28
+ * - Sort changes and load-more re-fetch client-side via `listReviews`
29
+ * (the API enforces ordering; the client just passes params — the
30
+ * default sort is with-media-first then newest).
31
+ * - Aggregate fallback preserved (production fix): if the aggregate is
32
+ * missing but reviews loaded, derive avg + distribution from the
33
+ * loaded page rather than showing a misleading "0.0" next to a filled
34
+ * star. The whole section renders null only when there are genuinely
35
+ * zero reviews.
36
+ *
37
+ * Settings that shape it (admin → Settings → Reviews): `widget_layout`
38
+ * (masonry|list), `widget_page_size`, `widget_photo_first` — all arrive
39
+ * via `getWidget().options`; moderation decides what is visible at all.
40
+ */
41
+ export interface ReviewWidgetProps {
42
+ client: StorefrontClient
43
+ productId: string
44
+ /** Server-fetched `getWidget` payload (recommended). */
45
+ initialData?: ReviewWidgetPayload | null
46
+ labels?: Partial<ReviewsUiLabels>
47
+ className?: string
48
+ }
49
+
50
+ export function ReviewWidget({
51
+ client,
52
+ productId,
53
+ initialData,
54
+ labels,
55
+ className,
56
+ }: ReviewWidgetProps) {
57
+ const l = { ...defaultReviewsUiLabels, ...labels }
58
+ const [data, setData] = useState<ReviewWidgetPayload | null>(
59
+ initialData ?? null
60
+ )
61
+ const [sort, setSort] = useState<ReviewSortKey>("default")
62
+ const [reviews, setReviews] = useState<PublicReview[]>(
63
+ initialData?.reviews ?? []
64
+ )
65
+ const [count, setCount] = useState<number>(initialData?.count ?? 0)
66
+ const [hasMore, setHasMore] = useState<boolean>(initialData?.has_more ?? false)
67
+ const [loading, setLoading] = useState(false)
68
+
69
+ const pageSize = data?.options.page_size ?? 10
70
+
71
+ // No server-provided bootstrap → fetch it on mount.
72
+ useEffect(() => {
73
+ if (initialData) return
74
+ let cancelled = false
75
+ ;(async () => {
76
+ try {
77
+ const payload = await getWidget(client, productId)
78
+ if (cancelled) return
79
+ setData(payload)
80
+ setReviews(payload.reviews)
81
+ setCount(payload.count)
82
+ setHasMore(payload.has_more)
83
+ } catch {
84
+ /* widget is decorative — a failed bootstrap renders nothing */
85
+ }
86
+ })()
87
+ return () => {
88
+ cancelled = true
89
+ }
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [client, productId])
92
+
93
+ // Re-fetch the first page whenever the sort changes (post-mount only —
94
+ // the bootstrap page covers sort='default', so no extra round-trip).
95
+ useEffect(() => {
96
+ if (sort === "default") return
97
+ let cancelled = false
98
+ setLoading(true)
99
+ ;(async () => {
100
+ try {
101
+ const params = sortParamsFor(sort)
102
+ const res = await listReviews(client, {
103
+ product_id: productId,
104
+ ...params,
105
+ limit: pageSize,
106
+ offset: 0,
107
+ })
108
+ if (cancelled) return
109
+ setReviews(res.reviews)
110
+ setCount(res.count)
111
+ setHasMore(res.has_more)
112
+ } finally {
113
+ if (!cancelled) setLoading(false)
114
+ }
115
+ })()
116
+ return () => {
117
+ cancelled = true
118
+ }
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ }, [sort, productId, pageSize])
121
+
122
+ const loadMore = useCallback(async () => {
123
+ setLoading(true)
124
+ try {
125
+ const params = sortParamsFor(sort)
126
+ const res = await listReviews(client, {
127
+ product_id: productId,
128
+ ...params,
129
+ limit: pageSize,
130
+ offset: reviews.length,
131
+ })
132
+ setReviews((prev) => [...prev, ...res.reviews])
133
+ setCount(res.count)
134
+ setHasMore(res.has_more)
135
+ } finally {
136
+ setLoading(false)
137
+ }
138
+ }, [client, productId, sort, pageSize, reviews.length])
139
+
140
+ // Header summary uses the full aggregate when available; else derive
141
+ // from the loaded reviews (see the header comment).
142
+ const aggregate = data?.aggregate ?? null
143
+ const total = aggregate?.count ?? count
144
+ const avg =
145
+ aggregate?.avg_rating ??
146
+ (reviews.length
147
+ ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length
148
+ : 0)
149
+ const distribution =
150
+ aggregate?.distribution ??
151
+ reviews.reduce(
152
+ (d, r) => {
153
+ const key = String(r.rating) as "1" | "2" | "3" | "4" | "5"
154
+ if (key in d) d[key]++
155
+ return d
156
+ },
157
+ { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<
158
+ "1" | "2" | "3" | "4" | "5",
159
+ number
160
+ >
161
+ )
162
+
163
+ if (total === 0) {
164
+ return null // Hide the section entirely when there are no reviews
165
+ }
166
+
167
+ return (
168
+ <section
169
+ id="reviews"
170
+ className={
171
+ className ?? "max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-10 my-16 sm:my-24"
172
+ }
173
+ >
174
+ <header className="mb-10">
175
+ <h2 className="text-2xl sm:text-3xl font-semibold text-foreground mb-6">
176
+ {l.sectionTitle}
177
+ </h2>
178
+
179
+ <div className="flex flex-col md:flex-row md:items-center gap-6 md:gap-12">
180
+ <StarBadge
181
+ aggregate={{ count: total, avg_rating: avg }}
182
+ labels={l}
183
+ />
184
+
185
+ <RatingDistribution distribution={distribution} total={total} />
186
+
187
+ <select
188
+ value={sort}
189
+ onChange={(e) => setSort(e.target.value as ReviewSortKey)}
190
+ aria-label={l.sortAria}
191
+ 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"
192
+ >
193
+ <option value="default">{l.sortDefault}</option>
194
+ <option value="date">{l.sortNewest}</option>
195
+ <option value="rating-desc">{l.sortRatingDesc}</option>
196
+ <option value="rating-asc">{l.sortRatingAsc}</option>
197
+ </select>
198
+ </div>
199
+ </header>
200
+
201
+ <ReviewList
202
+ reviews={reviews}
203
+ layout={data?.options.layout ?? "masonry"}
204
+ labels={l}
205
+ />
206
+
207
+ {hasMore && (
208
+ <div className="mt-8 text-center">
209
+ <button
210
+ type="button"
211
+ onClick={loadMore}
212
+ disabled={loading}
213
+ className={cn(
214
+ "inline-flex items-center justify-center px-6 py-2.5 rounded-[2px] border border-border text-sm font-medium transition-colors",
215
+ loading ? "opacity-50 cursor-not-allowed" : "hover:bg-muted"
216
+ )}
217
+ >
218
+ {loading ? l.loading : l.loadMore}
219
+ </button>
220
+ </div>
221
+ )}
222
+ </section>
223
+ )
224
+ }