@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|