@cartbase/storefront 0.1.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.
Files changed (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,569 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ startTransition,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useOptimistic,
10
+ useRef,
11
+ useState,
12
+ type ReactNode,
13
+ } from "react"
14
+
15
+ import {
16
+ addLineItem as sdkAddLineItem,
17
+ createCart as sdkCreateCart,
18
+ deleteLineItem as sdkDeleteLineItem,
19
+ retrieveCart as sdkRetrieveCart,
20
+ updateLineItem as sdkUpdateLineItem,
21
+ type Cart,
22
+ type CartLineItem,
23
+ } from "../api/carts"
24
+ import type { StorefrontClient } from "../api/http"
25
+ import { productItemCount } from "../lib/cart-helpers"
26
+ import { defaultCartDrawerLabels, type CartDrawerLabels } from "./labels"
27
+
28
+ /**
29
+ * Cart drawer context — open state + cart snapshot + labels + link hrefs.
30
+ *
31
+ * Stores wrap their root layout with `<CartDrawerProvider cart={cart}>`
32
+ * (the cart is fetched server-side and passed down as a prop). The
33
+ * provider manages open/close state, auto-opens when item count rises,
34
+ * locks body scroll while open, and closes on escape.
35
+ *
36
+ * Optimistic UI: the cart exposed via `useCartDrawer().cart` is wrapped
37
+ * in React 19's `useOptimistic`. Callers (PDP add buttons, drawer
38
+ * quantity steppers, drawer remove buttons) dispatch optimistic actions
39
+ * BEFORE awaiting the corresponding server mutation. The drawer reflects
40
+ * the change at React update speed (~16ms) instead of waiting for the
41
+ * full backend round-trip (~800-1500ms). On mutation failure the
42
+ * optimistic state reverts automatically because the confirmed cart
43
+ * snapshot never updated past the failed mutation. See
44
+ * https://react.dev/reference/react/useOptimistic.
45
+ *
46
+ * Ported from `@1click/ui/src/cart-drawer/context.tsx` (v2.3.1). Barter
47
+ * data seam: the Medusa server actions (`../data/cart`) are replaced by
48
+ * `@cartbase/storefront/api/carts` calls made through a `StorefrontClient`
49
+ * passed to the provider. Every mutation returns the FULL decorated cart
50
+ * (totals + gift_cards[]/gift_card_total/gift_card_remainder — SERVER
51
+ * truth), which the provider adopts as the new confirmed snapshot, so no
52
+ * RSC re-fetch is required for the drawer to settle. Amounts are EUR
53
+ * decimal major units (the optimistic `unit_price × quantity` arithmetic
54
+ * is unit-agnostic and unchanged).
55
+ *
56
+ * The source's `logEvent` ops funnel (backend error log endpoint) has no
57
+ * barter SDK equivalent yet — the same single-funnel discipline is kept
58
+ * via the `onOptimisticError` prop: every failed optimistic mutation
59
+ * (every PDP add, cross-sell add, drawer remove, drawer quantity change)
60
+ * flows through it with per-action context. Wire it to the store's ops
61
+ * logging; the console.error stays regardless.
62
+ */
63
+
64
+ type CartDrawerHrefs = {
65
+ /** Href for the checkout button in the sticky footer */
66
+ checkout: string
67
+ /** Href for the "browse products" CTA on the empty state */
68
+ browse: string
69
+ /** Prefix for product links — full href is `${productPrefix}/${handle}` */
70
+ productPrefix: string
71
+ }
72
+
73
+ const defaultHrefs: CartDrawerHrefs = {
74
+ checkout: "/checkout",
75
+ browse: "/store",
76
+ productPrefix: "/products",
77
+ }
78
+
79
+ /** Context payload passed to `onOptimisticError` for every failed mutation. */
80
+ export type OptimisticCartError = {
81
+ actionType: OptimisticCartAction["type"]
82
+ error: unknown
83
+ /** Structured per-action fields (quantity, line_id, variant/product ids). */
84
+ context: Record<string, unknown>
85
+ }
86
+
87
+ /**
88
+ * Display fields an "add" action carries so the drawer can render the row
89
+ * immediately, before the server confirms.
90
+ */
91
+ export type OptimisticAddDisplay = {
92
+ product_id?: string
93
+ product_title?: string
94
+ product_handle?: string
95
+ thumbnail?: string | null
96
+ variant_title?: string | null
97
+ /** Per-unit price in EUR major units. total = price × qty. */
98
+ unit_price?: number
99
+ }
100
+
101
+ /**
102
+ * Optimistic action shapes — what callers can dispatch to update the
103
+ * cart UI ahead of the server confirmation.
104
+ */
105
+ export type OptimisticCartAction =
106
+ | ({
107
+ type: "add"
108
+ /** Variant being added. We use this to merge with existing lines. */
109
+ variant_id: string
110
+ quantity: number
111
+ } & OptimisticAddDisplay)
112
+ | {
113
+ type: "remove"
114
+ lineId: string
115
+ }
116
+ | {
117
+ type: "update_quantity"
118
+ lineId: string
119
+ quantity: number
120
+ }
121
+
122
+ type CartDrawerContextValue = {
123
+ isOpen: boolean
124
+ open: () => void
125
+ close: () => void
126
+ toggle: () => void
127
+ /** Cart snapshot AFTER any pending optimistic updates. */
128
+ cart: Cart | null
129
+ /**
130
+ * Dispatch an optimistic update. MUST be called inside a
131
+ * `startTransition` (React requirement for useOptimistic setters
132
+ * outside Action props). The CartDrawerProvider exposes a helper
133
+ * `applyOptimistic` that wraps the dispatch in startTransition for
134
+ * convenience; prefer that.
135
+ */
136
+ dispatchOptimistic: (action: OptimisticCartAction) => void
137
+ /**
138
+ * Convenience wrapper: dispatches the optimistic action AND awaits
139
+ * the provided server mutation inside a single transition. Use from
140
+ * PDP add buttons or anywhere the store owns the server call itself
141
+ * (e.g. a Next.js server action). The built-in helpers below cover
142
+ * the SDK-wired path.
143
+ */
144
+ applyOptimistic: (
145
+ action: OptimisticCartAction,
146
+ serverAction: () => Promise<unknown>
147
+ ) => void
148
+ /**
149
+ * SDK-wired mutations (require the provider's `client` prop). Each
150
+ * dispatches the optimistic action, calls the matching
151
+ * `@cartbase/storefront/api/carts` function, and adopts the returned
152
+ * decorated cart. Resolves `true` on server confirmation, `false` on
153
+ * failure (after funneling through `onOptimisticError`) or when no
154
+ * client is configured.
155
+ */
156
+ addItem: (
157
+ variantId: string,
158
+ quantity?: number,
159
+ display?: OptimisticAddDisplay
160
+ ) => Promise<boolean>
161
+ /** POST line-items/:id with `{quantity}` — barter contract: 0 deletes. */
162
+ updateQuantity: (lineId: string, quantity: number) => Promise<boolean>
163
+ removeItem: (lineId: string) => Promise<boolean>
164
+ /** Re-read the decorated cart (totals/gift-card tender are live per read). */
165
+ refresh: () => Promise<void>
166
+ labels: CartDrawerLabels
167
+ hrefs: CartDrawerHrefs
168
+ }
169
+
170
+ const CartDrawerContext = createContext<CartDrawerContextValue>({
171
+ isOpen: false,
172
+ open: () => {},
173
+ close: () => {},
174
+ toggle: () => {},
175
+ cart: null,
176
+ dispatchOptimistic: () => {},
177
+ applyOptimistic: () => {},
178
+ addItem: async () => false,
179
+ updateQuantity: async () => false,
180
+ removeItem: async () => false,
181
+ refresh: async () => {},
182
+ labels: defaultCartDrawerLabels,
183
+ hrefs: defaultHrefs,
184
+ })
185
+
186
+ export function useCartDrawer(): CartDrawerContextValue {
187
+ return useContext(CartDrawerContext)
188
+ }
189
+
190
+ /**
191
+ * Reducer for the useOptimistic cart. Pure function. Returns the next
192
+ * cart shape after applying the action. Falls back to a synthetic empty
193
+ * cart when there's no current cart yet so optimistic adds work even on
194
+ * first-add (the server backfills the real cart_id + line_id below).
195
+ *
196
+ * Exported for unit tests (pure logic, no React).
197
+ */
198
+ export function reduceCart(
199
+ current: Cart | null,
200
+ action: OptimisticCartAction
201
+ ): Cart | null {
202
+ if (!current) {
203
+ // No cart yet on the server — synthesize a minimal one so the drawer
204
+ // can render the item immediately. The real createCart call will
205
+ // create the cart and the confirmed snapshot replaces this synthetic
206
+ // one.
207
+ if (action.type !== "add") return current
208
+ return {
209
+ id: "optimistic-cart",
210
+ items: [makeOptimisticLine(action)],
211
+ } as unknown as Cart
212
+ }
213
+
214
+ switch (action.type) {
215
+ case "add": {
216
+ const items = current.items ?? []
217
+ const existing = items.find((i) => i.variant_id === action.variant_id)
218
+ if (existing) {
219
+ return {
220
+ ...current,
221
+ items: items.map((i) =>
222
+ i.variant_id === action.variant_id
223
+ ? {
224
+ ...i,
225
+ quantity: i.quantity + action.quantity,
226
+ total:
227
+ (i.unit_price ?? 0) * (i.quantity + action.quantity),
228
+ }
229
+ : i
230
+ ),
231
+ }
232
+ }
233
+ return {
234
+ ...current,
235
+ items: [...items, makeOptimisticLine(action)],
236
+ }
237
+ }
238
+ case "remove": {
239
+ return {
240
+ ...current,
241
+ items: (current.items ?? []).filter((i) => i.id !== action.lineId),
242
+ }
243
+ }
244
+ case "update_quantity": {
245
+ return {
246
+ ...current,
247
+ items: (current.items ?? []).map((i) =>
248
+ i.id === action.lineId
249
+ ? {
250
+ ...i,
251
+ quantity: action.quantity,
252
+ total: (i.unit_price ?? 0) * action.quantity,
253
+ }
254
+ : i
255
+ ),
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Build a synthetic line item from an "add" action. The real server
263
+ * response will replace this with the actual line on confirmation.
264
+ * Cast through `unknown` because `CartLineItem` has many fields we
265
+ * don't have at click time (created_at, tax_lines, adjustments…) — the
266
+ * renderer is lenient about missing optional fields.
267
+ */
268
+ export function makeOptimisticLine(
269
+ action: Extract<OptimisticCartAction, { type: "add" }>
270
+ ): CartLineItem {
271
+ const unitPrice = action.unit_price ?? 0
272
+ return {
273
+ id: `optimistic-${action.variant_id}`,
274
+ variant_id: action.variant_id,
275
+ product_id: action.product_id,
276
+ product_title: action.product_title,
277
+ product_handle: action.product_handle,
278
+ thumbnail: action.thumbnail,
279
+ variant_title: action.variant_title,
280
+ quantity: action.quantity,
281
+ unit_price: unitPrice,
282
+ total: unitPrice * action.quantity,
283
+ original_total: unitPrice * action.quantity,
284
+ } as unknown as CartLineItem
285
+ }
286
+
287
+ export function CartDrawerProvider({
288
+ cart,
289
+ client,
290
+ cartId,
291
+ onCartChange,
292
+ onOptimisticError,
293
+ labels: labelOverrides,
294
+ hrefs: hrefOverrides,
295
+ children,
296
+ }: {
297
+ /**
298
+ * Server-fetched cart snapshot (RSC/server-action pattern). When the
299
+ * prop changes (e.g. after a router refresh) it becomes the confirmed
300
+ * snapshot. Optional when `client` + `cartId` are given — the provider
301
+ * then retrieves the cart itself on mount.
302
+ */
303
+ cart?: Cart | null
304
+ /** StorefrontClient for the SDK-wired mutations (addItem/updateQuantity/removeItem/refresh). */
305
+ client?: StorefrontClient
306
+ /** Known cart id to retrieve when no `cart` snapshot is passed. */
307
+ cartId?: string | null
308
+ /**
309
+ * Fires with the decorated cart after every server-confirmed change,
310
+ * including first-add cart creation — persist `cart.id` (cookie/storage)
311
+ * here so the cart survives reloads.
312
+ */
313
+ onCartChange?: (cart: Cart) => void
314
+ /** Ops funnel for every failed optimistic mutation (see module JSDoc). */
315
+ onOptimisticError?: (failure: OptimisticCartError) => void
316
+ labels?: Partial<CartDrawerLabels>
317
+ hrefs?: Partial<CartDrawerHrefs>
318
+ children: ReactNode
319
+ }) {
320
+ const [isOpen, setIsOpen] = useState(false)
321
+ const prevItemCount = useRef(0)
322
+
323
+ // Confirmed snapshot: seeded from the prop, replaced by every SDK
324
+ // mutation response (each returns the full decorated cart) and by
325
+ // prop updates from server refreshes.
326
+ const [serverCart, setServerCart] = useState<Cart | null>(cart ?? null)
327
+ useEffect(() => {
328
+ if (cart !== undefined) setServerCart(cart)
329
+ }, [cart])
330
+
331
+ // Client+cartId mode: retrieve the decorated cart on mount when no
332
+ // snapshot was provided.
333
+ useEffect(() => {
334
+ if (cart !== undefined || !client || !cartId) return
335
+ let cancelled = false
336
+ sdkRetrieveCart(client, cartId)
337
+ .then((res) => {
338
+ if (!cancelled) setServerCart(res.cart)
339
+ })
340
+ .catch(() => {
341
+ // 404 cart_not_found (expired/foreign id) → stay empty; the next
342
+ // addItem creates a fresh cart.
343
+ })
344
+ return () => {
345
+ cancelled = true
346
+ }
347
+ }, [cart, client, cartId])
348
+
349
+ const [optimisticCart, dispatchOptimistic] = useOptimistic(
350
+ serverCart,
351
+ reduceCart
352
+ )
353
+
354
+ // totalItems counts PRODUCT lines only. Backend-injected fee lines
355
+ // (e.g. the COD fee added by the payment-session flow) are excluded
356
+ // — otherwise the auto-open useEffect below treats a fee insertion
357
+ // as "a new item was added," opens the drawer mid-checkout, and
358
+ // locks body scroll. See @cartbase/storefront/lib/cart-helpers for the
359
+ // shared predicate.
360
+ const totalItems = productItemCount(optimisticCart?.items)
361
+
362
+ // Auto-open when items are added (count increases AFTER initial load)
363
+ useEffect(() => {
364
+ if (totalItems > prevItemCount.current && prevItemCount.current > 0) {
365
+ setIsOpen(true)
366
+ }
367
+ prevItemCount.current = totalItems
368
+ }, [totalItems])
369
+
370
+ // Lock body scroll while open
371
+ useEffect(() => {
372
+ if (isOpen) {
373
+ document.body.style.overflow = "hidden"
374
+ } else {
375
+ document.body.style.overflow = ""
376
+ }
377
+ return () => {
378
+ document.body.style.overflow = ""
379
+ }
380
+ }, [isOpen])
381
+
382
+ // Escape closes
383
+ useEffect(() => {
384
+ const handleEscape = (e: KeyboardEvent) => {
385
+ if (e.key === "Escape" && isOpen) setIsOpen(false)
386
+ }
387
+ document.addEventListener("keydown", handleEscape)
388
+ return () => document.removeEventListener("keydown", handleEscape)
389
+ }, [isOpen])
390
+
391
+ const open = useCallback(() => setIsOpen(true), [])
392
+ const close = useCallback(() => setIsOpen(false), [])
393
+ const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
394
+
395
+ /**
396
+ * Shared optimistic runner. useOptimistic setters MUST be called
397
+ * inside a transition or an Action prop, otherwise React errors
398
+ * during render. Wrapping both the dispatch AND the awaited server
399
+ * work in a single startTransition ensures the optimistic state stays
400
+ * "pending" for the entire round-trip — and reverts cleanly on throw.
401
+ *
402
+ * On failure the optimistic state auto-reverts because the confirmed
403
+ * snapshot hasn't updated. We do NOT rethrow — useOptimistic
404
+ * semantics require the transition to settle cleanly. Every failure
405
+ * is logged (console + `onOptimisticError`): this is the single
406
+ * funnel for every PDP add, cross-sell add, drawer remove, drawer
407
+ * quantity change. Without it those failures are invisible to ops.
408
+ */
409
+ const runOptimistic = useCallback(
410
+ (
411
+ action: OptimisticCartAction,
412
+ work: () => Promise<unknown>
413
+ ): Promise<boolean> =>
414
+ new Promise<boolean>((resolve) => {
415
+ startTransition(async () => {
416
+ dispatchOptimistic(action)
417
+ try {
418
+ await work()
419
+ resolve(true)
420
+ } catch (err) {
421
+ const err_ = err as { message?: string; name?: string }
422
+ // eslint-disable-next-line no-console
423
+ console.error("[cart-optimistic]", action.type, err_)
424
+ const ctx: Record<string, unknown> = { action_type: action.type }
425
+ if (action.type === "add") {
426
+ ctx.quantity = action.quantity
427
+ ctx.product_id = action.product_id
428
+ ctx.variant_id = action.variant_id
429
+ } else if (action.type === "remove") {
430
+ ctx.line_id = action.lineId
431
+ } else if (action.type === "update_quantity") {
432
+ ctx.line_id = action.lineId
433
+ ctx.quantity = action.quantity
434
+ }
435
+ ctx.err_name = err_?.name
436
+ ctx.err_message = err_?.message ?? String(err)
437
+ try {
438
+ onOptimisticError?.({ actionType: action.type, error: err, context: ctx })
439
+ } catch {
440
+ // The ops funnel itself must never break the UI.
441
+ }
442
+ resolve(false)
443
+ }
444
+ })
445
+ }),
446
+ [dispatchOptimistic, onOptimisticError]
447
+ )
448
+
449
+ const applyOptimistic = useCallback(
450
+ (action: OptimisticCartAction, serverAction: () => Promise<unknown>) => {
451
+ void runOptimistic(action, serverAction)
452
+ },
453
+ [runOptimistic]
454
+ )
455
+
456
+ /** Adopt a server-confirmed decorated cart. */
457
+ const confirm = useCallback(
458
+ (next: Cart) => {
459
+ setServerCart(next)
460
+ onCartChange?.(next)
461
+ },
462
+ [onCartChange]
463
+ )
464
+
465
+ const addItem = useCallback(
466
+ (
467
+ variantId: string,
468
+ quantity = 1,
469
+ display: OptimisticAddDisplay = {}
470
+ ): Promise<boolean> => {
471
+ if (!client) return Promise.resolve(false)
472
+ const action: OptimisticCartAction = {
473
+ type: "add",
474
+ variant_id: variantId,
475
+ quantity,
476
+ ...display,
477
+ }
478
+ return runOptimistic(action, async () => {
479
+ const existingId = serverCart?.id
480
+ if (existingId) {
481
+ const res = await sdkAddLineItem(client, existingId, {
482
+ variant_id: variantId,
483
+ quantity,
484
+ })
485
+ confirm(res.cart)
486
+ } else {
487
+ // First add with no cart: create one with the item in a single
488
+ // call. region_id falls back to the store default server-side.
489
+ const res = await sdkCreateCart(client, {
490
+ items: [{ variant_id: variantId, quantity }],
491
+ })
492
+ confirm(res.cart)
493
+ }
494
+ })
495
+ },
496
+ [client, serverCart?.id, runOptimistic, confirm]
497
+ )
498
+
499
+ const updateQuantity = useCallback(
500
+ (lineId: string, quantity: number): Promise<boolean> => {
501
+ const id = serverCart?.id
502
+ if (!client || !id) return Promise.resolve(false)
503
+ return runOptimistic(
504
+ { type: "update_quantity", lineId, quantity },
505
+ async () => {
506
+ // Barter contract: body is `{quantity}` only (metadata is NOT
507
+ // accepted on update, only on add); quantity 0 deletes the line.
508
+ const res = await sdkUpdateLineItem(client, id, lineId, { quantity })
509
+ confirm(res.cart)
510
+ }
511
+ )
512
+ },
513
+ [client, serverCart?.id, runOptimistic, confirm]
514
+ )
515
+
516
+ const removeItem = useCallback(
517
+ (lineId: string): Promise<boolean> => {
518
+ const id = serverCart?.id
519
+ if (!client || !id) return Promise.resolve(false)
520
+ return runOptimistic({ type: "remove", lineId }, async () => {
521
+ const res = await sdkDeleteLineItem(client, id, lineId)
522
+ confirm(res.cart)
523
+ })
524
+ },
525
+ [client, serverCart?.id, runOptimistic, confirm]
526
+ )
527
+
528
+ const refresh = useCallback(async (): Promise<void> => {
529
+ const id = serverCart?.id
530
+ if (!client || !id) return
531
+ try {
532
+ const res = await sdkRetrieveCart(client, id)
533
+ confirm(res.cart)
534
+ } catch {
535
+ // Keep the last snapshot on transient failure.
536
+ }
537
+ }, [client, serverCart?.id, confirm])
538
+
539
+ const labels: CartDrawerLabels = {
540
+ ...defaultCartDrawerLabels,
541
+ ...labelOverrides,
542
+ }
543
+ const hrefs: CartDrawerHrefs = {
544
+ ...defaultHrefs,
545
+ ...hrefOverrides,
546
+ }
547
+
548
+ return (
549
+ <CartDrawerContext.Provider
550
+ value={{
551
+ isOpen,
552
+ open,
553
+ close,
554
+ toggle,
555
+ cart: optimisticCart,
556
+ dispatchOptimistic,
557
+ applyOptimistic,
558
+ addItem,
559
+ updateQuantity,
560
+ removeItem,
561
+ refresh,
562
+ labels,
563
+ hrefs,
564
+ }}
565
+ >
566
+ {children}
567
+ </CartDrawerContext.Provider>
568
+ )
569
+ }
@@ -0,0 +1,28 @@
1
+ "use client"
2
+
3
+ import { useCartDrawer } from "./context"
4
+
5
+ /**
6
+ * CartContinueShopping — tiny "continue shopping" text link under the
7
+ * sticky footer.
8
+ *
9
+ * Ported from `@1click/ui/src/cart-drawer/continue-shopping.tsx` (v2.3.1);
10
+ * originally extracted from mindpages-storefront
11
+ * src/modules/cart-drawer/cart-continue-shopping.tsx.
12
+ */
13
+
14
+ export function CartContinueShopping() {
15
+ const { close, labels } = useCartDrawer()
16
+
17
+ return (
18
+ <div className="flex justify-center px-5 pb-3">
19
+ <button
20
+ type="button"
21
+ onClick={close}
22
+ className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 decoration-border hover:decoration-text-muted transition-colors"
23
+ >
24
+ {labels.continueShopping}
25
+ </button>
26
+ </div>
27
+ )
28
+ }