@cartbase/storefront 0.19.0 → 0.20.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 +1 -1
- package/src/api/reviews.ts +22 -1
- package/src/locales/bg.ts +3 -2
- package/src/locales/es.ts +3 -2
- package/src/reviews-ui/index.ts +12 -0
- package/src/reviews-ui/labels.ts +5 -2
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-list.tsx +24 -22
- package/src/reviews-ui/review-widget.tsx +218 -227
- package/src/reviews-ui/star-badge.tsx +6 -4
- package/src/reviews-ui/widget-options.ts +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/api/reviews.ts
CHANGED
|
@@ -95,6 +95,16 @@ export async function getAggregate(
|
|
|
95
95
|
return client.get("/api/store/reviews/aggregate", { query: { product_id: productId } })
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The widget's header, Loox's three: `minimal` is the stars and one fact
|
|
100
|
+
* with the breakdown under a chevron, `compact` the average, the stars and
|
|
101
|
+
* the count on one line, `expanded` the big score with the breakdown open.
|
|
102
|
+
*/
|
|
103
|
+
export type ReviewWidgetHeader = "minimal" | "compact" | "expanded"
|
|
104
|
+
|
|
105
|
+
/** What the minimal header shows beside its stars. */
|
|
106
|
+
export type ReviewWidgetHeaderContent = "count" | "average" | "stars"
|
|
107
|
+
|
|
98
108
|
/** Widget payload — aggregate + first page + display options, one call. */
|
|
99
109
|
export interface ReviewWidgetPayload {
|
|
100
110
|
product_id: string
|
|
@@ -106,6 +116,15 @@ export interface ReviewWidgetPayload {
|
|
|
106
116
|
layout: "masonry" | "list"
|
|
107
117
|
page_size: number
|
|
108
118
|
photo_first: boolean
|
|
119
|
+
/** The fields below arrive from platforms of 2026-09-17 on; the widget defaults each. */
|
|
120
|
+
header?: ReviewWidgetHeader
|
|
121
|
+
header_content?: ReviewWidgetHeaderContent
|
|
122
|
+
/** The 5 to 1 star breakdown. */
|
|
123
|
+
show_distribution?: boolean
|
|
124
|
+
/** The shopper's sort menu. */
|
|
125
|
+
show_sort?: boolean
|
|
126
|
+
/** The date on each card. */
|
|
127
|
+
show_date?: boolean
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
130
|
|
|
@@ -116,7 +135,9 @@ export interface ReviewWidgetPayload {
|
|
|
116
135
|
*
|
|
117
136
|
* Auth: anon (x-client-id).
|
|
118
137
|
* Errors: 400 validation_failed (missing product_id).
|
|
119
|
-
* Settings:
|
|
138
|
+
* Settings (admin, Reviews, Widgets): widget_header / widget_header_content /
|
|
139
|
+
* widget_layout / widget_page_size / widget_photo_first /
|
|
140
|
+
* widget_show_distribution / widget_show_sort / widget_show_date.
|
|
120
141
|
*/
|
|
121
142
|
export async function getWidget(
|
|
122
143
|
client: StorefrontClient,
|
package/src/locales/bg.ts
CHANGED
|
@@ -374,14 +374,14 @@ export const bg: StorefrontLocale = {
|
|
|
374
374
|
sectionTitle: "Отзиви",
|
|
375
375
|
reviewSingular: "отзив",
|
|
376
376
|
reviewPlural: "отзива",
|
|
377
|
-
sortAria: "Сортирай",
|
|
377
|
+
sortAria: "Сортирай по",
|
|
378
378
|
sortDefault: "Препоръчани",
|
|
379
379
|
sortNewest: "Най-нови",
|
|
380
380
|
sortRatingDesc: "Най-високи оценки",
|
|
381
381
|
sortRatingAsc: "Най-ниски оценки",
|
|
382
382
|
verified: "Потвърден",
|
|
383
383
|
verifiedTitle: "Истински купувач, потвърден чрез линк от поръчка",
|
|
384
|
-
loadMore: "Покажи още",
|
|
384
|
+
loadMore: "Покажи още отзиви",
|
|
385
385
|
loading: "Зарежда...",
|
|
386
386
|
dateToday: "днес",
|
|
387
387
|
dateYesterday: "вчера",
|
|
@@ -389,6 +389,7 @@ export const bg: StorefrontLocale = {
|
|
|
389
389
|
showPhoto: "Покажи снимка",
|
|
390
390
|
playVideo: "Пусни видео",
|
|
391
391
|
close: "Затвори",
|
|
392
|
+
ratingBreakdown: "Разбивка по оценки",
|
|
392
393
|
openReview: "Отвори отзива",
|
|
393
394
|
verifiedInfo: "Какво значи потвърден",
|
|
394
395
|
storeReply: "Отговор от магазина",
|
package/src/locales/es.ts
CHANGED
|
@@ -384,14 +384,14 @@ export const es: StorefrontLocale = {
|
|
|
384
384
|
sectionTitle: "Valoraciones",
|
|
385
385
|
reviewSingular: "valoración",
|
|
386
386
|
reviewPlural: "valoraciones",
|
|
387
|
-
sortAria: "Ordenar",
|
|
387
|
+
sortAria: "Ordenar por",
|
|
388
388
|
sortDefault: "Recomendadas",
|
|
389
389
|
sortNewest: "Más recientes",
|
|
390
390
|
sortRatingDesc: "Mejor valoradas",
|
|
391
391
|
sortRatingAsc: "Peor valoradas",
|
|
392
392
|
verified: "Compra verificada",
|
|
393
393
|
verifiedTitle: "Comprador real, verificado mediante un enlace de pedido",
|
|
394
|
-
loadMore: "Mostrar más",
|
|
394
|
+
loadMore: "Mostrar más valoraciones",
|
|
395
395
|
loading: "Cargando...",
|
|
396
396
|
dateToday: "hoy",
|
|
397
397
|
dateYesterday: "ayer",
|
|
@@ -399,6 +399,7 @@ export const es: StorefrontLocale = {
|
|
|
399
399
|
showPhoto: "Ver foto",
|
|
400
400
|
playVideo: "Reproducir vídeo",
|
|
401
401
|
close: "Cerrar",
|
|
402
|
+
ratingBreakdown: "Desglose de valoraciones",
|
|
402
403
|
openReview: "Abrir la valoración",
|
|
403
404
|
verifiedInfo: "Qué significa compra verificada",
|
|
404
405
|
storeReply: "Respuesta de la tienda",
|
package/src/reviews-ui/index.ts
CHANGED
|
@@ -62,6 +62,18 @@ export {
|
|
|
62
62
|
|
|
63
63
|
export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
|
|
64
64
|
|
|
65
|
+
export {
|
|
66
|
+
ReviewSummaryHeader,
|
|
67
|
+
ReviewSortMenu,
|
|
68
|
+
type ReviewSummaryHeaderProps,
|
|
69
|
+
} from "./review-header"
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
reviewWidgetOptions,
|
|
73
|
+
DEFAULT_REVIEW_WIDGET_OPTIONS,
|
|
74
|
+
type ReviewWidgetOptions,
|
|
75
|
+
} from "./widget-options"
|
|
76
|
+
|
|
65
77
|
export {
|
|
66
78
|
ReviewWizard,
|
|
67
79
|
ReviewWizardForm,
|
package/src/reviews-ui/labels.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type ReviewsUiLabels = {
|
|
|
31
31
|
showPhoto: string
|
|
32
32
|
playVideo: string
|
|
33
33
|
close: string
|
|
34
|
+
/** The header's chevron that opens the 5 to 1 star breakdown. */
|
|
35
|
+
ratingBreakdown: string
|
|
34
36
|
// — lightbox —
|
|
35
37
|
/** A review card's own button: the whole card opens the lightbox. */
|
|
36
38
|
openReview: string
|
|
@@ -130,14 +132,14 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
|
|
|
130
132
|
sectionTitle: "Reviews",
|
|
131
133
|
reviewSingular: "review",
|
|
132
134
|
reviewPlural: "reviews",
|
|
133
|
-
sortAria: "Sort",
|
|
135
|
+
sortAria: "Sort by",
|
|
134
136
|
sortDefault: "Recommended",
|
|
135
137
|
sortNewest: "Newest",
|
|
136
138
|
sortRatingDesc: "Highest rated",
|
|
137
139
|
sortRatingAsc: "Lowest rated",
|
|
138
140
|
verified: "Verified",
|
|
139
141
|
verifiedTitle: "Real buyer, verified via an order link",
|
|
140
|
-
loadMore: "Show more",
|
|
142
|
+
loadMore: "Show more reviews",
|
|
141
143
|
loading: "Loading...",
|
|
142
144
|
dateToday: "today",
|
|
143
145
|
dateYesterday: "yesterday",
|
|
@@ -145,6 +147,7 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
|
|
|
145
147
|
showPhoto: "View photo",
|
|
146
148
|
playVideo: "Play video",
|
|
147
149
|
close: "Close",
|
|
150
|
+
ratingBreakdown: "Rating breakdown",
|
|
148
151
|
openReview: "Open the review",
|
|
149
152
|
verifiedInfo: "What verified means",
|
|
150
153
|
storeReply: "Reply from the store",
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { Check, ChevronDown, SlidersHorizontal, Star } from "lucide-react"
|
|
5
|
+
import type { ReviewAggregate, ReviewWidgetHeader, ReviewWidgetHeaderContent } from "../api/reviews"
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/ui/popover"
|
|
7
|
+
import { reviewCountLabel, type ReviewSortKey } from "./helpers"
|
|
8
|
+
import type { ReviewsUiLabels } from "./labels"
|
|
9
|
+
import { RatingDistribution, StarBadge, StarRow } from "./star-badge"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The reviews widget's header, Loox's three layouts:
|
|
13
|
+
*
|
|
14
|
+
* - `minimal`: the stars and one fact beside them (the review count, the
|
|
15
|
+
* average, or nothing), the breakdown under a chevron;
|
|
16
|
+
* - `compact`: the average, the stars and the count on one line, the
|
|
17
|
+
* breakdown under a chevron;
|
|
18
|
+
* - `expanded`: the section title, the big score and the breakdown open
|
|
19
|
+
* beside it (the Alenika header).
|
|
20
|
+
*
|
|
21
|
+
* The sort menu sits on the right in all three. `showDistribution` and
|
|
22
|
+
* `showSort` switch those parts off.
|
|
23
|
+
*/
|
|
24
|
+
export interface ReviewSummaryHeaderProps {
|
|
25
|
+
layout: ReviewWidgetHeader
|
|
26
|
+
content: ReviewWidgetHeaderContent
|
|
27
|
+
total: number
|
|
28
|
+
average: number
|
|
29
|
+
distribution: ReviewAggregate["distribution"]
|
|
30
|
+
showDistribution: boolean
|
|
31
|
+
showSort: boolean
|
|
32
|
+
sort: ReviewSortKey
|
|
33
|
+
onSortChange: (sort: ReviewSortKey) => void
|
|
34
|
+
labels: ReviewsUiLabels
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ReviewSummaryHeader({
|
|
38
|
+
layout,
|
|
39
|
+
content,
|
|
40
|
+
total,
|
|
41
|
+
average,
|
|
42
|
+
distribution,
|
|
43
|
+
showDistribution,
|
|
44
|
+
showSort,
|
|
45
|
+
sort,
|
|
46
|
+
onSortChange,
|
|
47
|
+
labels: l,
|
|
48
|
+
}: ReviewSummaryHeaderProps) {
|
|
49
|
+
const count = reviewCountLabel(total, l)
|
|
50
|
+
const sortMenu = showSort ? <ReviewSortMenu value={sort} onChange={onSortChange} labels={l} /> : null
|
|
51
|
+
|
|
52
|
+
if (layout === "expanded") {
|
|
53
|
+
return (
|
|
54
|
+
<header className="mb-10">
|
|
55
|
+
<h2 className="mb-6 text-h4 text-foreground">{l.sectionTitle}</h2>
|
|
56
|
+
<div className="flex flex-col gap-6 md:flex-row md:items-center md:gap-12">
|
|
57
|
+
<StarBadge aggregate={{ count: total, avg_rating: average }} labels={l} />
|
|
58
|
+
{showDistribution ? <RatingDistribution distribution={distribution} total={total} /> : null}
|
|
59
|
+
{sortMenu ? <div className="md:ml-auto md:self-start">{sortMenu}</div> : null}
|
|
60
|
+
</div>
|
|
61
|
+
</header>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const summary =
|
|
66
|
+
layout === "compact" ? (
|
|
67
|
+
<>
|
|
68
|
+
<span className="text-h6 text-foreground tabular-nums">{average.toFixed(1)}</span>
|
|
69
|
+
<StarRow rating={average} size="lg" />
|
|
70
|
+
<span className="text-body-small text-foreground">{count}</span>
|
|
71
|
+
</>
|
|
72
|
+
) : (
|
|
73
|
+
<>
|
|
74
|
+
<StarRow rating={average} size="lg" />
|
|
75
|
+
{content === "count" ? <span className="text-body-small text-foreground">{count}</span> : null}
|
|
76
|
+
{content === "average" ? (
|
|
77
|
+
<span className="text-body-small font-semibold text-foreground tabular-nums">
|
|
78
|
+
{average.toFixed(1)}
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<header className="mb-6 flex items-center justify-between gap-4">
|
|
86
|
+
{showDistribution ? (
|
|
87
|
+
<Popover>
|
|
88
|
+
<PopoverTrigger
|
|
89
|
+
aria-label={`${l.ratingBreakdown}: ${count}`}
|
|
90
|
+
className="group flex items-center gap-2 rounded-md text-left"
|
|
91
|
+
>
|
|
92
|
+
{summary}
|
|
93
|
+
<ChevronDown
|
|
94
|
+
aria-hidden
|
|
95
|
+
className="size-4 text-foreground transition-transform group-data-[state=open]:rotate-180"
|
|
96
|
+
/>
|
|
97
|
+
</PopoverTrigger>
|
|
98
|
+
<PopoverContent align="start" className="w-[min(26rem,calc(100vw-2rem))] p-6">
|
|
99
|
+
<div className="mb-4 flex items-center justify-center gap-2">
|
|
100
|
+
<Star aria-hidden className="size-7 fill-rating text-rating" strokeWidth={0} />
|
|
101
|
+
<span className="text-h3 text-foreground tabular-nums">{average.toFixed(1)}</span>
|
|
102
|
+
</div>
|
|
103
|
+
<RatingDistribution distribution={distribution} total={total} className="md:max-w-none" />
|
|
104
|
+
</PopoverContent>
|
|
105
|
+
</Popover>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="flex items-center gap-2">{summary}</div>
|
|
108
|
+
)}
|
|
109
|
+
{sortMenu}
|
|
110
|
+
</header>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const SORT_OPTIONS: Array<{ key: ReviewSortKey; label: keyof ReviewsUiLabels }> = [
|
|
115
|
+
{ key: "default", label: "sortDefault" },
|
|
116
|
+
{ key: "date", label: "sortNewest" },
|
|
117
|
+
{ key: "rating-desc", label: "sortRatingDesc" },
|
|
118
|
+
{ key: "rating-asc", label: "sortRatingAsc" },
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The sort menu: an icon button, and under it "Sort by" with the four
|
|
123
|
+
* orders, the chosen one ticked. Choosing one closes it.
|
|
124
|
+
*/
|
|
125
|
+
export function ReviewSortMenu({
|
|
126
|
+
value,
|
|
127
|
+
onChange,
|
|
128
|
+
labels: l,
|
|
129
|
+
}: {
|
|
130
|
+
value: ReviewSortKey
|
|
131
|
+
onChange: (sort: ReviewSortKey) => void
|
|
132
|
+
labels: ReviewsUiLabels
|
|
133
|
+
}) {
|
|
134
|
+
const [open, setOpen] = useState(false)
|
|
135
|
+
return (
|
|
136
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
137
|
+
<PopoverTrigger
|
|
138
|
+
aria-label={l.sortAria}
|
|
139
|
+
className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background text-foreground transition-colors hover:bg-muted data-[state=open]:bg-muted"
|
|
140
|
+
>
|
|
141
|
+
<SlidersHorizontal aria-hidden className="size-4.5" />
|
|
142
|
+
</PopoverTrigger>
|
|
143
|
+
<PopoverContent align="end" className="w-64 p-2">
|
|
144
|
+
<p className="px-3 pb-2 pt-2 text-h6 text-foreground">{l.sortAria}</p>
|
|
145
|
+
<ul>
|
|
146
|
+
{SORT_OPTIONS.map((option) => (
|
|
147
|
+
<li key={option.key}>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-pressed={value === option.key}
|
|
151
|
+
onClick={() => {
|
|
152
|
+
setOpen(false)
|
|
153
|
+
if (option.key !== value) onChange(option.key)
|
|
154
|
+
}}
|
|
155
|
+
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-body-small text-foreground transition-colors hover:bg-muted"
|
|
156
|
+
>
|
|
157
|
+
{l[option.label]}
|
|
158
|
+
{value === option.key ? <Check aria-hidden className="size-4" /> : null}
|
|
159
|
+
</button>
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
</ul>
|
|
163
|
+
</PopoverContent>
|
|
164
|
+
</Popover>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useState } from "react"
|
|
4
|
-
import {
|
|
4
|
+
import { CircleCheck } from "lucide-react"
|
|
5
5
|
import type { PublicReview } from "../api/reviews"
|
|
6
6
|
import { useLocaleArea } from "../locales/context"
|
|
7
7
|
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
@@ -17,8 +17,10 @@ import { StarRow } from "./star-badge"
|
|
|
17
17
|
* `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden media is
|
|
18
18
|
* filtered out server-side, the client just renders what it gets).
|
|
19
19
|
*
|
|
20
|
-
* A
|
|
21
|
-
*
|
|
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.
|
|
22
24
|
*
|
|
23
25
|
* Verified-purchaser badge is unconditional — every Cartbase review comes
|
|
24
26
|
* from a tokenized post-purchase email, so the badge is a tautology of
|
|
@@ -34,6 +36,8 @@ export interface ReviewListProps {
|
|
|
34
36
|
reviews: PublicReview[]
|
|
35
37
|
/** Store display option (`getWidget().options.layout`). Default masonry. */
|
|
36
38
|
layout?: "masonry" | "list"
|
|
39
|
+
/** The date on each card (`getWidget().options.show_date`). Default on. */
|
|
40
|
+
showDate?: boolean
|
|
37
41
|
labels?: Partial<ReviewsUiLabels>
|
|
38
42
|
className?: string
|
|
39
43
|
}
|
|
@@ -41,6 +45,7 @@ export interface ReviewListProps {
|
|
|
41
45
|
export function ReviewList({
|
|
42
46
|
reviews,
|
|
43
47
|
layout = "masonry",
|
|
48
|
+
showDate = true,
|
|
44
49
|
labels,
|
|
45
50
|
className,
|
|
46
51
|
}: ReviewListProps) {
|
|
@@ -64,7 +69,7 @@ export function ReviewList({
|
|
|
64
69
|
return (
|
|
65
70
|
<li
|
|
66
71
|
key={r.id}
|
|
67
|
-
className="relative break-inside-avoid
|
|
72
|
+
className="relative mb-4 break-inside-avoid overflow-hidden rounded-lg bg-card shadow-card"
|
|
68
73
|
>
|
|
69
74
|
{/* The whole card is the button; the photos sit above it. */}
|
|
70
75
|
<button
|
|
@@ -113,46 +118,43 @@ export function ReviewList({
|
|
|
113
118
|
</button>
|
|
114
119
|
)}
|
|
115
120
|
|
|
116
|
-
<div className="p-4
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
<div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
|
|
120
|
-
<span className="text-sm font-medium text-foreground">
|
|
121
|
+
<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">
|
|
121
124
|
{reviewDisplayName(r.customer_name)}
|
|
122
125
|
</span>
|
|
123
126
|
<span
|
|
124
|
-
className="inline-flex items-center gap-1 text-
|
|
127
|
+
className="inline-flex items-center gap-1 text-caption text-foreground"
|
|
125
128
|
title={l.verifiedTitle}
|
|
126
129
|
>
|
|
127
|
-
<
|
|
130
|
+
<CircleCheck aria-hidden className="size-4 fill-foreground text-background" />
|
|
128
131
|
{l.verified}
|
|
129
132
|
</span>
|
|
130
|
-
<span className="text-xs text-muted-foreground ml-auto">
|
|
131
|
-
{formatReviewDate(r.created_at, l)}
|
|
132
|
-
</span>
|
|
133
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" />
|
|
134
140
|
|
|
135
141
|
{r.title && (
|
|
136
|
-
<h3 className="
|
|
137
|
-
{r.title}
|
|
138
|
-
</h3>
|
|
142
|
+
<h3 className="mt-2 text-h6 text-foreground">{r.title}</h3>
|
|
139
143
|
)}
|
|
140
144
|
{r.body && (
|
|
141
|
-
<p className="
|
|
142
|
-
{r.body}
|
|
143
|
-
</p>
|
|
145
|
+
<p className="mt-2 whitespace-pre-wrap text-body text-foreground">{r.body}</p>
|
|
144
146
|
)}
|
|
145
147
|
|
|
146
148
|
{/* Extra media beyond the hero */}
|
|
147
149
|
{rest.length > 0 && (
|
|
148
|
-
<div className="relative z-10 grid grid-cols-4 gap-2
|
|
150
|
+
<div className="relative z-10 mt-3 grid grid-cols-4 gap-2">
|
|
149
151
|
{rest.map((m, i) => (
|
|
150
152
|
<button
|
|
151
153
|
key={i}
|
|
152
154
|
type="button"
|
|
153
155
|
onClick={() => setOpen({ review: index, media: i + 1 })}
|
|
154
156
|
aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
|
|
155
|
-
className="relative aspect-square
|
|
157
|
+
className="relative aspect-square overflow-hidden rounded-md bg-muted"
|
|
156
158
|
>
|
|
157
159
|
{m.type === "image" ? (
|
|
158
160
|
<img
|
|
@@ -1,227 +1,218 @@
|
|
|
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 { useLocaleArea } from "../locales/context"
|
|
13
|
-
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
14
|
-
import { sortParamsFor, type ReviewSortKey } from "./helpers"
|
|
15
|
-
import {
|
|
16
|
-
import { ReviewList } from "./review-list"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
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
|
-
const [
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const [
|
|
70
|
-
const [
|
|
71
|
-
|
|
72
|
-
const
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
)}
|
|
220
|
-
>
|
|
221
|
-
{loading ? l.loading : l.loadMore}
|
|
222
|
-
</button>
|
|
223
|
-
</div>
|
|
224
|
-
)}
|
|
225
|
-
</section>
|
|
226
|
-
)
|
|
227
|
-
}
|
|
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
|
+
: "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]",
|
|
200
|
+
loading && "opacity-60 transition-opacity"
|
|
201
|
+
)}
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
{hasMore && (
|
|
205
|
+
<div className="mt-8 text-center">
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={loadMore}
|
|
209
|
+
disabled={loading}
|
|
210
|
+
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"
|
|
211
|
+
>
|
|
212
|
+
{loading ? l.loading : l.loadMore}
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</section>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
@@ -11,9 +11,11 @@ import { reviewCountLabel } from "./helpers"
|
|
|
11
11
|
* arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const STAR_SIZE = { sm: "size-3.5", md: "size-4.5", lg: "size-5" } as const
|
|
15
|
+
|
|
14
16
|
/**
|
|
15
17
|
* Five-star row, filled to `Math.round(rating)` in the theme's `rating`
|
|
16
|
-
* colour. `
|
|
18
|
+
* colour. `md` is a review card's row, `lg` the lightbox's and the header's.
|
|
17
19
|
*/
|
|
18
20
|
export function StarRow({
|
|
19
21
|
rating,
|
|
@@ -21,21 +23,21 @@ export function StarRow({
|
|
|
21
23
|
className,
|
|
22
24
|
}: {
|
|
23
25
|
rating: number
|
|
24
|
-
size?:
|
|
26
|
+
size?: keyof typeof STAR_SIZE
|
|
25
27
|
className?: string
|
|
26
28
|
}) {
|
|
27
29
|
return (
|
|
28
30
|
<span
|
|
29
31
|
role="img"
|
|
30
32
|
aria-label={`${Math.round(rating)} / 5`}
|
|
31
|
-
className={cn("inline-flex items-center", size === "
|
|
33
|
+
className={cn("inline-flex items-center", size === "sm" ? "gap-0.5" : "gap-px", className)}
|
|
32
34
|
>
|
|
33
35
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
34
36
|
<Star
|
|
35
37
|
key={i}
|
|
36
38
|
aria-hidden
|
|
37
39
|
className={cn(
|
|
38
|
-
size
|
|
40
|
+
STAR_SIZE[size],
|
|
39
41
|
i < Math.round(rating)
|
|
40
42
|
? "fill-rating text-rating"
|
|
41
43
|
: "fill-muted text-muted-foreground"
|
|
@@ -0,0 +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: 10,
|
|
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
|
+
}
|