@cartbase/storefront 0.21.0 → 0.22.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 (40) hide show
  1. package/package.json +14 -1
  2. package/src/api/checkout.ts +15 -0
  3. package/src/api/http.ts +9 -0
  4. package/src/api/integrations.ts +121 -0
  5. package/src/checkout/card-offer.ts +68 -0
  6. package/src/checkout/carrier-marks.ts +53 -0
  7. package/src/checkout/checkout-client.tsx +71 -13
  8. package/src/checkout/checkout-error-screen.tsx +113 -0
  9. package/src/checkout/discount-section.tsx +88 -56
  10. package/src/checkout/fulfillment-option.ts +30 -0
  11. package/src/checkout/index.ts +41 -6
  12. package/src/checkout/labels.ts +98 -0
  13. package/src/checkout/line-item-card.tsx +35 -83
  14. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  15. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  16. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  17. package/src/checkout/order-summary.tsx +85 -179
  18. package/src/checkout/payment-button.tsx +25 -8
  19. package/src/checkout/payment-method-list.tsx +51 -13
  20. package/src/checkout/payment-wrapper.tsx +57 -13
  21. package/src/checkout/pickup-option.ts +35 -0
  22. package/src/checkout/pickup-point-selector.tsx +372 -0
  23. package/src/checkout/pigeon-office-selector.tsx +379 -0
  24. package/src/checkout/shipping-method-list.tsx +102 -10
  25. package/src/checkout/summary-math.ts +152 -0
  26. package/src/checkout/use-checkout-funnel.ts +303 -0
  27. package/src/checkout/use-checkout-orchestration.ts +384 -30
  28. package/src/lib/stripe-env.ts +25 -0
  29. package/src/locales/bg.ts +37 -1
  30. package/src/locales/es.ts +35 -1
  31. package/src/tracking/attribution.ts +83 -0
  32. package/src/tracking/consent.ts +53 -0
  33. package/src/tracking/events.ts +113 -0
  34. package/src/tracking/fbq.ts +48 -0
  35. package/src/tracking/gtag.ts +32 -0
  36. package/src/tracking/index.ts +32 -1
  37. package/src/tracking/once.ts +137 -0
  38. package/src/tracking/rybbit-events.ts +42 -0
  39. package/src/tracking/ttq.ts +24 -0
  40. package/src/tracking/types.ts +34 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -102,6 +102,7 @@
102
102
  "./tracking/use-engagement-time": "./src/tracking/use-engagement-time.ts",
103
103
  "./tracking/page-views": "./src/tracking/page-views.tsx",
104
104
  "./tracking/live-heartbeat": "./src/tracking/live-heartbeat.tsx",
105
+ "./tracking/once": "./src/tracking/once.ts",
105
106
  "./checkout": "./src/checkout/index.ts",
106
107
  "./checkout/context": "./src/checkout/context.tsx",
107
108
  "./checkout/labels": "./src/checkout/labels.ts",
@@ -111,17 +112,29 @@
111
112
  "./checkout/payment-button": "./src/checkout/payment-button.tsx",
112
113
  "./checkout/econt-office-selector": "./src/checkout/econt-office-selector.tsx",
113
114
  "./checkout/boxnow-locker-selector": "./src/checkout/boxnow-locker-selector.tsx",
115
+ "./checkout/pigeon-office-selector": "./src/checkout/pigeon-office-selector.tsx",
116
+ "./checkout/pickup-point-selector": "./src/checkout/pickup-point-selector.tsx",
117
+ "./checkout/pickup-option": "./src/checkout/pickup-option.ts",
118
+ "./checkout/carrier-marks": "./src/checkout/carrier-marks.ts",
119
+ "./checkout/fulfillment-option": "./src/checkout/fulfillment-option.ts",
114
120
  "./checkout/address-select": "./src/checkout/address-select.tsx",
115
121
  "./checkout/company-details": "./src/checkout/company-details.tsx",
116
122
  "./checkout/discount-section": "./src/checkout/discount-section.tsx",
117
123
  "./checkout/gift-card-section": "./src/checkout/gift-card-section.tsx",
118
124
  "./checkout/line-item-card": "./src/checkout/line-item-card.tsx",
119
125
  "./checkout/order-summary": "./src/checkout/order-summary.tsx",
