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