@cartbase/storefront 0.19.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/reviews.ts +22 -1
- 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/bg.ts +3 -2
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -2
- 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/index.ts +12 -0
- package/src/reviews-ui/labels.ts +5 -2
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -191
- package/src/reviews-ui/review-widget.tsx +63 -71
- package/src/reviews-ui/star-badge.tsx +6 -4
- package/src/reviews-ui/widget-options.ts +55 -0
- 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,271 +1,271 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useEffect, type ReactNode } from "react"
|
|
4
|
-
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
5
|
-
import { ChevronLeft, ChevronRight, CircleCheck, Info, X } from "lucide-react"
|
|
6
|
-
import type { PublicReview } from "../api/reviews"
|
|
7
|
-
import { cn } from "../lib/utils"
|
|
8
|
-
import { useLocaleArea } from "../locales/context"
|
|
9
|
-
import { DialogOverlay, DialogPortal } from "../primitives/ui/dialog"
|
|
10
|
-
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/ui/popover"
|
|
11
|
-
import { formatLabel, formatReviewDate, reviewDisplayName } from "./helpers"
|
|
12
|
-
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
13
|
-
import { lightboxMedia, stepLightbox, type LightboxPosition } from "./lightbox-state"
|
|
14
|
-
import { StarRow } from "./star-badge"
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The review lightbox: one review, whole. The photo or video on the left
|
|
18
|
-
* over a blurred copy of itself, the review on the right: the reviewer's
|
|
19
|
-
* name, "Verified" with a button that says what it means, the stars, the
|
|
20
|
-
* date, the text and the store's reply. On a phone the photo sits on top
|
|
21
|
-
* and the review scrolls under it.
|
|
22
|
-
*
|
|
23
|
-
* Every widget that shows reviews opens this one: it takes the widget's
|
|
24
|
-
* own list and a position, so the arrows (and the arrow keys) walk through
|
|
25
|
-
* the open review's photos and then on to the next review. The dots jump
|
|
26
|
-
* between one review's photos. Built on the package's Dialog (focus trap,
|
|
27
|
-
* scroll lock, Escape and a click outside close it).
|
|
28
|
-
*
|
|
29
|
-
* const [open, setOpen] = useState<LightboxPosition | null>(null)
|
|
30
|
-
* <ReviewLightbox reviews={reviews} position={open} onPositionChange={setOpen} />
|
|
31
|
-
*
|
|
32
|
-
* Alenika's production fixes are kept: the media index counts from the
|
|
33
|
-
* same filtered list the cards use, and a video plays inline on iPhone
|
|
34
|
-
* with its controls as the answer to a blocked autoplay.
|
|
35
|
-
*/
|
|
36
|
-
export interface ReviewLightboxProps {
|
|
37
|
-
/** The reviews the opening widget shows, in its order. */
|
|
38
|
-
reviews: PublicReview[]
|
|
39
|
-
/** The open review and which of its photos; null when closed. */
|
|
40
|
-
position: LightboxPosition | null
|
|
41
|
-
/** The new position when the shopper steps, null when they close. */
|
|
42
|
-
onPositionChange: (position: LightboxPosition | null) => void
|
|
43
|
-
labels?: Partial<ReviewsUiLabels>
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function ReviewLightbox({ reviews, position, onPositionChange, labels }: ReviewLightboxProps) {
|
|
47
|
-
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
48
|
-
const review = position ? reviews[position.review] : undefined
|
|
49
|
-
const media = lightboxMedia(review)
|
|
50
|
-
const item = position ? media[position.media] : undefined
|
|
51
|
-
const previous = position ? stepLightbox(reviews, position, -1) : null
|
|
52
|
-
const next = position ? stepLightbox(reviews, position, 1) : null
|
|
53
|
-
|
|
54
|
-
// The arrow keys step as the arrows do; a video keeps its own keys.
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
if (!position) return
|
|
57
|
-
const onKey = (event: KeyboardEvent) => {
|
|
58
|
-
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
|
|
59
|
-
if ((event.target as HTMLElement | null)?.closest?.("video, input, textarea, select")) return
|
|
60
|
-
const to = stepLightbox(reviews, position, event.key === "ArrowLeft" ? -1 : 1)
|
|
61
|
-
if (to) onPositionChange(to)
|
|
62
|
-
}
|
|
63
|
-
window.addEventListener("keydown", onKey)
|
|
64
|
-
return () => window.removeEventListener("keydown", onKey)
|
|
65
|
-
}, [position, reviews, onPositionChange])
|
|
66
|
-
|
|
67
|
-
const name = review ? reviewDisplayName(review.customer_name) : ""
|
|
68
|
-
const stepping = Boolean(previous || next)
|
|
69
|
-
|
|
70
|
-
return (
|
|
71
|
-
<DialogPrimitive.Root
|
|
72
|
-
open={Boolean(review)}
|
|
73
|
-
onOpenChange={(open) => {
|
|
74
|
-
if (!open) onPositionChange(null)
|
|
75
|
-
}}
|
|
76
|
-
>
|
|
77
|
-
<DialogPortal>
|
|
78
|
-
<DialogOverlay />
|
|
79
|
-
<DialogPrimitive.Content
|
|
80
|
-
aria-describedby={undefined}
|
|
81
|
-
className={cn(
|
|
82
|
-
"fixed inset-0 z-50 outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
|
83
|
-
"md:inset-auto md:left-1/2 md:top-1/2 md:w-[calc(100%-10rem)] md:-translate-x-1/2 md:-translate-y-1/2 md:data-[state=open]:zoom-in-95 md:data-[state=closed]:zoom-out-95",
|
|
84
|
-
item ? "md:max-w-[55rem]" : "md:max-w-lg"
|
|
85
|
-
)}
|
|
86
|
-
>
|
|
87
|
-
{review ? (
|
|
88
|
-
<>
|
|
89
|
-
<DialogPrimitive.Close
|
|
90
|
-
aria-label={l.close}
|
|
91
|
-
className={cn(
|
|
92
|
-
"absolute left-4 top-4 z-10 flex size-10 items-center justify-center rounded-md transition-colors",
|
|
93
|
-
item
|
|
94
|
-
? "bg-black/40 text-white hover:bg-black/55"
|
|
95
|
-
: "bg-muted text-foreground hover:bg-border"
|
|
96
|
-
)}
|
|
97
|
-
>
|
|
98
|
-
<X className="size-5" />
|
|
99
|
-
</DialogPrimitive.Close>
|
|
100
|
-
|
|
101
|
-
<div
|
|
102
|
-
className={cn(
|
|
103
|
-
"flex h-full flex-col overflow-y-auto bg-background md:overflow-hidden md:rounded-lg md:shadow-popover",
|
|
104
|
-
item ? "md:h-[min(40rem,calc(100dvh-4rem))] md:flex-row" : "md:h-auto md:max-h-[calc(100dvh-4rem)]"
|
|
105
|
-
)}
|
|
106
|
-
>
|
|
107
|
-
{item && position ? (
|
|
108
|
-
<div className="relative flex h-[min(60vh,32rem)] shrink-0 items-center justify-center overflow-hidden bg-muted md:h-full md:w-[54%]">
|
|
109
|
-
{item.type === "image" ? (
|
|
110
|
-
<>
|
|
111
|
-
<img
|
|
112
|
-
src={item.url}
|
|
113
|
-
alt=""
|
|
114
|
-
aria-hidden
|
|
115
|
-
className="pointer-events-none absolute inset-0 size-full scale-110 object-cover blur-2xl"
|
|
116
|
-
/>
|
|
117
|
-
<img
|
|
118
|
-
key={item.url}
|
|
119
|
-
src={item.url}
|
|
120
|
-
alt={formatLabel(l.mediaAlt, {
|
|
121
|
-
n: position.media + 1,
|
|
122
|
-
total: media.length,
|
|
123
|
-
name,
|
|
124
|
-
})}
|
|
125
|
-
className="relative size-full object-contain"
|
|
126
|
-
/>
|
|
127
|
-
</>
|
|
128
|
-
) : (
|
|
129
|
-
<video
|
|
130
|
-
key={item.url}
|
|
131
|
-
src={`${item.url}#t=0.001`}
|
|
132
|
-
controls
|
|
133
|
-
autoPlay
|
|
134
|
-
playsInline
|
|
135
|
-
preload="metadata"
|
|
136
|
-
className="relative size-full bg-black object-contain"
|
|
137
|
-
/>
|
|
138
|
-
)}
|
|
139
|
-
|
|
140
|
-
{media.length > 1 ? (
|
|
141
|
-
<div
|
|
142
|
-
className={cn(
|
|
143
|
-
"absolute inset-x-0 flex justify-center gap-1.5",
|
|
144
|
-
item.type === "video" ? "top-4" : "bottom-4"
|
|
145
|
-
)}
|
|
146
|
-
>
|
|
147
|
-
{media.map((entry, index) => (
|
|
148
|
-
<button
|
|
149
|
-
key={`${entry.url}-${index}`}
|
|
150
|
-
type="button"
|
|
151
|
-
aria-label={formatLabel(l.showMediaAt, { n: index + 1 })}
|
|
152
|
-
aria-current={index === position.media}
|
|
153
|
-
onClick={() => onPositionChange({ review: position.review, media: index })}
|
|
154
|
-
className={cn(
|
|
155
|
-
"size-2 rounded-full ring-1 ring-black/20 transition-colors",
|
|
156
|
-
index === position.media ? "bg-white" : "bg-white/50 hover:bg-white/80"
|
|
157
|
-
)}
|
|
158
|
-
/>
|
|
159
|
-
))}
|
|
160
|
-
</div>
|
|
161
|
-
) : null}
|
|
162
|
-
</div>
|
|
163
|
-
) : null}
|
|
164
|
-
|
|
165
|
-
<div
|
|
166
|
-
className={cn(
|
|
167
|
-
"flex min-w-0 flex-1 flex-col gap-4 p-6 md:overflow-y-auto md:p-7",
|
|
168
|
-
!item && "pt-16 md:pt-16",
|
|
169
|
-
stepping && "pb-20 md:pb-7"
|
|
170
|
-
)}
|
|
171
|
-
>
|
|
172
|
-
<div className="flex flex-col gap-1.5">
|
|
173
|
-
<div className="flex items-center justify-between gap-3">
|
|
174
|
-
<DialogPrimitive.Title className="min-w-0 truncate text-body text-foreground">
|
|
175
|
-
{name}
|
|
176
|
-
</DialogPrimitive.Title>
|
|
177
|
-
<div className="flex shrink-0 items-center gap-2 text-body-small text-foreground">
|
|
178
|
-
<span className="flex items-center gap-1.5">
|
|
179
|
-
<CircleCheck aria-hidden className="size-4.5 fill-foreground text-background" />
|
|
180
|
-
{l.verified}
|
|
181
|
-
</span>
|
|
182
|
-
<span aria-hidden className="h-5 w-px bg-border" />
|
|
183
|
-
<Popover>
|
|
184
|
-
<PopoverTrigger
|
|
185
|
-
aria-label={l.verifiedInfo}
|
|
186
|
-
className="flex size-7 items-center justify-center rounded-full transition-colors hover:bg-muted"
|
|
187
|
-
>
|
|
188
|
-
<Info className="size-4.5" />
|
|
189
|
-
</PopoverTrigger>
|
|
190
|
-
<PopoverContent align="end" className="w-64 text-body-small">
|
|
191
|
-
{l.verifiedTitle}
|
|
192
|
-
</PopoverContent>
|
|
193
|
-
</Popover>
|
|
194
|
-
</div>
|
|
195
|
-
</div>
|
|
196
|
-
<div className="flex items-center justify-between gap-3">
|
|
197
|
-
<StarRow rating={review.rating} size="lg" />
|
|
198
|
-
<span className="shrink-0 text-caption text-muted-foreground">
|
|
199
|
-
{formatReviewDate(review.created_at, l)}
|
|
200
|
-
</span>
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
{review.title ? <p className="text-h6 text-foreground">{review.title}</p> : null}
|
|
205
|
-
{review.body ? (
|
|
206
|
-
<p className="whitespace-pre-wrap text-body text-foreground">{review.body}</p>
|
|
207
|
-
) : null}
|
|
208
|
-
|
|
209
|
-
{review.admin_response ? (
|
|
210
|
-
<div className="rounded-md bg-muted p-4">
|
|
211
|
-
<p className="text-caption text-muted-foreground">{l.storeReply}</p>
|
|
212
|
-
<p className="mt-1 whitespace-pre-wrap text-body-small text-foreground">
|
|
213
|
-
{review.admin_response}
|
|
214
|
-
</p>
|
|
215
|
-
</div>
|
|
216
|
-
) : null}
|
|
217
|
-
</div>
|
|
218
|
-
</div>
|
|
219
|
-
|
|
220
|
-
{previous ? (
|
|
221
|
-
<StepButton
|
|
222
|
-
label={l.previous}
|
|
223
|
-
onClick={() => onPositionChange(previous)}
|
|
224
|
-
className="bottom-4 left-4 md:-left-14"
|
|
225
|
-
>
|
|
226
|
-
<ChevronLeft className="size-5" />
|
|
227
|
-
</StepButton>
|
|
228
|
-
) : null}
|
|
229
|
-
{next ? (
|
|
230
|
-
<StepButton
|
|
231
|
-
label={l.next}
|
|
232
|
-
onClick={() => onPositionChange(next)}
|
|
233
|
-
className="bottom-4 right-4 md:-right-14"
|
|
234
|
-
>
|
|
235
|
-
<ChevronRight className="size-5" />
|
|
236
|
-
</StepButton>
|
|
237
|
-
) : null}
|
|
238
|
-
</>
|
|
239
|
-
) : null}
|
|
240
|
-
</DialogPrimitive.Content>
|
|
241
|
-
</DialogPortal>
|
|
242
|
-
</DialogPrimitive.Root>
|
|
243
|
-
)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** A round arrow: in the bottom corners on a phone, beside the card on a wider screen. */
|
|
247
|
-
function StepButton({
|
|
248
|
-
label,
|
|
249
|
-
onClick,
|
|
250
|
-
className,
|
|
251
|
-
children,
|
|
252
|
-
}: {
|
|
253
|
-
label: string
|
|
254
|
-
onClick: () => void
|
|
255
|
-
className: string
|
|
256
|
-
children: ReactNode
|
|
257
|
-
}) {
|
|
258
|
-
return (
|
|
259
|
-
<button
|
|
260
|
-
type="button"
|
|
261
|
-
aria-label={label}
|
|
262
|
-
onClick={onClick}
|
|
263
|
-
className={cn(
|
|
264
|
-
"absolute z-10 flex size-10 items-center justify-center rounded-full bg-background text-foreground shadow-popover transition-colors hover:bg-muted md:bottom-auto md:top-1/2 md:-translate-y-1/2",
|
|
265
|
-
className
|
|
266
|
-
)}
|
|
267
|
-
>
|
|
268
|
-
{children}
|
|
269
|
-
</button>
|
|
270
|
-
)
|
|
271
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, type ReactNode } from "react"
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
5
|
+
import { ChevronLeft, ChevronRight, CircleCheck, Info, X } from "lucide-react"
|
|
6
|
+
import type { PublicReview } from "../api/reviews"
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
import { useLocaleArea } from "../locales/context"
|
|
9
|
+
import { DialogOverlay, DialogPortal } from "../primitives/ui/dialog"
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/ui/popover"
|
|
11
|
+
import { formatLabel, formatReviewDate, reviewDisplayName } from "./helpers"
|
|
12
|
+
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
13
|
+
import { lightboxMedia, stepLightbox, type LightboxPosition } from "./lightbox-state"
|
|
14
|
+
import { StarRow } from "./star-badge"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The review lightbox: one review, whole. The photo or video on the left
|
|
18
|
+
* over a blurred copy of itself, the review on the right: the reviewer's
|
|
19
|
+
* name, "Verified" with a button that says what it means, the stars, the
|
|
20
|
+
* date, the text and the store's reply. On a phone the photo sits on top
|
|
21
|
+
* and the review scrolls under it.
|
|
22
|
+
*
|
|
23
|
+
* Every widget that shows reviews opens this one: it takes the widget's
|
|
24
|
+
* own list and a position, so the arrows (and the arrow keys) walk through
|
|
25
|
+
* the open review's photos and then on to the next review. The dots jump
|
|
26
|
+
* between one review's photos. Built on the package's Dialog (focus trap,
|
|
27
|
+
* scroll lock, Escape and a click outside close it).
|
|
28
|
+
*
|
|
29
|
+
* const [open, setOpen] = useState<LightboxPosition | null>(null)
|
|
30
|
+
* <ReviewLightbox reviews={reviews} position={open} onPositionChange={setOpen} />
|
|
31
|
+
*
|
|
32
|
+
* Alenika's production fixes are kept: the media index counts from the
|
|
33
|
+
* same filtered list the cards use, and a video plays inline on iPhone
|
|
34
|
+
* with its controls as the answer to a blocked autoplay.
|
|
35
|
+
*/
|
|
36
|
+
export interface ReviewLightboxProps {
|
|
37
|
+
/** The reviews the opening widget shows, in its order. */
|
|
38
|
+
reviews: PublicReview[]
|
|
39
|
+
/** The open review and which of its photos; null when closed. */
|
|
40
|
+
position: LightboxPosition | null
|
|
41
|
+
/** The new position when the shopper steps, null when they close. */
|
|
42
|
+
onPositionChange: (position: LightboxPosition | null) => void
|
|
43
|
+
labels?: Partial<ReviewsUiLabels>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ReviewLightbox({ reviews, position, onPositionChange, labels }: ReviewLightboxProps) {
|
|
47
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
48
|
+
const review = position ? reviews[position.review] : undefined
|
|
49
|
+
const media = lightboxMedia(review)
|
|
50
|
+
const item = position ? media[position.media] : undefined
|
|
51
|
+
const previous = position ? stepLightbox(reviews, position, -1) : null
|
|
52
|
+
const next = position ? stepLightbox(reviews, position, 1) : null
|
|
53
|
+
|
|
54
|
+
// The arrow keys step as the arrows do; a video keeps its own keys.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!position) return
|
|
57
|
+
const onKey = (event: KeyboardEvent) => {
|
|
58
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
|
|
59
|
+
if ((event.target as HTMLElement | null)?.closest?.("video, input, textarea, select")) return
|
|
60
|
+
const to = stepLightbox(reviews, position, event.key === "ArrowLeft" ? -1 : 1)
|
|
61
|
+
if (to) onPositionChange(to)
|
|
62
|
+
}
|
|
63
|
+
window.addEventListener("keydown", onKey)
|
|
64
|
+
return () => window.removeEventListener("keydown", onKey)
|
|
65
|
+
}, [position, reviews, onPositionChange])
|
|
66
|
+
|
|
67
|
+
const name = review ? reviewDisplayName(review.customer_name) : ""
|
|
68
|
+
const stepping = Boolean(previous || next)
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DialogPrimitive.Root
|
|
72
|
+
open={Boolean(review)}
|
|
73
|
+
onOpenChange={(open) => {
|
|
74
|
+
if (!open) onPositionChange(null)
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<DialogPortal>
|
|
78
|
+
<DialogOverlay />
|
|
79
|
+
<DialogPrimitive.Content
|
|
80
|
+
aria-describedby={undefined}
|
|
81
|
+
className={cn(
|
|
82
|
+
"fixed inset-0 z-50 outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
|
83
|
+
"md:inset-auto md:left-1/2 md:top-1/2 md:w-[calc(100%-10rem)] md:-translate-x-1/2 md:-translate-y-1/2 md:data-[state=open]:zoom-in-95 md:data-[state=closed]:zoom-out-95",
|
|
84
|
+
item ? "md:max-w-[55rem]" : "md:max-w-lg"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{review ? (
|
|
88
|
+
<>
|
|
89
|
+
<DialogPrimitive.Close
|
|
90
|
+
aria-label={l.close}
|
|
91
|
+
className={cn(
|
|
92
|
+
"absolute left-4 top-4 z-10 flex size-10 items-center justify-center rounded-md transition-colors",
|
|
93
|
+
item
|
|
94
|
+
? "bg-black/40 text-white hover:bg-black/55"
|
|
95
|
+
: "bg-muted text-foreground hover:bg-border"
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<X className="size-5" />
|
|
99
|
+
</DialogPrimitive.Close>
|
|
100
|
+
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
"flex h-full flex-col overflow-y-auto bg-background md:overflow-hidden md:rounded-lg md:shadow-popover",
|
|
104
|
+
item ? "md:h-[min(40rem,calc(100dvh-4rem))] md:flex-row" : "md:h-auto md:max-h-[calc(100dvh-4rem)]"
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{item && position ? (
|
|
108
|
+
<div className="relative flex h-[min(60vh,32rem)] shrink-0 items-center justify-center overflow-hidden bg-muted md:h-full md:w-[54%]">
|
|
109
|
+
{item.type === "image" ? (
|
|
110
|
+
<>
|
|
111
|
+
<img
|
|
112
|
+
src={item.url}
|
|
113
|
+
alt=""
|
|
114
|
+
aria-hidden
|
|
115
|
+
className="pointer-events-none absolute inset-0 size-full scale-110 object-cover blur-2xl"
|
|
116
|
+
/>
|
|
117
|
+
<img
|
|
118
|
+
key={item.url}
|
|
119
|
+
src={item.url}
|
|
120
|
+
alt={formatLabel(l.mediaAlt, {
|
|
121
|
+
n: position.media + 1,
|
|
122
|
+
total: media.length,
|
|
123
|
+
name,
|
|
124
|
+
})}
|
|
125
|
+
className="relative size-full object-contain"
|
|
126
|
+
/>
|
|
127
|
+
</>
|
|
128
|
+
) : (
|
|
129
|
+
<video
|
|
130
|
+
key={item.url}
|
|
131
|
+
src={`${item.url}#t=0.001`}
|
|
132
|
+
controls
|
|
133
|
+
autoPlay
|
|
134
|
+
playsInline
|
|
135
|
+
preload="metadata"
|
|
136
|
+
className="relative size-full bg-black object-contain"
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{media.length > 1 ? (
|
|
141
|
+
<div
|
|
142
|
+
className={cn(
|
|
143
|
+
"absolute inset-x-0 flex justify-center gap-1.5",
|
|
144
|
+
item.type === "video" ? "top-4" : "bottom-4"
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{media.map((entry, index) => (
|
|
148
|
+
<button
|
|
149
|
+
key={`${entry.url}-${index}`}
|
|
150
|
+
type="button"
|
|
151
|
+
aria-label={formatLabel(l.showMediaAt, { n: index + 1 })}
|
|
152
|
+
aria-current={index === position.media}
|
|
153
|
+
onClick={() => onPositionChange({ review: position.review, media: index })}
|
|
154
|
+
className={cn(
|
|
155
|
+
"size-2 rounded-full ring-1 ring-black/20 transition-colors",
|
|
156
|
+
index === position.media ? "bg-white" : "bg-white/50 hover:bg-white/80"
|
|
157
|
+
)}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
) : null}
|
|
164
|
+
|
|
165
|
+
<div
|
|
166
|
+
className={cn(
|
|
167
|
+
"flex min-w-0 flex-1 flex-col gap-4 p-6 md:overflow-y-auto md:p-7",
|
|
168
|
+
!item && "pt-16 md:pt-16",
|
|
169
|
+
stepping && "pb-20 md:pb-7"
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<div className="flex flex-col gap-1.5">
|
|
173
|
+
<div className="flex items-center justify-between gap-3">
|
|
174
|
+
<DialogPrimitive.Title className="min-w-0 truncate text-body text-foreground">
|
|
175
|
+
{name}
|
|
176
|
+
</DialogPrimitive.Title>
|
|
177
|
+
<div className="flex shrink-0 items-center gap-2 text-body-small text-foreground">
|
|
178
|
+
<span className="flex items-center gap-1.5">
|
|
179
|
+
<CircleCheck aria-hidden className="size-4.5 fill-foreground text-background" />
|
|
180
|
+
{l.verified}
|
|
181
|
+
</span>
|
|
182
|
+
<span aria-hidden className="h-5 w-px bg-border" />
|
|
183
|
+
<Popover>
|
|
184
|
+
<PopoverTrigger
|
|
185
|
+
aria-label={l.verifiedInfo}
|
|
186
|
+
className="flex size-7 items-center justify-center rounded-full transition-colors hover:bg-muted"
|
|
187
|
+
>
|
|
188
|
+
<Info className="size-4.5" />
|
|
189
|
+
</PopoverTrigger>
|
|
190
|
+
<PopoverContent align="end" className="w-64 text-body-small">
|
|
191
|
+
{l.verifiedTitle}
|
|
192
|
+
</PopoverContent>
|
|
193
|
+
</Popover>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="flex items-center justify-between gap-3">
|
|
197
|
+
<StarRow rating={review.rating} size="lg" />
|
|
198
|
+
<span className="shrink-0 text-caption text-muted-foreground">
|
|
199
|
+
{formatReviewDate(review.created_at, l)}
|
|
200
|
+
</span>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{review.title ? <p className="text-h6 text-foreground">{review.title}</p> : null}
|
|
205
|
+
{review.body ? (
|
|
206
|
+
<p className="whitespace-pre-wrap text-body text-foreground">{review.body}</p>
|
|
207
|
+
) : null}
|
|
208
|
+
|
|
209
|
+
{review.admin_response ? (
|
|
210
|
+
<div className="rounded-md bg-muted p-4">
|
|
211
|
+
<p className="text-caption text-muted-foreground">{l.storeReply}</p>
|
|
212
|
+
<p className="mt-1 whitespace-pre-wrap text-body-small text-foreground">
|
|
213
|
+
{review.admin_response}
|
|
214
|
+
</p>
|
|
215
|
+
</div>
|
|
216
|
+
) : null}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{previous ? (
|
|
221
|
+
<StepButton
|
|
222
|
+
label={l.previous}
|
|
223
|
+
onClick={() => onPositionChange(previous)}
|
|
224
|
+
className="bottom-4 left-4 md:-left-14"
|
|
225
|
+
>
|
|
226
|
+
<ChevronLeft className="size-5" />
|
|
227
|
+
</StepButton>
|
|
228
|
+
) : null}
|
|
229
|
+
{next ? (
|
|
230
|
+
<StepButton
|
|
231
|
+
label={l.next}
|
|
232
|
+
onClick={() => onPositionChange(next)}
|
|
233
|
+
className="bottom-4 right-4 md:-right-14"
|
|
234
|
+
>
|
|
235
|
+
<ChevronRight className="size-5" />
|
|
236
|
+
</StepButton>
|
|
237
|
+
) : null}
|
|
238
|
+
</>
|
|
239
|
+
) : null}
|
|
240
|
+
</DialogPrimitive.Content>
|
|
241
|
+
</DialogPortal>
|
|
242
|
+
</DialogPrimitive.Root>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A round arrow: in the bottom corners on a phone, beside the card on a wider screen. */
|
|
247
|
+
function StepButton({
|
|
248
|
+
label,
|
|
249
|
+
onClick,
|
|
250
|
+
className,
|
|
251
|
+
children,
|
|
252
|
+
}: {
|
|
253
|
+
label: string
|
|
254
|
+
onClick: () => void
|
|
255
|
+
className: string
|
|
256
|
+
children: ReactNode
|
|
257
|
+
}) {
|
|
258
|
+
return (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
aria-label={label}
|
|
262
|
+
onClick={onClick}
|
|
263
|
+
className={cn(
|
|
264
|
+
"absolute z-10 flex size-10 items-center justify-center rounded-full bg-background text-foreground shadow-popover transition-colors hover:bg-muted md:bottom-auto md:top-1/2 md:-translate-y-1/2",
|
|
265
|
+
className
|
|
266
|
+
)}
|
|
267
|
+
>
|
|
268
|
+
{children}
|
|
269
|
+
</button>
|
|
270
|
+
)
|
|
271
|
+
}
|