126
+ "./checkout/mobile-checkout-top-bar": "./src/checkout/mobile-checkout-top-bar.tsx",
127
+ "./checkout/mobile-checkout-bottom-bar": "./src/checkout/mobile-checkout-bottom-bar.tsx",
128
+ "./checkout/mobile-order-summary-body": "./src/checkout/mobile-order-summary-body.tsx",
129
+ "./checkout/summary-math": "./src/checkout/summary-math.ts",
130
+ "./checkout/card-offer": "./src/checkout/card-offer.ts",
120
131
  "./checkout/address-form": "./src/checkout/address-form.tsx",
121
132
  "./checkout/shipping-method-list": "./src/checkout/shipping-method-list.tsx",
122
133
  "./checkout/payment-method-list": "./src/checkout/payment-method-list.tsx",
123
134
  "./checkout/checkout-client": "./src/checkout/checkout-client.tsx",
135
+ "./checkout/checkout-error-screen": "./src/checkout/checkout-error-screen.tsx",
124
136
  "./checkout/use-checkout-orchestration": "./src/checkout/use-checkout-orchestration.ts",
137
+ "./checkout/use-checkout-funnel": "./src/checkout/use-checkout-funnel.ts",
125
138
  "./checkout/payment-error-copy": "./src/checkout/payment-error-copy.ts",
126
139
  "./checkout/address-error-copy": "./src/checkout/address-error-copy.ts",
127
140
  "./checkout/promotion-error-copy": "./src/checkout/promotion-error-copy.ts",
@@ -142,6 +142,21 @@ export interface StorePaymentProcessorEntry {
142
142
  id: string
143
143
  is_enabled: boolean
144
144
  created_at?: string
145
+ /**
146
+ * How the BROWSER reaches this processor (2026-09-18). Stripe entries
147
+ * carry the publishable key to load Stripe.js with; under Connect it is
148
+ * CARTBASE's key, not the merchant's, and `account_id` names the
149
+ * merchant's linked account. `CheckoutClient` reads both off this entry,
150
+ * so a merchant who connected Stripe in the admin has already told their
151
+ * storefront everything: nothing is pasted into a storefront env.
152
+ *
153
+ * Public by construction — a publishable key is made to ship to browsers,
154
+ * and the account id travels on every Elements request. Secret keys and
155
+ * webhook secrets are never on this wire.
156
+ */
157
+ publishable_key?: string
158
+ /** The merchant's linked Stripe account (`acct_…`), Connect stores only. */
159
+ account_id?: string
145
160
  }
146
161
 
147
162
  /** A merchant PAYMENT METHOD entry (pp_* kill, 20260811250000): methods
package/src/api/http.ts CHANGED
@@ -98,6 +98,15 @@ export class StorefrontClient {
98
98
  }
99
99
  }
100
100
 
101
+ /**
102
+ * The platform this store talks to, without a trailing slash. Read by the
103
+ * parts that point at something the platform serves rather than answers,
104
+ * such as the carrier marks on the checkout's shipping rows.
105
+ */
106
+ get origin(): string {
107
+ return this.config.baseUrl.replace(/\/+$/, "")
108
+ }
109
+
101
110
  /** The low-level typed request. Domain modules call this — apps rarely should. */
102
111
  async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
