@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.
Files changed (105) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/reviews.ts +22 -1
  20. package/src/api/search.ts +163 -163
  21. package/src/api/store.ts +35 -35
  22. package/src/api/types.ts +91 -91
  23. package/src/cart-drawer/context.tsx +778 -778
  24. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  25. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  26. package/src/cart-drawer/gift-wrap.tsx +82 -82
  27. package/src/cart-drawer/item/index.tsx +162 -162
  28. package/src/cart-drawer/item/upsell.tsx +110 -110
  29. package/src/cart-drawer/labels.ts +123 -123
  30. package/src/cart-drawer/mutation-queue.ts +78 -78
  31. package/src/cart-drawer/notes.tsx +131 -131
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/index.ts +20 -20
  49. package/src/common/market-select.tsx +57 -57
  50. package/src/index.ts +12 -12
  51. package/src/lib/country-name.ts +59 -59
  52. package/src/lib/get-product-price.ts +133 -133
  53. package/src/lib/media-image.tsx +39 -39
  54. package/src/lib/payment-constants.ts +53 -53
  55. package/src/lib/platform.ts +13 -13
  56. package/src/lib/price.tsx +39 -39
  57. package/src/lib/store-api-error.ts +36 -36
  58. package/src/lib/variant-caption.ts +32 -32
  59. package/src/locales/bg.ts +3 -2
  60. package/src/locales/context.ts +37 -37
  61. package/src/locales/en.ts +26 -26
  62. package/src/locales/es.ts +3 -2
  63. package/src/locales/index.ts +19 -19
  64. package/src/locales/provider.tsx +59 -59
  65. package/src/locales/types.ts +77 -77
  66. package/src/order/index.ts +62 -62
  67. package/src/order/labels.ts +79 -79
  68. package/src/order/order-totals.tsx +250 -250
  69. package/src/primitives/select-field.tsx +93 -93
  70. package/src/products/image-gallery.tsx +43 -43
  71. package/src/products/option-select.tsx +67 -67
  72. package/src/products/product-info.tsx +5 -5
  73. package/src/products/product-price.tsx +69 -69
  74. package/src/products/product-promises.tsx +61 -61
  75. package/src/products/product-specs.tsx +65 -65
  76. package/src/products/product-tabs.tsx +123 -123
  77. package/src/products/purchase-options.tsx +130 -130
  78. package/src/products/sets.ts +110 -110
  79. package/src/products/variant-matching.ts +71 -71
  80. package/src/products/variant-url.ts +74 -74
  81. package/src/reviews-ui/index.ts +12 -0
  82. package/src/reviews-ui/labels.ts +5 -2
  83. package/src/reviews-ui/lightbox-state.ts +46 -46
  84. package/src/reviews-ui/review-header.tsx +166 -0
  85. package/src/reviews-ui/review-lightbox.tsx +271 -271
  86. package/src/reviews-ui/review-list.tsx +193 -191
  87. package/src/reviews-ui/review-widget.tsx +63 -71
  88. package/src/reviews-ui/star-badge.tsx +6 -4
  89. package/src/reviews-ui/widget-options.ts +55 -0
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. 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
+ }