@cartbase/storefront 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
|
@@ -1,193 +1,193 @@
|
|
|
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-
|
|
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
|
-
}
|
|
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-2 sm:columns-[14rem] [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
|
+
}
|