@cartbase/storefront 0.18.0 → 0.19.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.
@@ -1,251 +1,191 @@
1
- "use client"
2
-
3
- import { useEffect, useState } from "react"
4
- import { ShieldCheck, X } from "lucide-react"
5
- import type { PublicReview } from "../api/reviews"
6
- import { useLocaleArea } from "../locales/context"
7
- import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
8
- import { formatReviewDate, reviewDisplayName } from "./helpers"
9
- import { StarRow } from "./star-badge"
10
-
11
- /**
12
- * Review cards (masonry) + lightbox. Ported from the Alenika PDP reviews
13
- * section (src/components/product/reviews-list.tsx — the card grid +
14
- * `Lightbox`), presentational half: the fetch/sort/paging orchestration
15
- * lives in `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden
16
- * media is filtered out server-side — the client just renders what it
17
- * gets).
18
- *
19
- * Verified-purchaser badge is unconditional — every Cartbase review comes
20
- * from a tokenized post-purchase email, so the badge is a tautology of
21
- * the system, not an opt-in flag.
22
- *
23
- * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
24
- * (short) pack together without empty gaps; `break-inside-avoid` keeps
25
- * each card whole. Images-first ordering comes from the API's default
26
- * sort, so photo cards lead the flow. `layout="list"` (the store's
27
- * widget_layout setting) renders a single column instead.
28
- */
29
- export interface ReviewListProps {
30
- reviews: PublicReview[]
31
- /** Store display option (`getWidget().options.layout`). Default masonry. */
32
- layout?: "masonry" | "list"
33
- labels?: Partial<ReviewsUiLabels>
34
- className?: string
35
- }
36
-
37
- export function ReviewList({
38
- reviews,
39
- layout = "masonry",
40
- labels,
41
- className,
42
- }: ReviewListProps) {
43
- const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
44
- // Lightbox: when set, the indicated review's media at the given index
45
- // is shown full-bleed.
46
- const [lightbox, setLightbox] = useState<{
47
- reviewId: string
48
- mediaIdx: number
49
- } | null>(null)
50
-
51
- return (
52
- <>
53
- <ul
54
- className={
55
- className ??
56
- (layout === "list"
57
- ? "columns-1 [column-gap:1rem]"
58
- : "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
59
- }
60
- >
61
- {reviews.map((r) => {
62
- const media = (r.media ?? []).filter(Boolean)
63
- const hero = media[0]
64
- const rest = media.slice(1)
65
- return (
66
- <li
67
- key={r.id}
68
- className="break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
69
- >
70
- {/* Hero media on top */}
71
- {hero && (
72
- <button
73
- type="button"
74
- onClick={() => setLightbox({ reviewId: r.id, mediaIdx: 0 })}
75
- aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
76
- className="block w-full relative group"
77
- >
78
- {hero.type === "image" ? (
79
- <img
80
- src={hero.thumb || hero.url}
81
- alt=""
82
- loading="lazy"
83
- className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
84
- />
85
- ) : (
86
- <>
87
- <video
88
- src={hero.url}
89
- muted
90
- preload="metadata"
91
- className="block w-full h-auto object-cover bg-black"
92
- />
93
- <span className="absolute inset-0 flex items-center justify-center">
94
- <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
95
- <span
96
- aria-hidden
97
- className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
98
- />
99
- </span>
100
- </span>
101
- </>
102
- )}
103
- </button>
104
- )}
105
-
106
- <div className="p-4 sm:p-5">
107
- <StarRow rating={r.rating} />
108
-
109
- <div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
110
- <span className="text-sm font-medium text-foreground">
111
- {reviewDisplayName(r.customer_name)}
112
- </span>
113
- <span
114
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
115
- title={l.verifiedTitle}
116
- >
117
- <ShieldCheck className="w-3.5 h-3.5" />
118
- {l.verified}
119
- </span>
120
- <span className="text-xs text-muted-foreground ml-auto">
121
- {formatReviewDate(r.created_at, l)}
122
- </span>
123
- </div>
124
-
125
- {r.title && (
126
- <h3 className="text-base font-semibold text-foreground leading-snug mt-3">
127
- {r.title}
128
- </h3>
129
- )}
130
- {r.body && (
131
- <p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
132
- {r.body}
133
- </p>
134
- )}
135
-
136
- {/* Extra media beyond the hero */}
137
- {rest.length > 0 && (
138
- <div className="grid grid-cols-4 gap-2 mt-3">
139
- {rest.map((m, i) => (
140
- <button
141
- key={i}
142
- type="button"
143
- onClick={() =>
144
- setLightbox({ reviewId: r.id, mediaIdx: i + 1 })
145
- }
146
- aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
147
- className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
148
- >
149
- {m.type === "image" ? (
150
- <img
151
- src={m.thumb || m.url}
152
- alt=""
153
- loading="lazy"
154
- className="absolute inset-0 w-full h-full object-cover"
155
- />
156
- ) : (
157
- <video
158
- src={m.url}
159
- muted
160
- preload="metadata"
161
- className="absolute inset-0 w-full h-full object-cover bg-black"
162
- />
163
- )}
164
- </button>
165
- ))}
166
- </div>
167
- )}
168
- </div>
169
- </li>
170
- )
171
- })}
172
- </ul>
173
-
174
- {lightbox && (
175
- <ReviewLightbox
176
- review={reviews.find((r) => r.id === lightbox.reviewId)}
177
- mediaIdx={lightbox.mediaIdx}
178
- closeLabel={l.close}
179
- onClose={() => setLightbox(null)}
180
- />
181
- )}
182
- </>
183
- )
184
- }
185
-
186
- /** Simple full-bleed overlay for the picked media. Closes on Escape/backdrop. */
187
- export function ReviewLightbox({
188
- review,
189
- mediaIdx,
190
- closeLabel,
191
- onClose,
192
- }: {
193
- review: PublicReview | undefined
194
- mediaIdx: number
195
- closeLabel?: string
196
- onClose: () => void
197
- }) {
198
- const fallback = useLocaleArea("reviews", defaultReviewsUiLabels)
199
- // Close on Escape
200
- useEffect(() => {
201
- const onKey = (e: KeyboardEvent) => {
202
- if (e.key === "Escape") onClose()
203
- }
204
- window.addEventListener("keydown", onKey)
205
- return () => window.removeEventListener("keydown", onKey)
206
- }, [onClose])
207
-
208
- if (!review) return null
209
- // Index into the SAME filtered array the grid used to compute mediaIdx,
210
- // so a falsy/hidden entry can't shift which item opens (production fix).
211
- const items = (review.media ?? []).filter(Boolean)
212
- const item = items[mediaIdx]
213
- if (!item) return null
214
-
215
- return (
216
- <div
217
- role="dialog"
218
- aria-modal="true"
219
- className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
220
- onClick={onClose}
221
- >
222
- <button
223
- type="button"
224
- aria-label={closeLabel ?? fallback.close}
225
- onClick={onClose}
226
- className="absolute top-4 right-4 w-10 h-10 rounded-full bg-background/10 hover:bg-background/20 text-background flex items-center justify-center"
227
- >
228
- <X className="w-5 h-5" />
229
- </button>
230
- <div
231
- className="max-w-5xl max-h-[90vh] w-full flex items-center justify-center"
232
- onClick={(e) => e.stopPropagation()}
233
- >
234
- {item.type === "image" ? (
235
- <img
236
- src={item.url}
237
- alt=""
238
- className="max-w-full max-h-[90vh] object-contain"
239
- />
240
- ) : (
241
- <video
242
- src={item.url}
243
- controls
244
- autoPlay
245
- className="max-w-full max-h-[90vh]"
246
- />
247
- )}
248
- </div>
249
- </div>
250
- )
251
- }
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { ShieldCheck } from "lucide-react"
5
+ import type { PublicReview } from "../api/reviews"
6
+ import { useLocaleArea } from "../locales/context"
7
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
8
+ import { formatReviewDate, reviewDisplayName } from "./helpers"
9
+ import { lightboxMedia, type LightboxPosition } from "./lightbox-state"
10
+ import { ReviewLightbox } from "./review-lightbox"
11
+ import { StarRow } from "./star-badge"
12
+
13
+ /**
14
+ * Review cards (masonry) + the review lightbox. Ported from the Alenika PDP
15
+ * reviews section (src/components/product/reviews-list.tsx, the card
16
+ * grid), presentational half: the fetch/sort/paging orchestration lives in
17
+ * `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden media is
18
+ * filtered out server-side, the client just renders what it gets).
19
+ *
20
+ * A click anywhere on a card opens that review in `ReviewLightbox`; a click
21
+ * on one of its photos opens it on that photo.
22
+ *
23
+ * Verified-purchaser badge is unconditional every Cartbase review comes
24
+ * from a tokenized post-purchase email, so the badge is a tautology of
25
+ * the system, not an opt-in flag.
26
+ *
27
+ * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
28
+ * (short) pack together without empty gaps; `break-inside-avoid` keeps
29
+ * each card whole. Images-first ordering comes from the API's default
30
+ * sort, so photo cards lead the flow. `layout="list"` (the store's
31
+ * widget_layout setting) renders a single column instead.
32
+ */
33
+ export interface ReviewListProps {
34
+ reviews: PublicReview[]
35
+ /** Store display option (`getWidget().options.layout`). Default masonry. */
36
+ layout?: "masonry" | "list"
37
+ labels?: Partial<ReviewsUiLabels>
38
+ className?: string
39
+ }
40
+
41
+ export function ReviewList({
42
+ reviews,
43
+ layout = "masonry",
44
+ labels,
45
+ className,
46
+ }: ReviewListProps) {
47
+ const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
48
+ const [open, setOpen] = useState<LightboxPosition | null>(null)
49
+
50
+ return (
51
+ <>
52
+ <ul
53
+ className={
54
+ className ??
55
+ (layout === "list"
56
+ ? "columns-1 [column-gap:1rem]"
57
+ : "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
58
+ }
59
+ >
60
+ {reviews.map((r, index) => {
61
+ const media = lightboxMedia(r)
62
+ const hero = media[0]
63
+ const rest = media.slice(1)
64
+ return (
65
+ <li
66
+ key={r.id}
67
+ className="relative break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
68
+ >
69
+ {/* The whole card is the button; the photos sit above it. */}
70
+ <button
71
+ type="button"
72
+ onClick={() => setOpen({ review: index, media: 0 })}
73
+ aria-label={`${l.openReview}: ${reviewDisplayName(r.customer_name)}`}
74
+ className="absolute inset-0 z-0 cursor-pointer"
75
+ />
76
+
77
+ {/* Hero media on top */}
78
+ {hero && (
79
+ <button
80
+ type="button"
81
+ onClick={() => setOpen({ review: index, media: 0 })}
82
+ aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
83
+ className="relative z-10 block w-full group"
84
+ >
85
+ {hero.type === "image" ? (
86
+ <img
87
+ src={hero.thumb || hero.url}
88
+ alt=""
89
+ loading="lazy"
90
+ className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
91
+ />
92
+ ) : (
93
+ <>
94
+ {/* `#t=0.001`: iOS Safari paints no first frame from
95
+ `preload="metadata"` alone; the seek makes it. */}
96
+ <video
97
+ src={`${hero.url}#t=0.001`}
98
+ muted
99
+ playsInline
100
+ preload="metadata"
101
+ className="block w-full h-auto object-cover bg-black"
102
+ />
103
+ <span className="absolute inset-0 flex items-center justify-center">
104
+ <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
105
+ <span
106
+ aria-hidden
107
+ className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
108
+ />
109
+ </span>
110
+ </span>
111
+ </>
112
+ )}
113
+ </button>
114
+ )}
115
+
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
+ {reviewDisplayName(r.customer_name)}
122
+ </span>
123
+ <span
124
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
125
+ title={l.verifiedTitle}
126
+ >
127
+ <ShieldCheck className="w-3.5 h-3.5" />
128
+ {l.verified}
129
+ </span>
130
+ <span className="text-xs text-muted-foreground ml-auto">
131
+ {formatReviewDate(r.created_at, l)}
132
+ </span>
133
+ </div>
134
+
135
+ {r.title && (
136
+ <h3 className="text-base font-semibold text-foreground leading-snug mt-3">
137
+ {r.title}
138
+ </h3>
139
+ )}
140
+ {r.body && (
141
+ <p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
142
+ {r.body}
143
+ </p>
144
+ )}
145
+
146
+ {/* Extra media beyond the hero */}
147
+ {rest.length > 0 && (
148
+ <div className="relative z-10 grid grid-cols-4 gap-2 mt-3">
149
+ {rest.map((m, i) => (
150
+ <button
151
+ key={i}
152
+ type="button"
153
+ onClick={() => setOpen({ review: index, media: i + 1 })}
154
+ aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
155
+ className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
156
+ >
157
+ {m.type === "image" ? (
158
+ <img
159
+ src={m.thumb || m.url}
160
+ alt=""
161
+ loading="lazy"
162
+ className="absolute inset-0 w-full h-full object-cover"
163
+ />
164
+ ) : (
165
+ <video
166
+ src={`${m.url}#t=0.001`}
167
+ muted
168
+ playsInline
169
+ preload="metadata"
170
+ className="absolute inset-0 w-full h-full object-cover bg-black"
171
+ />
172
+ )}
173
+ </button>
174
+ ))}
175
+ </div>
176
+ )}
177
+ </div>
178
+ </li>
179
+ )
180
+ })}
181
+ </ul>
182
+
183
+ <ReviewLightbox
184
+ reviews={reviews}
185
+ position={open}
186
+ onPositionChange={setOpen}
187
+ labels={labels}
188
+ />
189
+ </>
190
+ )
191
+ }
@@ -11,23 +11,33 @@ import { reviewCountLabel } from "./helpers"
11
11
  * arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
12
12
  */
13
13
 
14
- /** Five-star row, filled to `Math.round(rating)`. */
14
+ /**
15
+ * Five-star row, filled to `Math.round(rating)` in the theme's `rating`
16
+ * colour. `lg` is the lightbox's row.
17
+ */
15
18
  export function StarRow({
16
19
  rating,
20
+ size = "sm",
17
21
  className,
18
22
  }: {
19
23
  rating: number
24
+ size?: "sm" | "lg"
20
25
  className?: string
21
26
  }) {
22
27
  return (
23
- <span className={cn("inline-flex items-center gap-0.5", className)}>
28
+ <span
29
+ role="img"
30
+ aria-label={`${Math.round(rating)} / 5`}
31
+ className={cn("inline-flex items-center", size === "lg" ? "gap-px" : "gap-0.5", className)}
32
+ >
24
33
  {Array.from({ length: 5 }).map((_, i) => (
25
34
  <Star
26
35
  key={i}
36
+ aria-hidden
27
37
  className={cn(
28
- "w-3.5 h-3.5",
38
+ size === "lg" ? "size-5" : "size-3.5",
29
39
  i < Math.round(rating)
30
- ? "fill-warning text-warning"
40
+ ? "fill-rating text-rating"
31
41
  : "fill-muted text-muted-foreground"
32
42
  )}
33
43
  strokeWidth={0}
@@ -53,7 +63,7 @@ export function StarBadge({ aggregate, labels, className }: StarBadgeProps) {
53
63
  if (!aggregate.count) return null
54
64
  return (
55
65
  <div className={cn("flex items-center gap-3 shrink-0", className)}>
56
- <Star className="w-10 h-10 fill-warning text-warning" strokeWidth={0} />
66
+ <Star className="w-10 h-10 fill-rating text-rating" strokeWidth={0} />
57
67
  <div>
58
68
  <div className="text-3xl font-bold text-foreground leading-none">
59
69
  {aggregate.avg_rating.toFixed(1)}
@@ -6,7 +6,7 @@
6
6
  * the server templates read it back and convert to `offset`. Windowed
7
7
  * layout: ≤7 pages inline, otherwise ellipsis around the current window.
8
8
  */
9
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
9
+ import { usePathname, useRouter } from "next/navigation"
10
10
  import { cn } from "../lib/utils"
11
11
 
12
12
  export function Pagination({
@@ -20,13 +20,14 @@ export function Pagination({
20
20
  }) {
21
21
  const router = useRouter()
22
22
  const pathname = usePathname()
23
- const searchParams = useSearchParams()
24
23
 
25
24
  const arrayRange = (start: number, stop: number) =>
26
25
  Array.from({ length: stop - start + 1 }, (_, index) => start + index)
27
26
 
28
27
  const handlePageChange = (newPage: number) => {
29
- const params = new URLSearchParams(searchParams)
28
+ // The query as it is at the click: read while rendering, it would make
29
+ // every listing page request data and stop it from being prerendered.
30
+ const params = new URLSearchParams(window.location.search)
30
31
  params.set("page", newPage.toString())
31
32
  router.push(`${pathname}?${params.toString()}`)
32
33
  }
@@ -12,7 +12,7 @@
12
12
  * from `sortOptionLabelKeys` (labels.ts, pure) so map completeness against
13
13
  * `SortOptions` is unit-tested.
14
14
  */
15
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
15
+ import { usePathname, useRouter } from "next/navigation"
16
16
  import { useCallback } from "react"
17
17
  import { cn } from "../lib/utils"
18
18
  import type { SortOptions } from "../lib/sort-products"
@@ -35,20 +35,18 @@ export function SortSelect({
35
35
  const l = { ...defaultStoreLabels, ...labels }
36
36
  const router = useRouter()
37
37
  const pathname = usePathname()
38
- const searchParams = useSearchParams()
39
38
 
40
39
  const sortOptions = (
41
40
  Object.keys(sortOptionLabelKeys) as SortOptions[]
42
41
  ).map((value) => ({ value, label: l[sortOptionLabelKeys[value]] }))
43
42
 
44
- const createQueryString = useCallback(
45
- (name: string, value: string) => {
46
- const params = new URLSearchParams(searchParams)
47
- params.set(name, value)
48
- return params.toString()
49
- },
50
- [searchParams]
51
- )
43
+ // The query as it is at the click: read while rendering, it would make
44
+ // every listing page request data and stop it from being prerendered.
45
+ const createQueryString = useCallback((name: string, value: string) => {
46
+ const params = new URLSearchParams(window.location.search)
47
+ params.set(name, value)
48
+ return params.toString()
49
+ }, [])
52
50
 
53
51
  const handleChange = (value: SortOptions) => {
54
52
  const query = createQueryString("sortBy", value)
package/theme/theme.css CHANGED
@@ -40,6 +40,7 @@
40
40
  --color-success-foreground: var(--success-foreground);
41
41
  --color-warning: var(--warning);
42
42
  --color-warning-foreground: var(--warning-foreground);
43
+ --color-rating: var(--rating);
43
44
 
44
45
  --color-border: var(--border);
45
46
  --color-input: var(--input);
package/theme/tokens.css CHANGED
@@ -44,6 +44,9 @@
44
44
  --warning: oklch(0.828 0.189 84.429);
45
45
  --warning-foreground: oklch(0.279 0.077 45.635);
46
46
 
47
+ /* ── Ratings: the filled review star ──────────────────────────────────── */
48
+ --rating: oklch(0.828 0.189 84.429);
49
+
47
50
  /* ── Lines and focus ──────────────────────────────────────────────────── */
48
51
  --border: oklch(0.922 0 0);
49
52
  --input: oklch(0.922 0 0);
@@ -94,6 +97,8 @@
94
97
  --warning: oklch(0.828 0.189 84.429);
95
98
  --warning-foreground: oklch(0.279 0.077 45.635);
96
99
 
100
+ --rating: oklch(0.828 0.189 84.429);
101
+
97
102
  --border: oklch(1 0 0 / 12%);
98
103
  --input: oklch(1 0 0 / 18%);
99
104
  --ring: oklch(0.556 0 0);