@cartbase/storefront 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,9 +18,13 @@ import { useCartDrawer } from "../cart-drawer/context"
18
18
  * NOTE: depends on the sibling `cart-drawer` family's context (ported in
19
19
  * the same batch); mount inside `<CartDrawerProvider>`.
20
20
  */
21
- export function CartButtonClient({ cart }: { cart: Cart | null }) {
22
- const { open } = useCartDrawer()
23
- const totalItems = productItemCount(cart?.items)
21
+ export function CartButtonClient({ cart }: { cart?: Cart | null }) {
22
+ const drawer = useCartDrawer()
23
+ // Leave `cart` out and the badge counts the drawer's own cart, the one
24
+ // the provider read in the browser, so the header needs no server read
25
+ // and the page around it can be prerendered.
26
+ const totalItems = productItemCount((cart === undefined ? drawer.cart : cart)?.items)
27
+ const { open } = drawer
24
28
 
25
29
  return (
26
30
  <button
@@ -3,9 +3,10 @@
3
3
  * (platform-fingerprints card, fingerprint #4 — cookie names are a
4
4
  * primary Wappalyzer/BuiltWith detection signal).
5
5
  *
6
- * The app still OWNS *setting* the cart cookie carts.md is explicit that
7
- * "the SDK never persists the cart" this module is the single source of
8
- * the NAME so every consumer emits the same wire fingerprint instead of
6
+ * The app decides WHEN the cart cookie is written (it hands
7
+ * `writeCartCookie` to the drawer's `onCartChange`); this module is the
8
+ * single source of the NAME and the shape, so every consumer emits the
9
+ * same wire fingerprint instead of
9
10
  * inventing its own prefix (the reference app previously hardcoded
10
11
  * `_barter_cart_id` locally in `examples/storefront/src/lib/config.ts`).
11
12
  *
@@ -40,6 +41,39 @@ export function readCartCookie(
40
41
  return get(CART_COOKIE) ?? get(LEGACY_CART_COOKIE)
41
42
  }
42
43
 
44
+ /** How long a stored cart id lives: thirty days. */
45
+ export const CART_COOKIE_MAX_AGE = 60 * 60 * 24 * 30
46
+
47
+ /**
48
+ * The stored cart id, read in the browser; null on the server or when none
49
+ * is stored. A storefront hands it to `CartDrawerProvider` as `cartId`, so
50
+ * no page reads the cart cookie on the server and every page can be
51
+ * prerendered (Cache Components).
52
+ */
53
+ export function readBrowserCartId(): string | null {
54
+ if (typeof document === "undefined") return null
55
+ const cookies = new Map(
56
+ document.cookie
57
+ .split("; ")
58
+ .filter(Boolean)
59
+ .map((row) => {
60
+ const at = row.indexOf("=")
61
+ return [row.slice(0, at), decodeURIComponent(row.slice(at + 1))] as const
62
+ })
63
+ )
64
+ return readCartCookie((name) => cookies.get(name) || undefined) ?? null
65
+ }
66
+
67
+ /**
68
+ * Store the cart id in the browser, under the current name: what a
69
+ * storefront hands to `CartDrawerProvider`'s `onCartChange`. A no-op on the
70
+ * server.
71
+ */
72
+ export function writeCartCookie(cartId: string): void {
73
+ if (typeof document === "undefined") return
74
+ document.cookie = `${CART_COOKIE}=${encodeURIComponent(cartId)};path=/;max-age=${CART_COOKIE_MAX_AGE};samesite=lax`
75
+ }
76
+
43
77
  /**
44
78
  * Forget the stored cart in the browser, under both names: Medusa's
45
79
  * `removeCartId()`. The package's checkout calls it when the order is
package/src/locales/bg.ts CHANGED
@@ -389,6 +389,13 @@ export const bg: StorefrontLocale = {
389
389
  showPhoto: "Покажи снимка",
390
390
  playVideo: "Пусни видео",
391
391
  close: "Затвори",
392
+ openReview: "Отвори отзива",
393
+ verifiedInfo: "Какво значи потвърден",
394
+ storeReply: "Отговор от магазина",
395
+ previous: "Предишен",
396
+ next: "Следващ",
397
+ mediaAlt: "Снимка {n} от {total}, {name}",
398
+ showMediaAt: "Покажи снимка {n}",
392
399
  expiredTitle: "Този линк за отзив е изтекъл",
393
400
  expiredBody:
394
401
  "Линкът беше валиден 60 дни от датата на покупката. Ако искаш да оставиш отзив, пиши ни на {email}.",
package/src/locales/es.ts CHANGED
@@ -399,6 +399,13 @@ export const es: StorefrontLocale = {
399
399
  showPhoto: "Ver foto",
400
400
  playVideo: "Reproducir vídeo",
401
401
  close: "Cerrar",
402
+ openReview: "Abrir la valoración",
403
+ verifiedInfo: "Qué significa compra verificada",
404
+ storeReply: "Respuesta de la tienda",
405
+ previous: "Anterior",
406
+ next: "Siguiente",
407
+ mediaAlt: "Foto {n} de {total} de {name}",
408
+ showMediaAt: "Ver foto {n}",
402
409
  expiredTitle: "Este enlace de valoración ha caducado",
403
410
  expiredBody:
404
411
  "El enlace era válido durante 60 días desde tu compra. Si quieres dejar una valoración, escríbenos a {email}.",
@@ -23,8 +23,12 @@ type ProductActionsWrapperProps = {
23
23
  id: string
24
24
  /** Pricing context so `calculated_price` is present for the price panel. */
25
25
  pricingContext?: PricingContextQuery
26
- /** The variant the address names; see the product page contract. */
27
- initialVariantId?: string | null
26
+ /**
27
+ * The variant the address names; see the product page contract. A promise
28
+ * is read here, behind the template's boundary, so a page on Cache
29
+ * Components never reads its query outside one.
30
+ */
31
+ initialVariantId?: string | null | Promise<string | null | undefined>
28
32
  /** A server action; leave it out for the instant add through the mounted cart drawer. */
29
33
  addToCart?: (input: AddToCartInput) => Promise<void>
30
34
  onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
@@ -40,6 +44,9 @@ export async function ProductActionsWrapper({
40
44
  onAddToCart,
41
45
  openCart,
42
46
  }: ProductActionsWrapperProps) {
47
+ // The address first: it is request data, so the live read never runs
48
+ // while a build prerenders the page.
49
+ const variantId = await initialVariantId
43
50
  let product: StoreProduct
44
51
  try {
45
52
  const res = await retrieveProduct(client, id, pricingContext)
@@ -52,7 +59,7 @@ export async function ProductActionsWrapper({
52
59
  return (
53
60
  <ProductActions
54
61
  product={product}
55
- initialVariantId={initialVariantId}
62
+ initialVariantId={variantId}
56
63
  addToCart={addToCart}
57
64
  onAddToCart={onAddToCart}
58
65
  openCart={openCart}
@@ -26,6 +26,8 @@ import { RelatedProducts } from "./related-products"
26
26
  import { ProductInfo } from "./product-info"
27
27
  import { ProductActionsWrapper } from "./product-actions-wrapper"
28
28
 
29
+ type VariantParam = string | null | Promise<string | null | undefined>
30
+
29
31
  type ProductTemplateProps = {
30
32
  client: StorefrontClient
31
33
  product: StoreProduct
@@ -59,11 +61,13 @@ type ProductTemplateProps = {
59
61
  /** Anything else the store wants in the accordion, appended in order. */
60
62
  sections?: ProductSection[]
61
63
  /**
62
- * The variant the address names: pass `searchParams.variant` from the
63
- * page, so the server renders that variant's price, code and stock and
64
- * nothing flashes (the product page contract, `use-product-actions.ts`).
64
+ * The variant the address names, so the server renders that variant's
65
+ * price, code and stock and nothing flashes (the product page contract,
66
+ * `use-product-actions.ts`). On Cache Components pass it as a promise
67
+ * (`searchParams.then((q) => q.variant)`): the live buy box reads it
68
+ * behind its boundary and the rest of the page stays prerendered.
65
69
  */
66
- initialVariantId?: string | null
70
+ initialVariantId?: VariantParam
67
71
  /** The store's own card for the related strip; the library's preview without it. */
68
72
  renderProduct?: ComponentType<{ product: StoreProduct }>
69
73
  }
@@ -112,7 +116,7 @@ export function ProductTemplate({
112
116
  <ProductActions
113
117
  disabled={true}
114
118
  product={product}
115
- initialVariantId={initialVariantId}
119
+ initialVariantId={typeof initialVariantId === "string" ? initialVariantId : null}
116
120
  addToCart={addToCart}
117
121
  onAddToCart={onAddToCart}
118
122
  openCart={openCart}
@@ -31,7 +31,7 @@
31
31
  * to out of stock until the choice changes.
32
32
  */
33
33
  import { useCallback, useEffect, useMemo, useState } from "react"
34
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
34
+ import { usePathname, useRouter } from "next/navigation"
35
35
 
36
36
  import type { StoreProduct, StoreProductVariant } from "../api/products"
37
37
  import { useCartDrawer } from "../cart-drawer/context"
@@ -102,7 +102,6 @@ export function useProductActions({
102
102
  const router = useRouter()
103
103
  const drawer = useCartDrawer()
104
104
  const pathname = usePathname()
105
- const searchParams = useSearchParams()
106
105
  const variants = product.variants ?? []
107
106
 
108
107
  const [chosen, setChosen] = useState<OptionChoices>(() => {
@@ -127,12 +126,16 @@ export function useProductActions({
127
126
  setQuantityState(Math.max(1, Math.floor(next) || 1))
128
127
  }, [])
129
128
 
130
- // Rule 3: the address follows the choice, in the browser alone.
129
+ // Rule 3: the address follows the choice, in the browser alone. The query
130
+ // is read here, when the address is written, and never while rendering: a
131
+ // render that reads the query is request data, and would stop a store on
132
+ // Cache Components from prerendering its product pages.
131
133
  useEffect(() => {
132
134
  if (!syncAddress || variants.length < 2 || !variant) return
133
- if (searchParams.get(VARIANT_PARAM) === variantParamValue(variant.id)) return
134
- window.history.replaceState(null, "", variantHref(pathname, searchParams, variant.id))
135
- }, [syncAddress, variants.length, variant, searchParams, pathname])
135
+ const search = new URLSearchParams(window.location.search)
136
+ if (search.get(VARIANT_PARAM) === variantParamValue(variant.id)) return
137
+ window.history.replaceState(null, "", variantHref(pathname, search, variant.id))
138
+ }, [syncAddress, variants.length, variant, pathname])
136
139
 
137
140
  const inStock = useMemo(() => {
138
141
  if (!variant) return false
@@ -50,11 +50,15 @@ export {
50
50
  type RatingDistributionProps,
51
51
  } from "./star-badge"
52
52
 
53
+ export { ReviewList, type ReviewListProps } from "./review-list"
54
+
55
+ export { ReviewLightbox, type ReviewLightboxProps } from "./review-lightbox"
56
+
53
57
  export {
54
- ReviewList,
55
- ReviewLightbox,
56
- type ReviewListProps,
57
- } from "./review-list"
58
+ lightboxMedia,
59
+ stepLightbox,
60
+ type LightboxPosition,
61
+ } from "./lightbox-state"
58
62
 
59
63
  export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
60
64
 
@@ -31,6 +31,18 @@ export type ReviewsUiLabels = {
31
31
  showPhoto: string
32
32
  playVideo: string
33
33
  close: string
34
+ // — lightbox —
35
+ /** A review card's own button: the whole card opens the lightbox. */
36
+ openReview: string
37
+ /** The info button beside "Verified"; its popover says `verifiedTitle`. */
38
+ verifiedInfo: string
39
+ storeReply: string
40
+ previous: string
41
+ next: string
42
+ /** `{n}` = position, `{total}` = the review's photo count, `{name}` = reviewer. */
43
+ mediaAlt: string
44
+ /** A dot under the photo; `{n}` = position. */
45
+ showMediaAt: string
34
46
  // — wizard: entry states (ported from the alenika /review/[token] page) —
35
47
  expiredTitle: string
36
48
  /** `{email}` = store support email. */
@@ -133,6 +145,13 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
133
145
  showPhoto: "View photo",
134
146
  playVideo: "Play video",
135
147
  close: "Close",
148
+ openReview: "Open the review",
149
+ verifiedInfo: "What verified means",
150
+ storeReply: "Reply from the store",
151
+ previous: "Previous",
152
+ next: "Next",
153
+ mediaAlt: "Photo {n} of {total} from {name}",
154
+ showMediaAt: "Show photo {n}",
136
155
  expiredTitle: "This review link has expired",
137
156
  expiredBody:
138
157
  "The link was valid for 60 days after your purchase. If you'd like to leave a review, write to us at {email}.",
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Where the review lightbox stands, and how it steps. Pure, no React, so
3
+ * every widget that opens the lightbox (the review list, a carousel, a
4
+ * store's own row of reviews) walks the same sequence. Unit-tested in
5
+ * tests/unit/storefront-review-lightbox.test.ts.
6
+ */
7
+ import type { PublicReview, ReviewMedia } from "../api/reviews"
8
+
9
+ /** The open review (its index in the widget's list) and which of its photos or videos. */
10
+ export interface LightboxPosition {
11
+ review: number
12
+ media: number
13
+ }
14
+
15
+ /**
16
+ * A review's photos and videos as the lightbox shows them: the SAME
17
+ * filtered list the cards count their indexes from, so a falsy entry can
18
+ * never shift which item opens (the Alenika production fix).
19
+ */
20
+ export function lightboxMedia(review: Pick<PublicReview, "media"> | undefined): ReviewMedia[] {
21
+ return (review?.media ?? []).filter(Boolean)
22
+ }
23
+
24
+ /**
25
+ * One step through the reviews: through the open review's photos first,
26
+ * then on to the next review's first one, or back to the previous review's
27
+ * last. Null at either end, where the arrow is not drawn.
28
+ */
29
+ export function stepLightbox(
30
+ reviews: ReadonlyArray<Pick<PublicReview, "media">>,
31
+ at: LightboxPosition,
32
+ direction: 1 | -1
33
+ ): LightboxPosition | null {
34
+ const count = lightboxMedia(reviews[at.review]).length
35
+ if (direction === 1) {
36
+ if (at.media + 1 < count) return { review: at.review, media: at.media + 1 }
37
+ if (at.review + 1 < reviews.length) return { review: at.review + 1, media: 0 }
38
+ return null
39
+ }
40
+ if (at.media > 0) return { review: at.review, media: at.media - 1 }
41
+ if (at.review > 0) {
42
+ const previous = lightboxMedia(reviews[at.review - 1]).length
43
+ return { review: at.review - 1, media: Math.max(previous - 1, 0) }
44
+ }
45
+ return null
46
+ }
@@ -0,0 +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,251 +1,191 @@
1
- "use client"
2
-
3
- import { useEffect, useState } from "react"
4
- import { ShieldCheck, X } from "lucide-react"
5
- import type { PublicReview } from "../api/reviews"
6
- import { useLocaleArea } from "../locales/context"
7
- import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
8
- import { formatReviewDate, reviewDisplayName } from "./helpers"
9
- import { StarRow } from "./star-badge"
10
-
11
- /**
12
- * Review cards (masonry) + lightbox. Ported from the Alenika PDP reviews
13
- * section (src/components/product/reviews-list.tsx — the card grid +
14
- * `Lightbox`), presentational half: the fetch/sort/paging orchestration
15
- * lives in `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden
16
- * media is filtered out server-side — the client just renders what it
17
- * gets).
18
- *
19
- * Verified-purchaser badge is unconditional — every Cartbase review comes
20
- * from a tokenized post-purchase email, so the badge is a tautology of
21
- * the system, not an opt-in flag.
22
- *
23
- * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
24
- * (short) pack together without empty gaps; `break-inside-avoid` keeps
25
- * each card whole. Images-first ordering comes from the API's default
26
- * sort, so photo cards lead the flow. `layout="list"` (the store's
27
- * widget_layout setting) renders a single column instead.
28
- */
29
- export interface ReviewListProps {
30
- reviews: PublicReview[]
31
- /** Store display option (`getWidget().options.layout`). Default masonry. */
32
- layout?: "masonry" | "list"
33
- labels?: Partial<ReviewsUiLabels>
34
- className?: string
35
- }
36
-
37
- export function ReviewList({
38
- reviews,
39
- layout = "masonry",
40
- labels,
41
- className,
42
- }: ReviewListProps) {
43
- const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
44
- // Lightbox: when set, the indicated review's media at the given index
45
- // is shown full-bleed.
46
- const [lightbox, setLightbox] = useState<{
47
- reviewId: string
48
- mediaIdx: number
49
- } | null>(null)
50
-
51
- return (
52
- <>
53
- <ul
54
- className={
55
- className ??
56
- (layout === "list"
57
- ? "columns-1 [column-gap:1rem]"
58
- : "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
59
- }
60
- >
61
- {reviews.map((r) => {
62
- const media = (r.media ?? []).filter(Boolean)
63
- const hero = media[0]
64
- const rest = media.slice(1)
65
- return (
66
- <li
67
- key={r.id}
68
- className="break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
69
- >
70
- {/* Hero media on top */}
71
- {hero && (
72
- <button
73
- type="button"
74
- onClick={() => setLightbox({ reviewId: r.id, mediaIdx: 0 })}
75
- aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
76
- className="block w-full relative group"
77
- >
78
- {hero.type === "image" ? (
79
- <img
80
- src={hero.thumb || hero.url}
81
- alt=""
82
- loading="lazy"
83
- className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
84
- />
85
- ) : (
86
- <>
87
- <video
88
- src={hero.url}
89
- muted
90
- preload="metadata"
91
- className="block w-full h-auto object-cover bg-black"
92
- />
93
- <span className="absolute inset-0 flex items-center justify-center">
94
- <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
95
- <span
96
- aria-hidden
97
- className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
98
- />
99
- </span>
100
- </span>
101
- </>
102
- )}
103
- </button>
104
- )}
105
-
106
- <div className="p-4 sm:p-5">
107
- <StarRow rating={r.rating} />
108
-
109
- <div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
110
- <span className="text-sm font-medium text-foreground">
111
- {reviewDisplayName(r.customer_name)}
112
- </span>
113
- <span
114
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
115
- title={l.verifiedTitle}
116
- >
117
- <ShieldCheck className="w-3.5 h-3.5" />
118
- {l.verified}
119
- </span>
120
- <span className="text-xs text-muted-foreground ml-auto">
121
- {formatReviewDate(r.created_at, l)}
122
- </span>
123
- </div>
124
-
125
- {r.title && (
126
- <h3 className="text-base font-semibold text-foreground leading-snug mt-3">
127
- {r.title}
128
- </h3>
129
- )}
130
- {r.body && (
131
- <p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
132
- {r.body}
133
- </p>
134
- )}
135
-
136
- {/* Extra media beyond the hero */}
137
- {rest.length > 0 && (
138
- <div className="grid grid-cols-4 gap-2 mt-3">
139
- {rest.map((m, i) => (
140
- <button
141
- key={i}
142
- type="button"
143
- onClick={() =>
144
- setLightbox({ reviewId: r.id, mediaIdx: i + 1 })
145
- }
146
- aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
147
- className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
148
- >
149
- {m.type === "image" ? (
150
- <img
151
- src={m.thumb || m.url}
152
- alt=""
153
- loading="lazy"
154
- className="absolute inset-0 w-full h-full object-cover"
155
- />
156
- ) : (
157
- <video
158
- src={m.url}
159
- muted
160
- preload="metadata"
161
- className="absolute inset-0 w-full h-full object-cover bg-black"
162
- />
163
- )}
164
- </button>
165
- ))}
166
- </div>
167
- )}
168
- </div>
169
- </li>
170
- )
171
- })}
172
- </ul>
173
-
174
- {lightbox && (
175
- <ReviewLightbox
176
- review={reviews.find((r) => r.id === lightbox.reviewId)}
177
- mediaIdx={lightbox.mediaIdx}
178
- closeLabel={l.close}
179
- onClose={() => setLightbox(null)}
180
- />
181
- )}
182
- </>
183
- )
184
- }
185
-
186
- /** Simple full-bleed overlay for the picked media. Closes on Escape/backdrop. */
187
- export function ReviewLightbox({
188
- review,
189
- mediaIdx,
190
- closeLabel,
191
- onClose,
192
- }: {
193
- review: PublicReview | undefined
194
- mediaIdx: number
195
- closeLabel?: string
196
- onClose: () => void
197
- }) {
198
- const fallback = useLocaleArea("reviews", defaultReviewsUiLabels)
199
- // Close on Escape
200
- useEffect(() => {
201
- const onKey = (e: KeyboardEvent) => {
202
- if (e.key === "Escape") onClose()
203
- }
204
- window.addEventListener("keydown", onKey)
205
- return () => window.removeEventListener("keydown", onKey)
206
- }, [onClose])
207
-
208
- if (!review) return null
209
- // Index into the SAME filtered array the grid used to compute mediaIdx,
210
- // so a falsy/hidden entry can't shift which item opens (production fix).
211
- const items = (review.media ?? []).filter(Boolean)
212
- const item = items[mediaIdx]
213
- if (!item) return null
214
-
215
- return (
216
- <div
217
- role="dialog"
218
- aria-modal="true"
219
- className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
220
- onClick={onClose}
221
- >
222
- <button
223
- type="button"
224
- aria-label={closeLabel ?? fallback.close}
225
- onClick={onClose}
226
- className="absolute top-4 right-4 w-10 h-10 rounded-full bg-background/10 hover:bg-background/20 text-background flex items-center justify-center"
227
- >
228
- <X className="w-5 h-5" />
229
- </button>
230
- <div
231
- className="max-w-5xl max-h-[90vh] w-full flex items-center justify-center"
232
- onClick={(e) => e.stopPropagation()}
233
- >
234
- {item.type === "image" ? (
235
- <img
236
- src={item.url}
237
- alt=""
238
- className="max-w-full max-h-[90vh] object-contain"
239
- />
240
- ) : (
241
- <video
242
- src={item.url}
243
- controls
244
- autoPlay
245
- className="max-w-full max-h-[90vh]"
246
- />
247
- )}
248
- </div>
249
- </div>
250
- )
251
- }
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { ShieldCheck } 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 click anywhere on a card opens that review in `ReviewLightbox`; a click
21
+ * on one of its photos opens it on that photo.
22
+ *
23
+ * Verified-purchaser badge is unconditional every Cartbase review comes
24
+ * from a tokenized post-purchase email, so the badge is a tautology of
25
+ * the system, not an opt-in flag.
26
+ *
27
+ * Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
28
+ * (short) pack together without empty gaps; `break-inside-avoid` keeps
29
+ * each card whole. Images-first ordering comes from the API's default
30
+ * sort, so photo cards lead the flow. `layout="list"` (the store's
31
+ * widget_layout setting) renders a single column instead.
32
+ */
33
+ export interface ReviewListProps {
34
+ reviews: PublicReview[]
35
+ /** Store display option (`getWidget().options.layout`). Default masonry. */
36
+ layout?: "masonry" | "list"
37
+ labels?: Partial<ReviewsUiLabels>
38
+ className?: string
39
+ }
40
+
41
+ export function ReviewList({
42
+ reviews,
43
+ layout = "masonry",
44
+ labels,
45
+ className,
46
+ }: ReviewListProps) {
47
+ const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
48
+ const [open, setOpen] = useState<LightboxPosition | null>(null)
49
+
50
+ return (
51
+ <>
52
+ <ul
53
+ className={
54
+ className ??
55
+ (layout === "list"
56
+ ? "columns-1 [column-gap:1rem]"
57
+ : "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
58
+ }
59
+ >
60
+ {reviews.map((r, index) => {
61
+ const media = lightboxMedia(r)
62
+ const hero = media[0]
63
+ const rest = media.slice(1)
64
+ return (
65
+ <li
66
+ key={r.id}
67
+ className="relative break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
68
+ >
69
+ {/* The whole card is the button; the photos sit above it. */}
70
+ <button
71
+ type="button"
72
+ onClick={() => setOpen({ review: index, media: 0 })}
73
+ aria-label={`${l.openReview}: ${reviewDisplayName(r.customer_name)}`}
74
+ className="absolute inset-0 z-0 cursor-pointer"
75
+ />
76
+
77
+ {/* Hero media on top */}
78
+ {hero && (
79
+ <button
80
+ type="button"
81
+ onClick={() => setOpen({ review: index, media: 0 })}
82
+ aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
83
+ className="relative z-10 block w-full group"
84
+ >
85
+ {hero.type === "image" ? (
86
+ <img
87
+ src={hero.thumb || hero.url}
88
+ alt=""
89
+ loading="lazy"
90
+ className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
91
+ />
92
+ ) : (
93
+ <>
94
+ {/* `#t=0.001`: iOS Safari paints no first frame from
95
+ `preload="metadata"` alone; the seek makes it. */}
96
+ <video
97
+ src={`${hero.url}#t=0.001`}
98
+ muted
99
+ playsInline
100
+ preload="metadata"
101
+ className="block w-full h-auto object-cover bg-black"
102
+ />
103
+ <span className="absolute inset-0 flex items-center justify-center">
104
+ <span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
105
+ <span
106
+ aria-hidden
107
+ className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
108
+ />
109
+ </span>
110
+ </span>
111
+ </>
112
+ )}
113
+ </button>
114
+ )}
115
+
116
+ <div className="p-4 sm:p-5">
117
+ <StarRow rating={r.rating} />
118
+
119
+ <div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
120
+ <span className="text-sm font-medium text-foreground">
121
+ {reviewDisplayName(r.customer_name)}
122
+ </span>
123
+ <span
124
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
125
+ title={l.verifiedTitle}
126
+ >
127
+ <ShieldCheck className="w-3.5 h-3.5" />
128
+ {l.verified}
129
+ </span>
130
+ <span className="text-xs text-muted-foreground ml-auto">
131
+ {formatReviewDate(r.created_at, l)}
132
+ </span>
133
+ </div>
134
+
135
+ {r.title && (
136
+ <h3 className="text-base font-semibold text-foreground leading-snug mt-3">
137
+ {r.title}
138
+ </h3>
139
+ )}
140
+ {r.body && (
141
+ <p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
142
+ {r.body}
143
+ </p>
144
+ )}
145
+
146
+ {/* Extra media beyond the hero */}
147
+ {rest.length > 0 && (
148
+ <div className="relative z-10 grid grid-cols-4 gap-2 mt-3">
149
+ {rest.map((m, i) => (
150
+ <button
151
+ key={i}
152
+ type="button"
153
+ onClick={() => setOpen({ review: index, media: i + 1 })}
154
+ aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
155
+ className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
156
+ >
157
+ {m.type === "image" ? (
158
+ <img
159
+ src={m.thumb || m.url}
160
+ alt=""
161
+ loading="lazy"
162
+ className="absolute inset-0 w-full h-full object-cover"
163
+ />
164
+ ) : (
165
+ <video
166
+ src={`${m.url}#t=0.001`}
167
+ muted
168
+ playsInline
169
+ preload="metadata"
170
+ className="absolute inset-0 w-full h-full object-cover bg-black"
171
+ />
172
+ )}
173
+ </button>
174
+ ))}
175
+ </div>
176
+ )}
177
+ </div>
178
+ </li>
179
+ )
180
+ })}
181
+ </ul>
182
+
183
+ <ReviewLightbox
184
+ reviews={reviews}
185
+ position={open}
186
+ onPositionChange={setOpen}
187
+ labels={labels}
188
+ />
189
+ </>
190
+ )
191
+ }
@@ -11,23 +11,33 @@ import { reviewCountLabel } from "./helpers"
11
11
  * arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
12
12
  */
13
13
 
14
- /** Five-star row, filled to `Math.round(rating)`. */
14
+ /**
15
+ * Five-star row, filled to `Math.round(rating)` in the theme's `rating`
16
+ * colour. `lg` is the lightbox's row.
17
+ */
15
18
  export function StarRow({
16
19
  rating,
20
+ size = "sm",
17
21
  className,
18
22
  }: {
19
23
  rating: number
24
+ size?: "sm" | "lg"
20
25
  className?: string
21
26
  }) {
22
27
  return (
23
- <span className={cn("inline-flex items-center gap-0.5", className)}>
28
+ <span
29
+ role="img"
30
+ aria-label={`${Math.round(rating)} / 5`}
31
+ className={cn("inline-flex items-center", size === "lg" ? "gap-px" : "gap-0.5", className)}
32
+ >
24
33
  {Array.from({ length: 5 }).map((_, i) => (
25
34
  <Star
26
35
  key={i}
36
+ aria-hidden
27
37
  className={cn(
28
- "w-3.5 h-3.5",
38
+ size === "lg" ? "size-5" : "size-3.5",
29
39
  i < Math.round(rating)
30
- ? "fill-warning text-warning"
40
+ ? "fill-rating text-rating"
31
41
  : "fill-muted text-muted-foreground"
32
42
  )}
33
43
  strokeWidth={0}
@@ -53,7 +63,7 @@ export function StarBadge({ aggregate, labels, className }: StarBadgeProps) {
53
63
  if (!aggregate.count) return null
54
64
  return (
55
65
  <div className={cn("flex items-center gap-3 shrink-0", className)}>
56
- <Star className="w-10 h-10 fill-warning text-warning" strokeWidth={0} />
66
+ <Star className="w-10 h-10 fill-rating text-rating" strokeWidth={0} />
57
67
  <div>
58
68
  <div className="text-3xl font-bold text-foreground leading-none">
59
69
  {aggregate.avg_rating.toFixed(1)}
@@ -6,7 +6,7 @@
6
6
  * the server templates read it back and convert to `offset`. Windowed
7
7
  * layout: ≤7 pages inline, otherwise ellipsis around the current window.
8
8
  */
9
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
9
+ import { usePathname, useRouter } from "next/navigation"
10
10
  import { cn } from "../lib/utils"
11
11
 
12
12
  export function Pagination({
@@ -20,13 +20,14 @@ export function Pagination({
20
20
  }) {
21
21
  const router = useRouter()
22
22
  const pathname = usePathname()
23
- const searchParams = useSearchParams()
24
23
 
25
24
  const arrayRange = (start: number, stop: number) =>
26
25
  Array.from({ length: stop - start + 1 }, (_, index) => start + index)
27
26
 
28
27
  const handlePageChange = (newPage: number) => {
29
- const params = new URLSearchParams(searchParams)
28
+ // The query as it is at the click: read while rendering, it would make
29
+ // every listing page request data and stop it from being prerendered.
30
+ const params = new URLSearchParams(window.location.search)
30
31
  params.set("page", newPage.toString())
31
32
  router.push(`${pathname}?${params.toString()}`)
32
33
  }
@@ -12,7 +12,7 @@
12
12
  * from `sortOptionLabelKeys` (labels.ts, pure) so map completeness against
13
13
  * `SortOptions` is unit-tested.
14
14
  */
15
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
15
+ import { usePathname, useRouter } from "next/navigation"
16
16
  import { useCallback } from "react"
17
17
  import { cn } from "../lib/utils"
18
18
  import type { SortOptions } from "../lib/sort-products"
@@ -35,20 +35,18 @@ export function SortSelect({
35
35
  const l = { ...defaultStoreLabels, ...labels }
36
36
  const router = useRouter()
37
37
  const pathname = usePathname()
38
- const searchParams = useSearchParams()
39
38
 
40
39
  const sortOptions = (
41
40
  Object.keys(sortOptionLabelKeys) as SortOptions[]
42
41
  ).map((value) => ({ value, label: l[sortOptionLabelKeys[value]] }))
43
42
 
44
- const createQueryString = useCallback(
45
- (name: string, value: string) => {
46
- const params = new URLSearchParams(searchParams)
47
- params.set(name, value)
48
- return params.toString()
49
- },
50
- [searchParams]
51
- )
43
+ // The query as it is at the click: read while rendering, it would make
44
+ // every listing page request data and stop it from being prerendered.
45
+ const createQueryString = useCallback((name: string, value: string) => {
46
+ const params = new URLSearchParams(window.location.search)
47
+ params.set(name, value)
48
+ return params.toString()
49
+ }, [])
52
50
 
53
51
  const handleChange = (value: SortOptions) => {
54
52
  const query = createQueryString("sortBy", value)
package/theme/theme.css CHANGED
@@ -40,6 +40,7 @@
40
40
  --color-success-foreground: var(--success-foreground);
41
41
  --color-warning: var(--warning);
42
42
  --color-warning-foreground: var(--warning-foreground);
43
+ --color-rating: var(--rating);
43
44
 
44
45
  --color-border: var(--border);
45
46
  --color-input: var(--input);
package/theme/tokens.css CHANGED
@@ -44,6 +44,9 @@
44
44
  --warning: oklch(0.828 0.189 84.429);
45
45
  --warning-foreground: oklch(0.279 0.077 45.635);
46
46
 
47
+ /* ── Ratings: the filled review star ──────────────────────────────────── */
48
+ --rating: oklch(0.828 0.189 84.429);
49
+
47
50
  /* ── Lines and focus ──────────────────────────────────────────────────── */
48
51
  --border: oklch(0.922 0 0);
49
52
  --input: oklch(0.922 0 0);
@@ -94,6 +97,8 @@
94
97
  --warning: oklch(0.828 0.189 84.429);
95
98
  --warning-foreground: oklch(0.279 0.077 45.635);
96
99
 
100
+ --rating: oklch(0.828 0.189 84.429);
101
+
97
102
  --border: oklch(1 0 0 / 12%);
98
103
  --input: oklch(1 0 0 / 18%);
99
104
  --ring: oklch(0.556 0 0);