@cartbase/storefront 0.18.0 → 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.
@@ -134,6 +134,20 @@ export type CartMutationResult =
134
134
  /** The id prefix of a line the platform has not confirmed yet. */
135
135
  const PENDING_PREFIX = "optimistic-"
136
136
 
137
+ /**
138
+ * A cart that can still be shopped. A completed cart is an order now: the
139
+ * platform still answers it, lines and all, but refuses every change to it.
140
+ */
141
+ export function isLiveCart(cart: Cart | null | undefined): cart is Cart {
142
+ return Boolean(cart && !cart.completed_at)
143
+ }
144
+
145
+ /** The platform's answer that the cart a change was aimed at is over (ordered, or gone). */
146
+ export function isEndedCartError(err: unknown): boolean {
147
+ const code = (err as { code?: unknown } | null)?.code
148
+ return code === "cart_completed" || code === "cart_not_found"
149
+ }
150
+
137
151
  /**
138
152
  * A line the platform has not confirmed yet: it was added a moment ago and
139
153
  * carries a placeholder id. Its quantity and removal work already (the
@@ -196,6 +210,12 @@ type CartDrawerContextValue = {
196
210
  removeItem: (lineId: string) => Promise<boolean>
197
211
  /** Re-read the decorated cart (totals/gift-card tender are live per read). */
198
212
  refresh: () => Promise<void>
213
+ /**
214
+ * The cart is over (the order was placed): the drawer empties and the
215
+ * store's `onCartEnd` clears its cookie. The checkout calls it; the next
216
+ * add starts a new cart.
217
+ */
218
+ forget: () => Promise<void>
199
219
  /** True when the provider was given a client, so the methods above reach the platform. */
200
220
  canMutate: boolean
201
221
  labels: CartDrawerLabels
@@ -215,6 +235,7 @@ const CartDrawerContext = createContext<CartDrawerContextValue>({
215
235
  updateQuantity: async () => false,
216
236
  removeItem: async () => false,
217
237
  refresh: async () => {},
238
+ forget: async () => {},
218
239
  canMutate: false,
219
240
  labels: defaultCartDrawerLabels,
220
241
  hrefs: defaultHrefs,
@@ -351,6 +372,7 @@ export function CartDrawerProvider({
351
372
  client,
352
373
  cartId,
353
374
  onCartChange,
375
+ onCartEnd,
354
376
  onOptimisticError,
355
377
  labels: labelOverrides,
356
378
  hrefs: hrefOverrides,
@@ -374,6 +396,13 @@ export function CartDrawerProvider({
374
396
  * here so the cart survives reloads.
375
397
  */
376
398
  onCartChange?: (cart: Cart) => void
399
+ /**
400
+ * Fires when the cart is over: the order was placed, or the platform
401
+ * answered that the stored cart is completed or gone. Clear the stored
402
+ * cart id (the cookie) here; the next add creates a new cart and
403
+ * `onCartChange` stores its id.
404
+ */
405
+ onCartEnd?: () => void
377
406
  /** Ops funnel for every failed optimistic mutation (see module JSDoc). */
378
407
  onOptimisticError?: (failure: OptimisticCartError) => void
379
408
  /**
@@ -392,27 +421,55 @@ export function CartDrawerProvider({
392
421
  // it reads when a change's turn comes (state lags a render behind).
393
422
  const queue = useRef<CartMutationQueue | null>(null)
394
423
  if (!queue.current) queue.current = createCartMutationQueue()
395
- const latest = useRef<Cart | null>(cart ?? null)
424
+ // A completed cart is an order: the drawer never holds one.
425
+ const latest = useRef<Cart | null>(isLiveCart(cart) ? cart : null)
426
+
427
+ // The store's hooks, read through refs so an inline handler never re-runs an effect.
428
+ const cartEnded = useRef(onCartEnd)
429
+ cartEnded.current = onCartEnd
396
430
 
397
431
  // Confirmed snapshot: seeded from the prop, replaced by every SDK
398
432
  // mutation response and by prop updates from server refreshes.
399
- const [serverCart, setServerCart] = useState<Cart | null>(cart ?? null)
433
+ const [serverCart, setServerCart] = useState<Cart | null>(latest.current)
434
+
435
+ /** The cart is over: the drawer empties and the store forgets the id. */
436
+ const end = useCallback(() => {
437
+ latest.current = null
438
+ startTransition(() => setServerCart(null))
439
+ try {
440
+ cartEnded.current?.()
441
+ } catch {
442
+ // A store's handler must never break the drawer.
443
+ }
444
+ }, [])
445
+
400
446
  useEffect(() => {
401
447
  if (cart === undefined) return
402
448
  // A refresh carrying the cart as it was must not overwrite an answer
403
449
  // still on its way; the queue's own answers are newer.
404
450
  if (queue.current?.busy()) return
451
+ if (cart && !isLiveCart(cart)) {
452
+ end()
453
+ return
454
+ }
405
455
  latest.current = cart
406
456
  setServerCart(cart)
407
- }, [cart])
457
+ }, [cart, end])
408
458
 
409
459
  // A confirmed cart becomes the snapshot inside a transition, so it lands
410
460
  // together with the end of the change that asked for it and a pending
411
- // line is never shown twice.
412
- const adopt = useCallback((next: Cart) => {
413
- latest.current = next
414
- startTransition(() => setServerCart(next))
415
- }, [])
461
+ // line is never shown twice. A completed one ends the cart instead.
462
+ const adopt = useCallback(
463
+ (next: Cart) => {
464
+ if (!isLiveCart(next)) {
465
+ end()
466
+ return
467
+ }
468
+ latest.current = next
469
+ startTransition(() => setServerCart(next))
470
+ },
471
+ [end]
472
+ )
416
473
 
417
474
  // Client+cartId mode: read the cart on mount, through the queue, so an
418
475
  // add made before the read answers goes to this cart, not a new one.
@@ -423,15 +480,16 @@ export function CartDrawerProvider({
423
480
  try {
424
481
  const res = await sdkRetrieveCart(client, cartId)
425
482
  if (!cancelled) adopt(res.cart)
426
- } catch {
427
- // 404 cart_not_found (expired/foreign id) stay empty; the next
428
- // addLine creates a fresh cart.
483
+ } catch (err) {
484
+ // A stored id the platform no longer knows: forget it. Any other
485
+ // failure (a network blip) keeps the id for the next visit.
486
+ if (!cancelled && isEndedCartError(err)) end()
429
487
  }
430
488
  })
431
489
  return () => {
432
490
  cancelled = true
433
491
  }
434
- }, [cart, client, cartId, adopt])
492
+ }, [cart, client, cartId, adopt, end])
435
493
 
