@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.
- package/package.json +14 -1
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +121 -0
- package/src/checkout/card-offer.ts +68 -0
- package/src/checkout/carrier-marks.ts +53 -0
- package/src/checkout/checkout-client.tsx +71 -13
- package/src/checkout/checkout-error-screen.tsx +113 -0
- package/src/checkout/discount-section.tsx +88 -56
- package/src/checkout/fulfillment-option.ts +30 -0
- package/src/checkout/index.ts +41 -6
- package/src/checkout/labels.ts +98 -0
- package/src/checkout/line-item-card.tsx +35 -83
- package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
- package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
- package/src/checkout/mobile-order-summary-body.tsx +172 -0
- package/src/checkout/order-summary.tsx +85 -179
- package/src/checkout/payment-button.tsx +25 -8
- package/src/checkout/payment-method-list.tsx +51 -13
- package/src/checkout/payment-wrapper.tsx +57 -13
- package/src/checkout/pickup-option.ts +35 -0
- package/src/checkout/pickup-point-selector.tsx +372 -0
- package/src/checkout/pigeon-office-selector.tsx +379 -0
- package/src/checkout/shipping-method-list.tsx +102 -10
- package/src/checkout/summary-math.ts +152 -0
- package/src/checkout/use-checkout-funnel.ts +303 -0
- package/src/checkout/use-checkout-orchestration.ts +384 -30
- package/src/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +37 -1
- package/src/locales/es.ts +35 -1
- package/src/tracking/attribution.ts +83 -0
- package/src/tracking/consent.ts +53 -0
- package/src/tracking/events.ts +113 -0
- package/src/tracking/fbq.ts +48 -0
- package/src/tracking/gtag.ts +32 -0
- package/src/tracking/index.ts +32 -1
- package/src/tracking/once.ts +137 -0
- package/src/tracking/rybbit-events.ts +42 -0
- package/src/tracking/ttq.ts +24 -0
- 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.
|
|
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",
|
package/src/api/checkout.ts
CHANGED
|
@@ -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(
|
package/src/api/integrations.ts
CHANGED
|
@@ -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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
+
}
|