@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.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +22 -1
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +3 -2
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -2
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +12 -0
- package/src/reviews-ui/labels.ts +5 -2
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -191
- package/src/reviews-ui/review-widget.tsx +63 -71
- package/src/reviews-ui/star-badge.tsx +6 -4
- package/src/reviews-ui/widget-options.ts +55 -0
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
|
@@ -1,191 +1,193 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useState } from "react"
|
|
4
|
-
import {
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<span
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{l.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
</span>
|
|
133
|
-
</div>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 {
|
|
15
|
+
import { ReviewSummaryHeader } from "./review-header"
|
|
16
16
|
import { ReviewList } from "./review-list"
|
|
17
|
+
import { reviewWidgetOptions } from "./widget-options"
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
97
|
-
//
|
|
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 (
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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={
|
|
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=
|
|
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>
|