436
494
  const [optimisticCart, dispatchOptimistic] = useOptimistic(
437
495
  serverCart,
@@ -532,7 +590,8 @@ export function CartDrawerProvider({
532
590
  const confirm = useCallback(
533
591
  (next: Cart) => {
534
592
  adopt(next)
535
- onCartChange?.(next)
593
+ // A completed cart has ended the cart; its id is never stored again.
594
+ if (isLiveCart(next)) onCartChange?.(next)
536
595
  },
537
596
  [adopt, onCartChange]
538
597
  )
@@ -561,13 +620,26 @@ export function CartDrawerProvider({
561
620
  ...display,
562
621
  }
563
622
  const work = queue.current!.run(async (): Promise<CartMutationResult> => {
623
+ // First add with no cart: create one with the item in a single
624
+ // call. region_id falls back to the store default server-side.
625
+ const create = () => sdkCreateCart(client, { items: [{ variant_id: variantId, quantity }] })
564
626
  try {
565
627
  const cartId = latest.current?.id
566
- const res = cartId
567
- ? await sdkAddLineItem(client, cartId, { variant_id: variantId, quantity })
568
- : // First add with no cart: create one with the item in a single
569
- // call. region_id falls back to the store default server-side.
570
- await sdkCreateCart(client, { items: [{ variant_id: variantId, quantity }] })
628
+ let res
629
+ if (!cartId) {
630
+ res = await create()
631
+ } else {
632
+ try {
633
+ res = await sdkAddLineItem(client, cartId, { variant_id: variantId, quantity })
634
+ } catch (error) {
635
+ // The stored cart is over (ordered in another tab, or gone):
636
+ // Medusa's starter treats it as no cart, so a new one starts
637
+ // with this item.
638
+ if (!isEndedCartError(error)) throw error
639
+ end()
640
+ res = await create()
641
+ }
642
+ }
571
643
  confirm(res.cart)
572
644
  return { ok: true, cart: res.cart }
573
645
  } catch (error) {
@@ -577,7 +649,7 @@ export function CartDrawerProvider({
577
649
  })
578
650
  return showWhile(action, work)
579
651
  },
580
- [client, confirm, report, showWhile]
652
+ [client, confirm, report, showWhile, end]
581
653
  )
582
654
 
583
655
  const addItem = useCallback(
@@ -599,7 +671,8 @@ export function CartDrawerProvider({
599
671
  confirm(res.cart)
600
672
  return true
601
673
  } catch (err) {
602
- report({ type: "update_quantity", lineId, quantity: latestQuantity }, err)
674
+ if (isEndedCartError(err)) end()
675
+ else report({ type: "update_quantity", lineId, quantity: latestQuantity }, err)
603
676
  return false
604
677
  }
605
678
  })
@@ -608,7 +681,7 @@ export function CartDrawerProvider({
608
681
  work.then((sent) => sent === true)
609
682
  )
610
683
  },
611
- [client, lineNow, confirm, report, showWhile]
684
+ [client, lineNow, confirm, report, showWhile, end]
612
685
  )
613
686
 
614
687
  const removeItem = useCallback(
@@ -627,13 +700,17 @@ export function CartDrawerProvider({
627
700
  confirm(res.cart)
628
701
  return true
629
702
  } catch (err) {
703
+ if (isEndedCartError(err)) {
704
+ end()
705
+ return true
706
+ }
630
707
  report(action, err)
631
708
  return false
632
709
  }
633
710
  })
634
711
  return showWhile(action, work)
635
712
  },
636
- [client, lineNow, confirm, report, showWhile]
713
+ [client, lineNow, confirm, report, showWhile, end]
637
714
  )
638
715
 
639
716
  const refresh = useCallback(
@@ -645,12 +722,20 @@ export function CartDrawerProvider({
645
722
  try {
646
723
  const res = await sdkRetrieveCart(client, id)
647
724
  confirm(res.cart)
648
- } catch {
649
- // Keep the last snapshot on transient failure.
725
+ } catch (err) {
726
+ // A cart the platform no longer knows ends; a transient failure
727
+ // keeps the last snapshot.
728
+ if (isEndedCartError(err)) end()
650
729
  }
651
730
  })
652
731
  },
653
- [client, confirm]
732
+ [client, confirm, end]
733
+ )
734
+
735
+ /** The order was placed: empty the drawer and let the store forget the id. */
736
+ const forget = useCallback(
737
+ (): Promise<void> => queue.current!.run(async () => end()),
738
+ [end]
654
739
  )
655
740
 
656
741
  // The mounted language is the default (2026-09-14): a store that mounts
@@ -681,6 +766,7 @@ export function CartDrawerProvider({
681
766
  updateQuantity,
682
767
  removeItem,
683
768
  refresh,
769
+ forget,
684
770
  canMutate: Boolean(client),
685
771
  labels,
686
772
  hrefs,
@@ -21,6 +21,8 @@ export {
21
21
  reduceCart,
22
22
  makeOptimisticLine,
23
23
  isPendingLine,
24
+ isLiveCart,
25
+ isEndedCartError,
24
26
  type CartMutationResult,
25
27
  type OptimisticCartAction,
26
28
  type OptimisticAddDisplay,
@@ -41,6 +41,8 @@ import {
41
41
  type UpdateCustomerInput,
42
42
  } from "../api/customers"
43
43
  import { isStripeLike } from "../lib/payment-constants"
44
+ import { clearCartCookie } from "../lib/cookie-names"
45
+ import { useCartDrawer } from "../cart-drawer/context"
44
46
  import compareAddresses from "./compare-addresses"
45
47
  import { translateAddressError } from "./address-error-copy"
46
48
  import { translatePaymentError } from "./payment-error-copy"
@@ -303,6 +305,9 @@ export function useCheckoutOrchestration({
303
305
  const contextOrderConfirmedPath = useOrderConfirmedPath()
304
306
  const orderConfirmedPath =
305
307
  orderConfirmedPathProp ?? contextOrderConfirmedPath
308
+ // The mounted cart drawer, emptied when the order is placed (a no-op
309
+ // when the store mounts none).
310
+ const { forget: forgetCart } = useCartDrawer()
306
311
 
307
312
  // ── Completed-cart detection ────────────────────────────────────────
308
313
  // Surfaced as a flag so the page-level server component can redirect
@@ -1041,6 +1046,12 @@ export function useCheckoutOrchestration({
1041
1046
 
1042
1047
  const { order } = await completeCart(client, cart.id)
1043
1048
 
1049
+ // The cart is an order now (Medusa's placeOrder: removeCartId()): the
1050
+ // stored id goes and the drawer empties, so the next add starts a new
1051
+ // cart instead of being refused by the completed one.
1052
+ clearCartCookie()
1053
+ await forgetCart()
1054
+
1044
1055
  if (onOrderPlaced) {
1045
1056
  onOrderPlaced(order)
1046
1057
  } else if (typeof window !== "undefined") {
@@ -1057,6 +1068,7 @@ export function useCheckoutOrchestration({
1057
1068
  client,
1058
1069
  cart.id,
1059
1070
  cart.metadata,
1071
+ forgetCart,
1060
1072
  onOrderPlaced,
1061
1073
  orderConfirmedPath,
1062
1074
  resolveTrackingMetadata,
@@ -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,53 @@ 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
+
77
+ /**
78
+ * Forget the stored cart in the browser, under both names: Medusa's
79
+ * `removeCartId()`. The package's checkout calls it when the order is
80
+ * placed, and a store hands it to `CartDrawerProvider`'s `onCartEnd` so a
81
+ * cart that turned out to be completed or gone is forgotten too. A no-op
82
+ * on the server.
83
+ */
84
+ export function clearCartCookie(): void {
85
+ if (typeof document === "undefined") return
86
+ for (const name of [CART_COOKIE, LEGACY_CART_COOKIE]) {
87
+ document.cookie = `${name}=;path=/;max-age=0;samesite=lax`
88
+ }
89
+ }
90
+
43
91
  /**
44
92
  * THE VISITOR ID — our own per-browser id and the join key between browsing
45
93
  * and money (ecommerce-analytics §3.1). It is OURS deliberately: the
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
+ }