@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.
- package/package.json +258 -258
- package/src/cart-drawer/context.tsx +111 -25
- package/src/cart-drawer/index.ts +2 -0
- package/src/checkout/use-checkout-orchestration.ts +12 -0
- package/src/common/cart-button-client.tsx +7 -3
- package/src/lib/cookie-names.ts +51 -3
- package/src/locales/bg.ts +7 -0
- package/src/locales/es.ts +7 -0
- package/src/products/product-actions-wrapper.tsx +10 -3
- package/src/products/product-template.tsx +9 -5
- package/src/products/use-product-actions.ts +9 -6
- package/src/reviews-ui/index.ts +8 -4
- package/src/reviews-ui/labels.ts +19 -0
- package/src/reviews-ui/lightbox-state.ts +46 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -0
- package/src/reviews-ui/review-list.tsx +191 -251
- package/src/reviews-ui/star-badge.tsx +15 -5
- package/src/store/pagination.tsx +4 -3
- package/src/store/sort-select.tsx +8 -10
- package/theme/theme.css +1 -0
- package/theme/tokens.css +5 -0
|
@@ -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
|
-
|
|
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>(
|
|
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(
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
//
|
|
428
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
package/src/cart-drawer/index.ts
CHANGED
|
@@ -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
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
package/src/lib/cookie-names.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
* the NAME so every consumer emits the
|
|
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
|
-
/**
|
|
27
|
-
|
|
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={
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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?:
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
package/src/reviews-ui/index.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
type
|
|
57
|
-
} from "./
|
|
58
|
+
lightboxMedia,
|
|
59
|
+
stepLightbox,
|
|
60
|
+
type LightboxPosition,
|
|
61
|
+
} from "./lightbox-state"
|
|
58
62
|
|
|
59
63
|
export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
|
|
60
64
|
|
package/src/reviews-ui/labels.ts
CHANGED
|
@@ -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
|
+
}
|