@cartbase/storefront 0.20.0 → 0.21.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/LICENSE +21 -21
- package/package.json +4 -1
- 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 +181 -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/search.ts +163 -163
- package/src/api/store.ts +56 -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/payment-badges.tsx +36 -96
- 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/icons/cartbase-mark.ts +9 -0
- package/src/common/icons/payment-marks.ts +73 -0
- package/src/common/icons/social-marks.ts +61 -0
- package/src/common/index.ts +35 -20
- package/src/common/market-select.tsx +57 -57
- package/src/common/payment-icons.tsx +54 -0
- package/src/common/powered-by-cartbase.tsx +47 -0
- package/src/common/social-links.tsx +65 -0
- 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 -0
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -0
- 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/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/store/labels.ts +10 -0
- 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,218 +1,219 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useRef, 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 { useLocaleArea } from "../locales/context"
|
|
13
|
-
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
14
|
-
import { sortParamsFor, type ReviewSortKey } from "./helpers"
|
|
15
|
-
import { ReviewSummaryHeader } from "./review-header"
|
|
16
|
-
import { ReviewList } from "./review-list"
|
|
17
|
-
import { reviewWidgetOptions } from "./widget-options"
|
|
18
|
-
|
|
19
|
-
/**
|
|
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
|
|
24
|
-
* (src/components/product/reviews-list.tsx) with the Cartbase data seam:
|
|
25
|
-
*
|
|
26
|
-
* - Bootstrap is ONE call — `api/reviews.getWidget(client, productId)`
|
|
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).
|
|
39
|
-
* - Aggregate fallback preserved (production fix): if the aggregate is
|
|
40
|
-
* missing but reviews loaded, derive avg + distribution from the
|
|
41
|
-
* loaded page rather than showing a misleading "0.0" next to a filled
|
|
42
|
-
* star. The whole section renders null only when there are genuinely
|
|
43
|
-
* zero reviews.
|
|
44
|
-
*/
|
|
45
|
-
export interface ReviewWidgetProps {
|
|
46
|
-
client: StorefrontClient
|
|
47
|
-
productId: string
|
|
48
|
-
/** Server-fetched `getWidget` payload (recommended). */
|
|
49
|
-
initialData?: ReviewWidgetPayload | null
|
|
50
|
-
labels?: Partial<ReviewsUiLabels>
|
|
51
|
-
className?: string
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function ReviewWidget({
|
|
55
|
-
client,
|
|
56
|
-
productId,
|
|
57
|
-
initialData,
|
|
58
|
-
labels,
|
|
59
|
-
className,
|
|
60
|
-
}: ReviewWidgetProps) {
|
|
61
|
-
// The mounted language, then the store's overrides (2026-09-14): a store
|
|
62
|
-
// that mounts StorefrontLocaleProvider passes nothing here.
|
|
63
|
-
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
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"
|
|
68
|
-
)
|
|
69
|
-
const [reviews, setReviews] = useState<PublicReview[]>(initialData?.reviews ?? [])
|
|
70
|
-
const [count, setCount] = useState<number>(initialData?.count ?? 0)
|
|
71
|
-
const [hasMore, setHasMore] = useState<boolean>(initialData?.has_more ?? false)
|
|
72
|
-
const [loading, setLoading] = useState(false)
|
|
73
|
-
const sortChanged = useRef(false)
|
|
74
|
-
|
|
75
|
-
const options = reviewWidgetOptions(data?.options)
|
|
76
|
-
const pageSize = options.pageSize
|
|
77
|
-
|
|
78
|
-
// No server-provided bootstrap → fetch it on mount.
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
if (initialData) return
|
|
81
|
-
let cancelled = false
|
|
82
|
-
;(async () => {
|
|
83
|
-
try {
|
|
84
|
-
const payload = await getWidget(client, productId)
|
|
85
|
-
if (cancelled) return
|
|
86
|
-
setData(payload)
|
|
87
|
-
setReviews(payload.reviews)
|
|
88
|
-
setCount(payload.count)
|
|
89
|
-
setHasMore(payload.has_more)
|
|
90
|
-
if (payload.options.photo_first === false) setSort("date")
|
|
91
|
-
} catch {
|
|
92
|
-
/* widget is decorative — a failed bootstrap renders nothing */
|
|
93
|
-
}
|
|
94
|
-
})()
|
|
95
|
-
return () => {
|
|
96
|
-
cancelled = true
|
|
97
|
-
}
|
|
98
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
-
}, [client, productId])
|
|
100
|
-
|
|
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).
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
if (!sortChanged.current) return
|
|
105
|
-
let cancelled = false
|
|
106
|
-
setLoading(true)
|
|
107
|
-
;(async () => {
|
|
108
|
-
try {
|
|
109
|
-
const res = await listReviews(client, {
|
|
110
|
-
product_id: productId,
|
|
111
|
-
...sortParamsFor(sort),
|
|
112
|
-
limit: pageSize,
|
|
113
|
-
offset: 0,
|
|
114
|
-
})
|
|
115
|
-
if (cancelled) return
|
|
116
|
-
setReviews(res.reviews)
|
|
117
|
-
setCount(res.count)
|
|
118
|
-
setHasMore(res.has_more)
|
|
119
|
-
} finally {
|
|
120
|
-
if (!cancelled) setLoading(false)
|
|
121
|
-
}
|
|
122
|
-
})()
|
|
123
|
-
return () => {
|
|
124
|
-
cancelled = true
|
|
125
|
-
}
|
|
126
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
-
}, [sort, productId, pageSize])
|
|
128
|
-
|
|
129
|
-
const chooseSort = (next: ReviewSortKey) => {
|
|
130
|
-
sortChanged.current = true
|
|
131
|
-
setSort(next)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const loadMore = useCallback(async () => {
|
|
135
|
-
setLoading(true)
|
|
136
|
-
try {
|
|
137
|
-
const res = await listReviews(client, {
|
|
138
|
-
product_id: productId,
|
|
139
|
-
...sortParamsFor(sort),
|
|
140
|
-
limit: pageSize,
|
|
141
|
-
offset: reviews.length,
|
|
142
|
-
})
|
|
143
|
-
setReviews((prev) => [...prev, ...res.reviews])
|
|
144
|
-
setCount(res.count)
|
|
145
|
-
setHasMore(res.has_more)
|
|
146
|
-
} finally {
|
|
147
|
-
setLoading(false)
|
|
148
|
-
}
|
|
149
|
-
}, [client, productId, sort, pageSize, reviews.length])
|
|
150
|
-
|
|
151
|
-
// Header summary uses the full aggregate when available; else derive
|
|
152
|
-
// from the loaded reviews (see the header comment).
|
|
153
|
-
const aggregate = data?.aggregate ?? null
|
|
154
|
-
const total = aggregate?.count ?? count
|
|
155
|
-
const avg =
|
|
156
|
-
aggregate?.avg_rating ??
|
|
157
|
-
(reviews.length ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length : 0)
|
|
158
|
-
const distribution =
|
|
159
|
-
aggregate?.distribution ??
|
|
160
|
-
reviews.reduce(
|
|
161
|
-
(d, r) => {
|
|
162
|
-
const key = String(r.rating) as "1" | "2" | "3" | "4" | "5"
|
|
163
|
-
if (key in d) d[key]++
|
|
164
|
-
return d
|
|
165
|
-
},
|
|
166
|
-
{ "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<"1" | "2" | "3" | "4" | "5", number>
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
if (total === 0) {
|
|
170
|
-
return null // Hide the section entirely when there are no reviews
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return (
|
|
174
|
-
<section
|
|
175
|
-
id="reviews"
|
|
176
|
-
className={className ?? "max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-10 my-16 sm:my-24"}
|
|
177
|
-
>
|
|
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
|
-
/>
|
|
190
|
-
|
|
191
|
-
<ReviewList
|
|
192
|
-
reviews={reviews}
|
|
193
|
-
layout={options.layout}
|
|
194
|
-
showDate={options.showDate}
|
|
195
|
-
labels={l}
|
|
196
|
-
className={cn(
|
|
197
|
-
options.layout === "list"
|
|
198
|
-
? "columns-1 [column-gap:1rem]"
|
|
199
|
-
:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, 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 { useLocaleArea } from "../locales/context"
|
|
13
|
+
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
14
|
+
import { sortParamsFor, type ReviewSortKey } from "./helpers"
|
|
15
|
+
import { ReviewSummaryHeader } from "./review-header"
|
|
16
|
+
import { ReviewList } from "./review-list"
|
|
17
|
+
import { reviewWidgetOptions } from "./widget-options"
|
|
18
|
+
|
|
19
|
+
/**
|
|
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
|
|
24
|
+
* (src/components/product/reviews-list.tsx) with the Cartbase data seam:
|
|
25
|
+
*
|
|
26
|
+
* - Bootstrap is ONE call — `api/reviews.getWidget(client, productId)`
|
|
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).
|
|
39
|
+
* - Aggregate fallback preserved (production fix): if the aggregate is
|
|
40
|
+
* missing but reviews loaded, derive avg + distribution from the
|
|
41
|
+
* loaded page rather than showing a misleading "0.0" next to a filled
|
|
42
|
+
* star. The whole section renders null only when there are genuinely
|
|
43
|
+
* zero reviews.
|
|
44
|
+
*/
|
|
45
|
+
export interface ReviewWidgetProps {
|
|
46
|
+
client: StorefrontClient
|
|
47
|
+
productId: string
|
|
48
|
+
/** Server-fetched `getWidget` payload (recommended). */
|
|
49
|
+
initialData?: ReviewWidgetPayload | null
|
|
50
|
+
labels?: Partial<ReviewsUiLabels>
|
|
51
|
+
className?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ReviewWidget({
|
|
55
|
+
client,
|
|
56
|
+
productId,
|
|
57
|
+
initialData,
|
|
58
|
+
labels,
|
|
59
|
+
className,
|
|
60
|
+
}: ReviewWidgetProps) {
|
|
61
|
+
// The mounted language, then the store's overrides (2026-09-14): a store
|
|
62
|
+
// that mounts StorefrontLocaleProvider passes nothing here.
|
|
63
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
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"
|
|
68
|
+
)
|
|
69
|
+
const [reviews, setReviews] = useState<PublicReview[]>(initialData?.reviews ?? [])
|
|
70
|
+
const [count, setCount] = useState<number>(initialData?.count ?? 0)
|
|
71
|
+
const [hasMore, setHasMore] = useState<boolean>(initialData?.has_more ?? false)
|
|
72
|
+
const [loading, setLoading] = useState(false)
|
|
73
|
+
const sortChanged = useRef(false)
|
|
74
|
+
|
|
75
|
+
const options = reviewWidgetOptions(data?.options)
|
|
76
|
+
const pageSize = options.pageSize
|
|
77
|
+
|
|
78
|
+
// No server-provided bootstrap → fetch it on mount.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (initialData) return
|
|
81
|
+
let cancelled = false
|
|
82
|
+
;(async () => {
|
|
83
|
+
try {
|
|
84
|
+
const payload = await getWidget(client, productId)
|
|
85
|
+
if (cancelled) return
|
|
86
|
+
setData(payload)
|
|
87
|
+
setReviews(payload.reviews)
|
|
88
|
+
setCount(payload.count)
|
|
89
|
+
setHasMore(payload.has_more)
|
|
90
|
+
if (payload.options.photo_first === false) setSort("date")
|
|
91
|
+
} catch {
|
|
92
|
+
/* widget is decorative — a failed bootstrap renders nothing */
|
|
93
|
+
}
|
|
94
|
+
})()
|
|
95
|
+
return () => {
|
|
96
|
+
cancelled = true
|
|
97
|
+
}
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
}, [client, productId])
|
|
100
|
+
|
|
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).
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!sortChanged.current) return
|
|
105
|
+
let cancelled = false
|
|
106
|
+
setLoading(true)
|
|
107
|
+
;(async () => {
|
|
108
|
+
try {
|
|
109
|
+
const res = await listReviews(client, {
|
|
110
|
+
product_id: productId,
|
|
111
|
+
...sortParamsFor(sort),
|
|
112
|
+
limit: pageSize,
|
|
113
|
+
offset: 0,
|
|
114
|
+
})
|
|
115
|
+
if (cancelled) return
|
|
116
|
+
setReviews(res.reviews)
|
|
117
|
+
setCount(res.count)
|
|
118
|
+
setHasMore(res.has_more)
|
|
119
|
+
} finally {
|
|
120
|
+
if (!cancelled) setLoading(false)
|
|
121
|
+
}
|
|
122
|
+
})()
|
|
123
|
+
return () => {
|
|
124
|
+
cancelled = true
|
|
125
|
+
}
|
|
126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
+
}, [sort, productId, pageSize])
|
|
128
|
+
|
|
129
|
+
const chooseSort = (next: ReviewSortKey) => {
|
|
130
|
+
sortChanged.current = true
|
|
131
|
+
setSort(next)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const loadMore = useCallback(async () => {
|
|
135
|
+
setLoading(true)
|
|
136
|
+
try {
|
|
137
|
+
const res = await listReviews(client, {
|
|
138
|
+
product_id: productId,
|
|
139
|
+
...sortParamsFor(sort),
|
|
140
|
+
limit: pageSize,
|
|
141
|
+
offset: reviews.length,
|
|
142
|
+
})
|
|
143
|
+
setReviews((prev) => [...prev, ...res.reviews])
|
|
144
|
+
setCount(res.count)
|
|
145
|
+
setHasMore(res.has_more)
|
|
146
|
+
} finally {
|
|
147
|
+
setLoading(false)
|
|
148
|
+
}
|
|
149
|
+
}, [client, productId, sort, pageSize, reviews.length])
|
|
150
|
+
|
|
151
|
+
// Header summary uses the full aggregate when available; else derive
|
|
152
|
+
// from the loaded reviews (see the header comment).
|
|
153
|
+
const aggregate = data?.aggregate ?? null
|
|
154
|
+
const total = aggregate?.count ?? count
|
|
155
|
+
const avg =
|
|
156
|
+
aggregate?.avg_rating ??
|
|
157
|
+
(reviews.length ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length : 0)
|
|
158
|
+
const distribution =
|
|
159
|
+
aggregate?.distribution ??
|
|
160
|
+
reviews.reduce(
|
|
161
|
+
(d, r) => {
|
|
162
|
+
const key = String(r.rating) as "1" | "2" | "3" | "4" | "5"
|
|
163
|
+
if (key in d) d[key]++
|
|
164
|
+
return d
|
|
165
|
+
},
|
|
166
|
+
{ "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<"1" | "2" | "3" | "4" | "5", number>
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (total === 0) {
|
|
170
|
+
return null // Hide the section entirely when there are no reviews
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<section
|
|
175
|
+
id="reviews"
|
|
176
|
+
className={className ?? "max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-10 my-16 sm:my-24"}
|
|
177
|
+
>
|
|
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
|
+
/>
|
|
190
|
+
|
|
191
|
+
<ReviewList
|
|
192
|
+
reviews={reviews}
|
|
193
|
+
layout={options.layout}
|
|
194
|
+
showDate={options.showDate}
|
|
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
|
+
)}
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
{hasMore && (
|
|
206
|
+
<div className="mt-8 text-center">
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={loadMore}
|
|
210
|
+
disabled={loading}
|
|
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"
|
|
212
|
+
>
|
|
213
|
+
{loading ? l.loading : l.loadMore}
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</section>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The reviews widget's display options with every default filled, from the
|
|
3
|
-
* payload the platform serves (`getWidget().options`) or from nothing. A
|
|
4
|
-
* platform older than a field leaves it out; the widget then shows what
|
|
5
|
-
* Loox shows by default. Pure. Unit-tested in
|
|
6
|
-
* tests/unit/storefront-review-lightbox.test.ts.
|
|
7
|
-
*/
|
|
8
|
-
import type {
|
|
9
|
-
ReviewWidgetHeader,
|
|
10
|
-
ReviewWidgetHeaderContent,
|
|
11
|
-
ReviewWidgetPayload,
|
|
12
|
-
} from "../api/reviews"
|
|
13
|
-
|
|
14
|
-
export interface ReviewWidgetOptions {
|
|
15
|
-
layout: "masonry" | "list"
|
|
16
|
-
pageSize: number
|
|
17
|
-
header: ReviewWidgetHeader
|
|
18
|
-
headerContent: ReviewWidgetHeaderContent
|
|
19
|
-
showDistribution: boolean
|
|
20
|
-
showSort: boolean
|
|
21
|
-
showDate: boolean
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const DEFAULT_REVIEW_WIDGET_OPTIONS: ReviewWidgetOptions = {
|
|
25
|
-
layout: "masonry",
|
|
26
|
-
pageSize:
|
|
27
|
-
header: "minimal",
|
|
28
|
-
headerContent: "count",
|
|
29
|
-
showDistribution: true,
|
|
30
|
-
showSort: true,
|
|
31
|
-
showDate: true,
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function reviewWidgetOptions(
|
|
35
|
-
options: Partial<ReviewWidgetPayload["options"]> | null | undefined
|
|
36
|
-
): ReviewWidgetOptions {
|
|
37
|
-
const d = DEFAULT_REVIEW_WIDGET_OPTIONS
|
|
38
|
-
if (!options) return { ...d }
|
|
39
|
-
return {
|
|
40
|
-
layout: options.layout === "list" ? "list" : d.layout,
|
|
41
|
-
pageSize:
|
|
42
|
-
typeof options.page_size === "number" && options.page_size > 0
|
|
43
|
-
? Math.trunc(options.page_size)
|
|
44
|
-
: d.pageSize,
|
|
45
|
-
header:
|
|
46
|
-
options.header === "compact" || options.header === "expanded" ? options.header : d.header,
|
|
47
|
-
headerContent:
|
|
48
|
-
options.header_content === "average" || options.header_content === "stars"
|
|
49
|
-
? options.header_content
|
|
50
|
-
: d.headerContent,
|
|
51
|
-
showDistribution: options.show_distribution ?? d.showDistribution,
|
|
52
|
-
showSort: options.show_sort ?? d.showSort,
|
|
53
|
-
showDate: options.show_date ?? d.showDate,
|
|
54
|
-
}
|
|
55
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The reviews widget's display options with every default filled, from the
|
|
3
|
+
* payload the platform serves (`getWidget().options`) or from nothing. A
|
|
4
|
+
* platform older than a field leaves it out; the widget then shows what
|
|
5
|
+
* Loox shows by default. Pure. Unit-tested in
|
|
6
|
+
* tests/unit/storefront-review-lightbox.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
ReviewWidgetHeader,
|
|
10
|
+
ReviewWidgetHeaderContent,
|
|
11
|
+
ReviewWidgetPayload,
|
|
12
|
+
} from "../api/reviews"
|
|
13
|
+
|
|
14
|
+
export interface ReviewWidgetOptions {
|
|
15
|
+
layout: "masonry" | "list"
|
|
16
|
+
pageSize: number
|
|
17
|
+
header: ReviewWidgetHeader
|
|
18
|
+
headerContent: ReviewWidgetHeaderContent
|
|
19
|
+
showDistribution: boolean
|
|
20
|
+
showSort: boolean
|
|
21
|
+
showDate: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_REVIEW_WIDGET_OPTIONS: ReviewWidgetOptions = {
|
|
25
|
+
layout: "masonry",
|
|
26
|
+
pageSize: 20,
|
|
27
|
+
header: "minimal",
|
|
28
|
+
headerContent: "count",
|
|
29
|
+
showDistribution: true,
|
|
30
|
+
showSort: true,
|
|
31
|
+
showDate: true,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function reviewWidgetOptions(
|
|
35
|
+
options: Partial<ReviewWidgetPayload["options"]> | null | undefined
|
|
36
|
+
): ReviewWidgetOptions {
|
|
37
|
+
const d = DEFAULT_REVIEW_WIDGET_OPTIONS
|
|
38
|
+
if (!options) return { ...d }
|
|
39
|
+
return {
|
|
40
|
+
layout: options.layout === "list" ? "list" : d.layout,
|
|
41
|
+
pageSize:
|
|
42
|
+
typeof options.page_size === "number" && options.page_size > 0
|
|
43
|
+
? Math.trunc(options.page_size)
|
|
44
|
+
: d.pageSize,
|
|
45
|
+
header:
|
|
46
|
+
options.header === "compact" || options.header === "expanded" ? options.header : d.header,
|
|
47
|
+
headerContent:
|
|
48
|
+
options.header_content === "average" || options.header_content === "stars"
|
|
49
|
+
? options.header_content
|
|
50
|
+
: d.headerContent,
|
|
51
|
+
showDistribution: options.show_distribution ?? d.showDistribution,
|
|
52
|
+
showSort: options.show_sort ?? d.showSort,
|
|
53
|
+
showDate: options.show_date ?? d.showDate,
|
|
54
|
+
}
|
|
55
|
+
}
|