@cartbase/storefront 0.18.1 → 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.
@@ -1,251 +1,193 @@
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 { CircleCheck } from "lucide-react"
5
+ import type { PublicReview } from "../api/reviews"
6
+ import { useLocaleArea } from "../locales/context"
7
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
8
+ import { formatReviewDate, reviewDisplayName } from "./helpers"
9
+ import { lightboxMedia, type LightboxPosition } from "./lightbox-state"
10
+ import { ReviewLightbox } from "./review-lightbox"
11
+ import { StarRow } from "./star-badge"
12
+
13
+ /**
14
+ * Review cards (masonry) + the review lightbox. Ported from the Alenika PDP
15
+ * reviews section (src/components/product/reviews-list.tsx, the card
16
+ * grid), presentational half: the fetch/sort/paging orchestration lives in
17
+ * `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden media is
18
+ * filtered out server-side, the client just renders what it gets).
19
+ *
20
+ * A card (Loox's, as evoo.bg runs it): the first photo on top at its own
21
+ * height, then the reviewer with the verified badge, the date, the stars and
22
+ * the text. A click anywhere on a card opens that review in
23
+ * `ReviewLightbox`; a click on one of its photos opens it on that photo.
24
+ *
25
+ * Verified-purchaser badge is unconditional every Cartbase review comes
26
+ * from a tokenized post-purchase email, so the badge is a tautology of
27
+ * the system, not an opt-in flag.
28
+ *
29
+ * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
30
+ * (short) pack together without empty gaps; `break-inside-avoid` keeps
31
+ * each card whole. Images-first ordering comes from the API's default
32
+ * sort, so photo cards lead the flow. `layout="list"` (the store's
33
+ * widget_layout setting) renders a single column instead.
34
+ */
35
+ export interface ReviewListProps {
36
+ reviews: PublicReview[]
37
+ /** Store display option (`getWidget().options.layout`). Default masonry. */
38
+ layout?: "masonry" | "list"
39
+ /** The date on each card (`getWidget().options.show_date`). Default on. */
40
+ showDate?: boolean
41
+ labels?: Partial<ReviewsUiLabels>
42
+ className?: string
43
+ }
44
+
45
+ export function ReviewList({
46
+ reviews,
47
+ layout = "masonry",
48
+ showDate = true,
49
+ labels,
50
+ className,
51
+ }: ReviewListProps) {
52
+ const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
53
+ const [open, setOpen] = useState<LightboxPosition | null>(null)
54
+
55
+ return (
56
+ <>
57
+ <ul
58
+ className={
59
+ className ??
60
+ (layout === "list"
61
+ ? "columns-1 [column-gap:1rem]"
62
+ : "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
63
+ }
64
+ >
65
+ {reviews.map((r, index) => {
66
+ const media = lightboxMedia(r)
67
+ const hero = media[0]
68
+ const rest = media.slice(1)
69
+ return (
70
+ <li
71
+ key={r.id}
72
+ className="relative mb-4 break-inside-avoid overflow-hidden rounded-lg bg-card shadow-card"
73
+ >
74
+ {/* The whole card is the button; the photos sit above it. */}
75
+ <button
76
+ type="button"
77
+ onClick={() => setOpen({ review: index, media: 0 })}
78
+ aria-label={`${l.openReview}: ${reviewDisplayName(r.customer_name)}`}
79
+ className="absolute inset-0 z-0 cursor-pointer"
80
+ />
81
+
82
+ {/* Hero media on top */}
83
+ {hero && (
84
+ <button
85
+ type="button"
86
+ onClick={() => setOpen({ review: index, media: 0 })}
87
+ aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
88
+ className="relative z-10 block w-full group"
89
+ >
90
+ {hero.type === "image" ? (
91
+ <img
92
+ src={hero.thumb || hero.url}
93
+ alt=""
94
+ loading="lazy"
95
+ className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
96
+ />
97
+ ) : (
98
+ <>
99
+ {/* `#t=0.001`: iOS Safari paints no first frame from
100
+ `preload="metadata"` alone; the seek makes it. */}
101
+ <video
102
+ src={`${hero.url}#t=0.001`}
103
+ muted
104
+ playsInline
105
+ preload="metadata"
106
+ className="block w-full h-auto object-cover bg-black"
107
+ />
108
+ <span className="absolute inset-0 flex items-center justify-center">
109
+ <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
110
+ <span
111
+ aria-hidden
112
+ className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
113
+ />
114
+ </span>
115
+ </span>
116
+ </>
117
+ )}
118
+ </button>
119
+ )}
120
+
121
+ <div className="p-4">
122
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
123
+ <span className="text-body font-semibold text-foreground">
124
+ {reviewDisplayName(r.customer_name)}
125
+ </span>
126
+ <span
127
+ className="inline-flex items-center gap-1 text-caption text-foreground"
128
+ title={l.verifiedTitle}
129
+ >
130
+ <CircleCheck aria-hidden className="size-4 fill-foreground text-background" />
131
+ {l.verified}
132
+ </span>
133
+ </div>
134
+ {showDate ? (
135
+ <p className="mt-0.5 text-caption text-muted-foreground">
136
+ {formatReviewDate(r.created_at, l)}
137
+ </p>
138
+ ) : null}
139
+ <StarRow rating={r.rating} size="md" className="mt-2" />
140
+
141
+ {r.title && (
142
+ <h3 className="mt-2 text-h6 text-foreground">{r.title}</h3>
143
+ )}
144
+ {r.body && (
145
+ <p className="mt-2 whitespace-pre-wrap text-body text-foreground">{r.body}</p>
146
+ )}
147
+
148
+ {/* Extra media beyond the hero */}
149
+ {rest.length > 0 && (
150
+ <div className="relative z-10 mt-3 grid grid-cols-4 gap-2">
151
+ {rest.map((m, i) => (
152
+ <button
153
+ key={i}
154
+ type="button"
155
+ onClick={() => setOpen({ review: index, media: i + 1 })}
156
+ aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
157
+ className="relative aspect-square overflow-hidden rounded-md bg-muted"
158
+ >
159
+ {m.type === "image" ? (
160
+ <img
161
+ src={m.thumb || m.url}
162
+ alt=""
163
+ loading="lazy"
164
+ className="absolute inset-0 w-full h-full object-cover"
165
+ />
166
+ ) : (
167
+ <video
168
+ src={`${m.url}#t=0.001`}
169
+ muted
170
+ playsInline
171
+ preload="metadata"
172
+ className="absolute inset-0 w-full h-full object-cover bg-black"
173
+ />
174
+ )}
175
+ </button>
176
+ ))}
177
+ </div>
178
+ )}
179
+ </div>
180
+ </li>
181
+ )
182
+ })}
183
+ </ul>
184
+
185
+ <ReviewLightbox
186
+ reviews={reviews}
187
+ position={open}
188
+ onPositionChange={setOpen}
189
+ labels={labels}
190
+ />
191
+ </>
192
+ )
193
+ }