@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|