103
112
  const url = new URL(
@@ -116,3 +116,124 @@ export async function listBoxNowLockers(
116
116
  ): Promise<BoxNowLockersResponse> {
117
117
  return client.get("/api/store/integrations/boxnow/lockers")
118
118
  }
119
+
120
+ /** One Pigeon Express pickup point, the allowlisted fields the door serves. */
121
+ export interface PigeonOffice {
122
+ /**
123
+ * Pigeon's own catalogue id, which the waybill needs verbatim. It arrives
124
+ * as a NUMBER on the wire (the carrier's catalogue is numeric), so a
125
+ * caller that stores it stringifies at the edge and never assumes.
126
+ */
127
+ id: string | number
128
+ code?: string | null
129
+ name: string
130
+ /** "office" or "locker": one catalogue, told apart by this. */
131
+ type: string
132
+ city?: string | null
133
+ address?: string | null
134
+ postal_code?: string | null
135
+ phone?: string | null
136
+ latitude?: number | null
137
+ longitude?: number | null
138
+ max_weight?: number | null
139
+ max_dimensions?: string | null
140
+ }
141
+
142
+ export interface PigeonOfficesResponse {
143
+ offices: PigeonOffice[]
144
+ /** Present only when the carrier is not connected or refused. */
145
+ message?: string
146
+ }
147
+
148
+ /**
149
+ * GET /api/store/integrations/pigeon/offices — the Pigeon Express pickup
150
+ * points for the checkout picker. Pigeon's catalogue is credentialed, so
151
+ * unlike Econt the browser can never ask the carrier itself: the platform
152
+ * holds the carrier's answer per store and query for ten minutes and serves
153
+ * allowlisted fields only.
154
+ *
155
+ * Auth: anon (the store's key).
156
+ * Errors: 503 when Pigeon is not connected for the store, 502 when Pigeon
157
+ * itself fails; both carry an empty `offices` list.
158
+ */
159
+ export async function listPigeonOffices(
160
+ client: StorefrontClient,
161
+ opts: { q?: string; type?: "office" | "locker" } = {}
162
+ ): Promise<PigeonOfficesResponse> {
163
+ const query = new URLSearchParams()
164
+ if (opts.q) query.set("q", opts.q)
165
+ if (opts.type) query.set("type", opts.type)
166
+ const suffix = query.size > 0 ? `?${query.toString()}` : ""
167
+ return client.get(`/api/store/integrations/pigeon/offices${suffix}`)
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Pickup points — ONE door, every carrier (2026-09-18)
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /** Where a parcel can be collected, in the one shape every carrier answers in. */
175
+ export interface StorePickupPoint {
176
+ /** `econt`, `speedy`, `boxnow`, `pigeon`. */
177
+ provider: string
178
+ mode: "office" | "locker"
179
+ /** THEIR id, exactly as the order will carry it. */
180
+ id: string
181
+ name: string
182
+ city: string
183
+ address: string
184
+ postal_code: string
185
+ phone: string
186
+ /** The carrier's own coordinates, for "nearest to me". Null when absent. */
187
+ latitude: number | null
188
+ longitude: number | null
189
+ }
190
+
191
+ /**
192
+ * What the chosen point is called on the cart and on the order, for this
193
+ * carrier and this mode. Travels with the answer so a storefront writes the
194
+ * point under the names THIS carrier's booking reads without carrying a copy
195
+ * of that map: the names are declared once on the platform, and the waybill
196
+ * compositions read the same declaration.
197
+ */
198
+ export interface PickupPointKeys {
199
+ point: string
200
+ name: string | null
201
+ city: string | null
202
+ address: string | null
203
+ phone: string | null
204
+ postal: string | null
205
+ }
206
+
207
+ export interface PickupPointsResponse {
208
+ points: StorePickupPoint[]
209
+ keys: PickupPointKeys
210
+ }
211
+
212
+ /**
213
+ * GET /api/store/integrations/:provider/pickup-points — every carrier's
214
+ * offices and lockers, one shape, one ranking, the store twin of the admin's
215
+ * own picker door.
216
+ *
217
+ * Search is the CARRIER'S where the carrier searches (Speedy by office name
218
+ * and by town, Pigeon by name compensated with town and postcode) and the
219
+ * platform's where it does not (Econt and BoxNow publish whole catalogues,
220
+ * held per store for ten minutes and ranked so a town's own offices beat an
221
+ * office on a street that shares the town's name). A storefront sends the
222
+ * query and gets the right answer either way.
223
+ *
224
+ * Auth: anon (the store's key).
225
+ * Errors: 400 when `mode` is missing or the carrier does not serve it, 404
226
+ * for an unknown carrier, 503 when the store has not connected it.
227
+ */
228
+ export async function listPickupPoints(
229
+ client: StorefrontClient,
230
+ provider: string,
231
+ opts: { mode: "office" | "locker"; q?: string; limit?: number }
232
+ ): Promise<PickupPointsResponse> {
233
+ const query = new URLSearchParams({ mode: opts.mode })
234
+ if (opts.q) query.set("q", opts.q)
235
+ if (opts.limit) query.set("limit", String(opts.limit))
236
+ return client.get(
237
+ `/api/store/integrations/${encodeURIComponent(provider)}/pickup-points?${query.toString()}`
238
+ )
239
+ }
@@ -0,0 +1,68 @@
1
+ import { isStripeLike } from "../lib/payment-constants"
2
+
3
+ /**
4
+ * Is there a card at this checkout, and how does the browser reach it?
5
+ *
6
+ * One row answers both. The `pp_stripe` entry of
7
+ * `GET /api/store/payment-providers` carries its own `publishable_key` and,
8
+ * for a store that linked Stripe by Connect, the merchant's `account_id`.
9
+ * Reading the two apart is what broke: a store could offer a card it had no
10
+ * way to charge, and a shopper met a card form that never loaded.
11
+ *
12
+ * A card with no key is NOT an offer. Stripe.js cannot load without a
13
+ * publishable key, so the tab would sit on a loading skeleton for ever and
14
+ * the checkout would read as broken. It is a misconfiguration, never the
15
+ * shopper's problem, so the caller hides the tab and tells the merchant's
16
+ * checkout error log instead.
17
+ *
18
+ * `env` is the storefront's own `NEXT_PUBLIC_STRIPE_KEY` and
19
+ * `NEXT_PUBLIC_STRIPE_ACCOUNT_ID`, the fallback for a storefront running its
20
+ * own Stripe outside the platform. It is taken as a PAIR, never mixed with
21
+ * the store's: a platform key with no account charges the platform, and a
22
+ * merchant account under the wrong key is refused by Stripe.
23
+ */
24
+
25
+ /** What a processor entry has to carry for the browser to use it. */
26
+ export type CardEntryLike = {
27
+ id: string
28
+ publishable_key?: string
29
+ account_id?: string
30
+ }
31
+
32
+ export type CardOffer = {
33
+ /** The entry, whether or not it can be mounted. */
34
+ entry: CardEntryLike | null
35
+ /** True when the browser has a publishable key for it. */
36
+ mountable: boolean
37
+ /** The key to load Stripe.js with, the store's before the storefront's. */
38
+ publishableKey: string | undefined
39
+ /** The merchant's linked account, Connect stores only. */
40
+ accountId: string | undefined
41
+ }
42
+
43
+ const NO_CARD: CardOffer = {
44
+ entry: null,
45
+ mountable: false,
46
+ publishableKey: undefined,
47
+ accountId: undefined,
48
+ }
49
+
50
+ export function resolveCardOffer(
51
+ methods: ReadonlyArray<Record<string, unknown>> | null | undefined,
52
+ env?: { key?: string; accountId?: string }
53
+ ): CardOffer {
54
+ const entry = methods?.find(
55
+ (m) => "id" in m && isStripeLike(m.id as string)
56
+ ) as CardEntryLike | undefined
57
+ if (!entry) return NO_CARD
58
+ // The store's answer wins, as one pair or not at all.
59
+ const fromStore = !!entry.publishable_key
60
+ const publishableKey = fromStore ? entry.publishable_key : env?.key
61
+ const accountId = fromStore ? entry.account_id : env?.accountId
62
+ return {
63
+ entry,
64
+ mountable: !!publishableKey,
65
+ publishableKey,
66
+ accountId,
67
+ }
68
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The carrier's own mark beside its name on a checkout shipping row.
3
+ *
4
+ * A shopper picks a courier by recognising it, and until 2026-09-18 a store
5
+ * got names only unless it hunted down logo files and passed a map of its
6
+ * own (store-package card). The marks are the platform's: the same files
7
+ * the admin's integrations screens wear, served from the platform the store
8
+ * already talks to, so a carrier we add tomorrow arrives with its mark and
9
+ * no store copies an image anywhere.
10
+ *
11
+ * Keyed by the carrier's fulfillment option id, which the platform's
12
+ * registry owns and a shipping option carries in `data.id`. Both modes of a
13
+ * carrier wear the same mark: it names the courier, not the destination.
14
+ */
15
+
16
+ const MARK: Record<string, { carrier: string; alt: string }> = {
17
+ "econt-address": { carrier: "econt", alt: "Econt" },
18
+ "econt-office": { carrier: "econt", alt: "Econt" },
19
+ "speedy-address": { carrier: "speedy", alt: "Speedy" },
20
+ "speedy-office": { carrier: "speedy", alt: "Speedy" },
21
+ "boxnow-locker": { carrier: "boxnow", alt: "BOX NOW" },
22
+ "pigeon-address": { carrier: "pigeon", alt: "Pigeon Express" },
23
+ "pigeon-office": { carrier: "pigeon", alt: "Pigeon Express" },
24
+ "pigeon-locker": { carrier: "pigeon", alt: "Pigeon Express" },
25
+ }
26
+
27
+ /**
28
+ * The default `logoByFulfillmentOptionId` map for a checkout's shipping
29
+ * list. A store that passes its own map keeps it: this is the floor, not a
30
+ * ceiling.
31
+ *
32
+ * `origin` is the SDK client's own (`client.origin`), which in a storefront
33
+ * is the shop's own address, because the store API ships no CORS headers and
34
+ * every storefront reaches it through a same-origin `/api/store/*` rewrite.
35
+ * So the mark is asked for under that same path, which is the one address a
36
+ * storefront can always reach; the platform answers it with the file it
37
+ * already serves. Pointing at the platform's own `/integrations/…` instead
38
+ * is what made every mark 404, since that path is not proxied and the shop's
39
+ * own domain has no such file (found on the workbench before release).
40
+ */
41
+ export function carrierMarks(
42
+ origin: string
43
+ ): Record<string, { src: string; alt: string }> {
44
+ const base = origin.replace(/\/+$/, "")
45
+ const marks: Record<string, { src: string; alt: string }> = {}
46
+ for (const [optionId, mark] of Object.entries(MARK)) {
47
+ marks[optionId] = {
48
+ src: `${base}/api/store/integrations/marks/${mark.carrier}`,
49
+ alt: mark.alt,
50
+ }
51
+ }
52
+ return marks
53
+ }
@@ -7,6 +7,10 @@ import type { Cart } from "../api/carts"
7
7
  import type { StoreShippingOption } from "../api/checkout"
8
8
  import type { StoreCustomer } from "../api/customers"
9
9
  import { CheckoutAddressForm } from "./address-form"
10
+ import { carrierMarks } from "./carrier-marks"
11
+ import { DiscountSection } from "./discount-section"
12
+ import { MobileCheckoutBottomBar } from "./mobile-checkout-bottom-bar"
13
+ import { MobileCheckoutTopBar } from "./mobile-checkout-top-bar"
10
14
  import { OrderSummary } from "./order-summary"
11
15
  import { CheckoutPaymentMethodList } from "./payment-method-list"
12
16
  import { CheckoutShippingMethodList } from "./shipping-method-list"
@@ -41,9 +45,13 @@ import {
41
45
  * cart returned by promo/gift-card/quantity calls is handed up so the
42
46
  * host updates its cart state (`router.refresh()` in an RSC app) AND
43
47
  * the hook's `syncPaymentAmount` re-aligns any pending session.
44
- * - The mobile top/bottom summary bars of the @1click layout are not in
45
- * this batch's scope; the desktop-first layout stands alone and
46
- * stores compose their own mobile bars from the hook + OrderSummary.
48
+ *
49
+ * On a phone the summary column would be a wall of items above the form, so
50
+ * it is replaced by two bars carrying the same content behind one tap: the
51
+ * total at the top of the page, and, above Place order, a card with the
52
+ * first item and the total. Both open onto `MobileOrderSummaryBody`, all
53
+ * three surfaces take their numbers from `checkoutTotals`, and the promo
54
+ * field is exposed above the bottom bar rather than left two taps deep.
47
55
  */
48
56
 
49
57
  type CheckoutClientProps = {
@@ -147,7 +155,23 @@ export function CheckoutClient({
147
155
  amount={o.optimisticTotalCents}
148
156
  appearance={appearance}
149
157
  fonts={fonts}
158
+ // The store's own Stripe, off the card entry the listing already
159
+ // carried here. Nothing for a storefront to configure: connecting
160
+ // Stripe in the admin is the whole setup.
161
+ publishableKey={o.cardPublishableKey}
162
+ stripeAccountId={o.cardStripeAccountId}
150
163
  >
164
+ {/* The phone's first sight of the order: the total, one tap from what
165
+ makes it up. Above the form, as the desktop summary is beside it. */}
166
+ <MobileCheckoutTopBar
167
+ client={client}
168
+ cart={o.summaryCart}
169
+ optimisticShippingCost={o.optimisticShippingCost}
170
+ optimisticMethodFee={o.optimisticMethodFee}
171
+ showGiftCards={showGiftCards}
172
+ onCartChange={handleCartChange}
173
+ />
174
+
151
175
  <div className="max-w-[1140px] mx-auto px-5 py-8 sm:py-12">
152
176
  <div className="flex flex-col sm:flex-row sm:justify-center gap-8 sm:gap-12">
153
177
  {/* ═══ LEFT: FORM ════════════════════════════════════════════ */}
@@ -174,20 +198,27 @@ export function CheckoutClient({
174
198
  onSelect={o.handleSelectShipping}
175
199
  addressReady={o.addressReady}
176
200
  currencyCode={cart.currency_code}
177
- econt={{
178
- selectedOffice: o.selectedEcontOffice,
179
- onSelectOffice: o.handleSelectEcontOffice,
180
- userCity: o.formData["shipping_address.city"] ?? "",
181
- userAddress: o.formData["shipping_address.address_1"] ?? "",
182
- }}
183
- boxnow={{
201
+ // ONE picker, every carrier (2026-09-18). This used to mount
202
+ // three: an Econt one that called the carrier from the browser,
203
+ // a BoxNow one and a Pigeon one that each called a store route
204
+ // written for them alone, and nothing for Speedy, whose
205
+ // catalogue is credentialed and can never be reached from a
206
+ // browser at all. So a store could offer delivery to a Speedy
207
+ // office and give the shopper no way to name one. All four are
208
+ // the same question, answered by one platform door. The three
209
+ // components stay exported for a store that mounts its own.
210
+ pickup={{
184
211
  client,
185
- selectedLocker: o.selectedBoxnowLocker,
186
- onSelectLocker: o.handleSelectBoxnowLocker,
212
+ selectedPoint: o.selectedPickupPoint,
213
+ onSelectPoint: o.handleSelectPickupPoint,
187
214
  userCity: o.formData["shipping_address.city"] ?? "",
188
215
  userAddress: o.formData["shipping_address.address_1"] ?? "",
189
216
  }}
190
- logoByFulfillmentOptionId={logoByFulfillmentOptionId}
217
+ // Every carrier wears its own mark without the store lifting a
218
+ // finger; a store that passes its own map keeps it.
219
+ logoByFulfillmentOptionId={
220
+ logoByFulfillmentOptionId ?? carrierMarks(client.origin)
221
+ }
191
222
  />
192
223
 
193
224
  <CheckoutPaymentMethodList
@@ -197,6 +228,7 @@ export function CheckoutClient({
197
228
  paymentTab={o.paymentTab}
198
229
  onPaymentTab={o.handlePaymentTab}
199
230
  deliveryReady={o.deliveryReady}
231
+ nothingToPay={o.nothingToPay}
200
232
  paymentError={o.paymentError}
201
233
  onPaymentElementChange={o.handlePaymentElementChange}
202
234
  performBuyClick={o.performBuyClick}
@@ -207,6 +239,32 @@ export function CheckoutClient({
207
239
  total={o.optimisticTotal}
208
240
  logError={logError}
209
241
  extraTabs={extraPaymentTabs}
242
+ beforePaymentButton={
243
+ // Phone only: the desktop summary already carries both, and
244
+ // an empty slot would still take its margin above the button.
245
+ <div className="sm:hidden space-y-3">
246
+ {/* The code field itself, open: inside a closed bar it
247
+ would be the second tap and a shopper holding a code
248
+ would never reach it. */}
249
+ <DiscountSection
250
+ client={client}
251
+ cart={o.summaryCart}
252
+ alwaysOpen
253
+ onCartChange={handleCartChange}
254
+ />
255
+ {/* What they are paying for, last thing before they pay.
256
+ Its own promo door is off: the field above is the one. */}
257
+ <MobileCheckoutBottomBar
258
+ client={client}
259
+ cart={o.summaryCart}
260
+ optimisticShippingCost={o.optimisticShippingCost}
261
+ optimisticMethodFee={o.optimisticMethodFee}
262
+ showGiftCards={showGiftCards}
263
+ hideDiscount
264
+ onCartChange={handleCartChange}
265
+ />
266
+ </div>
267
+ }
210
268
  />
211
269
  </div>
212
270
 
@@ -0,0 +1,113 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef } from "react"
4
+
5
+ import { reportCheckoutError } from "../api/checkout"
6
+ import type { StorefrontClient } from "../api/http"
7
+ import { useCheckoutLabels } from "./context"
8
+
9
+ /**
10
+ * CheckoutErrorScreen — what a shopper sees when the checkout PAGE fails,
11
+ * as opposed to a call inside it.
12
+ *
13
+ * Next renders a route's `error.tsx` in place of the page when anything in
14
+ * it throws, a server component included, through the RSC bridge. Without
15
+ * one, a checkout that throws shows the framework's own error page: a
16
+ * shopper at the moment of paying is handed a stack trace or a blank
17
+ * screen, their cart looks lost, and nobody is told. The kit's own
18
+ * failures already reach the merchant's checkout error log; this closes
19
+ * the one class that never did, because the component that reports them
20
+ * was the component that died.
21
+ *
22
+ * Mount it as the route's boundary:
23
+ *
24
+ * // src/app/checkout/error.tsx
25
+ * "use client"
26
+ * import { CheckoutErrorScreen } from "@cartbase/storefront/checkout"
27
+ * import { storeClient } from "@/lib/store"
28
+ *
29
+ * export default function CheckoutError(props) {
30
+ * return <CheckoutErrorScreen {...props} client={storeClient} />
31
+ * }
32
+ *
33
+ * The copy comes from the label pack when a `CheckoutProvider` stands
34
+ * above the boundary (a provider in the route's LAYOUT does; one inside
35
+ * the page does not, because the page is what Next replaced). Pass the
36
+ * three strings for a store whose provider sits inside the page.
37
+ */
38
+
39
+ export type CheckoutErrorScreenProps = {
40
+ /** Next's error boundary argument. */
41
+ error: Error & { digest?: string }
42
+ /** Next's retry: re-renders the route's tree. */
43
+ reset: () => void
44
+ /**
45
+ * The SDK transport. With it the failure reaches the merchant's checkout
46
+ * error log; without it the screen still renders, and nobody hears.
47
+ */
48
+ client?: StorefrontClient
49
+ /** The cart the shopper was checking out, when the store knows it. */
50
+ cartId?: string
51
+ /** Copy override, for a boundary that stands outside the provider. */
52
+ title?: string
53
+ body?: string
54
+ retryLabel?: string
55
+ }
56
+
57
+ export function CheckoutErrorScreen({
58
+ error,
59
+ reset,
60
+ client,
61
+ cartId,
62
+ title,
63
+ body,
64
+ retryLabel,
65
+ }: CheckoutErrorScreenProps) {
66
+ const labels = useCheckoutLabels()
67
+ // React runs effects twice in development's strict mode, and a retry
68
+ // that fails again remounts this component: the ref keeps one failure to
69
+ // one report instead of filling the merchant's log with copies.
70
+ const reportedRef = useRef<string | null>(null)
71
+
72
+ useEffect(() => {
73
+ if (!client) return
74
+ const key = error.digest ?? error.message ?? "unknown"
75
+ if (reportedRef.current === key) return
76
+ reportedRef.current = key
77
+
78
+ void reportCheckoutError(client, {
79
+ error_type: "error_boundary_checkout",
80
+ message: error.message || String(error),
81
+ ...(cartId ? { cart_id: cartId } : {}),
82
+ context: {
83
+ digest: error.digest ?? null,
84
+ err_name: error.name ?? null,
85
+ // Enough of the stack to find the component, short enough to stay
86
+ // inside the sink's limit.
87
+ err_stack: error.stack?.slice(0, 1500) ?? null,
88
+ path:
89
+ typeof window !== "undefined" ? window.location.pathname : null,
90
+ },
91
+ })
92
+ }, [client, cartId, error])
93
+
94
+ return (
95
+ <div className="min-h-[60vh] flex items-center justify-center px-5 py-16">
96
+ <div className="max-w-md text-center">
97
+ <h1 className="text-xl font-semibold text-foreground mb-2">
98
+ {title ?? labels.errorTitle}
99
+ </h1>
100
+ <p className="text-sm text-muted-foreground mb-5">
101
+ {body ?? labels.errorBody}
102
+ </p>
103
+ <button
104
+ type="button"
105
+ onClick={reset}
106
+ className="inline-flex items-center justify-center h-11 px-5 rounded-xl bg-foreground text-card text-sm font-semibold transition-colors hover:bg-foreground/90"
107
+ >
108
+ {retryLabel ?? labels.errorRetry}
109
+ </button>
110
+ </div>
111
+ </div>
112
+ )
113
+ }