@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.19.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": {
@@ -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: widget_layout / widget_page_size / widget_photo_first.
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",
@@ -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,
@@ -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 { ShieldCheck } from "lucide-react"
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 click anywhere on a card opens that review in `ReviewLightbox`; a click
21
- * on one of its photos opens it on that photo.
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 mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
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 sm:p-5">
117
- <StarRow rating={r.rating} />
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-xs text-muted-foreground"
127
+ className="inline-flex items-center gap-1 text-caption text-foreground"
125
128
  title={l.verifiedTitle}
126
129
  >
127
- <ShieldCheck className="w-3.5 h-3.5" />
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="text-base font-semibold text-foreground leading-snug mt-3">
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="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
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 mt-3">
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 rounded-[2px] overflow-hidden bg-muted"
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 { RatingDistribution, StarBadge } from "./star-badge"
16
- import { ReviewList } from "./review-list"
17
-
18
- /**
19
- * PDP reviews widget: aggregate header (score + distribution + sort) +
20
- * masonry card list + load-more. Ported from the Alenika PDP section
21
- * (src/components/product/reviews-list.tsx) with the Cartbase data seam:
22
- *
23
- * - Bootstrap is ONE call `api/reviews.getWidget(client, productId)`
24
- * (aggregate + first page sized/sorted per the store's Settings
25
- * Reviews display options; edge-cached 60s). Server-fetch it and pass
26
- * `initialData` (recommendedno client waterfall, mirrors the
27
- * production server-fetched first page); when omitted the widget
28
- * fetches it on mount.
29
- * - Sort changes and load-more re-fetch client-side via `listReviews`
30
- * (the API enforces ordering; the client just passes params — the
31
- * default sort is with-media-first then newest).
32
- * - Aggregate fallback preserved (production fix): if the aggregate is
33
- * missing but reviews loaded, derive avg + distribution from the
34
- * loaded page rather than showing a misleading "0.0" next to a filled
35
- * star. The whole section renders null only when there are genuinely
36
- * 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
- */
42
- export interface ReviewWidgetProps {
43
- client: StorefrontClient
44
- productId: string
45
- /** Server-fetched `getWidget` payload (recommended). */
46
- initialData?: ReviewWidgetPayload | null
47
- labels?: Partial<ReviewsUiLabels>
48
- className?: string
49
- }
50
-
51
- export function ReviewWidget({
52
- client,
53
- productId,
54
- initialData,
55
- labels,
56
- className,
57
- }: ReviewWidgetProps) {
58
- // The mounted language, then the store's overrides (2026-09-14): a store
59
- // that mounts StorefrontLocaleProvider passes nothing here.
60
- const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
61
- const [data, setData] = useState<ReviewWidgetPayload | null>(
62
- initialData ?? null
63
- )
64
- const [sort, setSort] = useState<ReviewSortKey>("default")
65
- const [reviews, setReviews] = useState<PublicReview[]>(
66
- initialData?.reviews ?? []
67
- )
68
- const [count, setCount] = useState<number>(initialData?.count ?? 0)
69
- const [hasMore, setHasMore] = useState<boolean>(initialData?.has_more ?? false)
70
- const [loading, setLoading] = useState(false)
71
-
72
- const pageSize = data?.options.page_size ?? 10
73
-
74
- // No server-provided bootstrap → fetch it on mount.
75
- useEffect(() => {
76
- if (initialData) return
77
- let cancelled = false
78
- ;(async () => {
79
- try {
80
- const payload = await getWidget(client, productId)
81
- if (cancelled) return
82
- setData(payload)
83
- setReviews(payload.reviews)
84
- setCount(payload.count)
85
- setHasMore(payload.has_more)
86
- } catch {
87
- /* widget is decorative — a failed bootstrap renders nothing */
88
- }
89
- })()
90
- return () => {
91
- cancelled = true
92
- }
93
- // eslint-disable-next-line react-hooks/exhaustive-deps
94
- }, [client, productId])
95
-
96
- // Re-fetch the first page whenever the sort changes (post-mount only —
97
- // the bootstrap page covers sort='default', so no extra round-trip).
98
- useEffect(() => {
99
- if (sort === "default") return
100
- let cancelled = false
101
- setLoading(true)
102
- ;(async () => {
103
- try {
104
- const params = sortParamsFor(sort)
105
- const res = await listReviews(client, {
106
- product_id: productId,
107
- ...params,
108
- limit: pageSize,
109
- offset: 0,
110
- })
111
- if (cancelled) return
112
- setReviews(res.reviews)
113
- setCount(res.count)
114
- setHasMore(res.has_more)
115
- } finally {
116
- if (!cancelled) setLoading(false)
117
- }
118
- })()
119
- return () => {
120
- cancelled = true
121
- }
122
- // eslint-disable-next-line react-hooks/exhaustive-deps
123
- }, [sort, productId, pageSize])
124
-
125
- const loadMore = useCallback(async () => {
126
- setLoading(true)
127
- try {
128
- const params = sortParamsFor(sort)
129
- const res = await listReviews(client, {
130
- product_id: productId,
131
- ...params,
132
- limit: pageSize,
133
- offset: reviews.length,
134
- })
135
- setReviews((prev) => [...prev, ...res.reviews])
136
- setCount(res.count)
137
- setHasMore(res.has_more)
138
- } finally {
139
- setLoading(false)
140
- }
141
- }, [client, productId, sort, pageSize, reviews.length])
142
-
143
- // Header summary uses the full aggregate when available; else derive
144
- // from the loaded reviews (see the header comment).
145
- const aggregate = data?.aggregate ?? null
146
- const total = aggregate?.count ?? count
147
- const avg =
148
- aggregate?.avg_rating ??
149
- (reviews.length
150
- ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length
151
- : 0)
152
- const distribution =
153
- aggregate?.distribution ??
154
- reviews.reduce(
155
- (d, r) => {
156
- const key = String(r.rating) as "1" | "2" | "3" | "4" | "5"
157
- if (key in d) d[key]++
158
- return d
159
- },
160
- { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<
161
- "1" | "2" | "3" | "4" | "5",
162
- number
163
- >
164
- )
165
-
166
- if (total === 0) {
167
- return null // Hide the section entirely when there are no reviews
168
- }
169
-
170
- return (
171
- <section
172
- 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
- >
177
- <header className="mb-10">
178
- <h2 className="text-2xl sm:text-3xl font-semibold text-foreground mb-6">
179
- {l.sectionTitle}
180
- </h2>
181
-
182
- <div className="flex flex-col md:flex-row md:items-center gap-6 md:gap-12">
183
- <StarBadge
184
- aggregate={{ count: total, avg_rating: avg }}
185
- labels={l}
186
- />
187
-
188
- <RatingDistribution distribution={distribution} total={total} />
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>
203
-
204
- <ReviewList
205
- reviews={reviews}
206
- layout={data?.options.layout ?? "masonry"}
207
- labels={l}
208
- />
209
-
210
- {hasMore && (
211
- <div className="mt-8 text-center">
212
- <button
213
- type="button"
214
- onClick={loadMore}
215
- disabled={loading}
216
- className={cn(
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
- )}
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. `lg` is the lightbox's row.
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?: "sm" | "lg"
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 === "lg" ? "gap-px" : "gap-0.5", className)}
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 === "lg" ? "size-5" : "size-3.5",
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
+ }