@cartbase/storefront 0.20.1 → 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 (50) hide show
  1. package/package.json +274 -258
  2. package/src/api/checkout.ts +15 -0
  3. package/src/api/http.ts +10 -0
  4. package/src/api/integrations.ts +121 -0
  5. package/src/api/store.ts +21 -0
  6. package/src/cart-drawer/payment-badges.tsx +36 -96
  7. package/src/checkout/card-offer.ts +68 -0
  8. package/src/checkout/carrier-marks.ts +53 -0
  9. package/src/checkout/checkout-client.tsx +71 -13
  10. package/src/checkout/checkout-error-screen.tsx +113 -0
  11. package/src/checkout/discount-section.tsx +88 -56
  12. package/src/checkout/fulfillment-option.ts +30 -0
  13. package/src/checkout/index.ts +41 -6
  14. package/src/checkout/labels.ts +98 -0
  15. package/src/checkout/line-item-card.tsx +35 -83
  16. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  17. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  18. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  19. package/src/checkout/order-summary.tsx +85 -179
  20. package/src/checkout/payment-button.tsx +25 -8
  21. package/src/checkout/payment-method-list.tsx +51 -13
  22. package/src/checkout/payment-wrapper.tsx +57 -13
  23. package/src/checkout/pickup-option.ts +35 -0
  24. package/src/checkout/pickup-point-selector.tsx +372 -0
  25. package/src/checkout/pigeon-office-selector.tsx +379 -0
  26. package/src/checkout/shipping-method-list.tsx +102 -10
  27. package/src/checkout/summary-math.ts +152 -0
  28. package/src/checkout/use-checkout-funnel.ts +303 -0
  29. package/src/checkout/use-checkout-orchestration.ts +384 -30
  30. package/src/common/icons/cartbase-mark.ts +9 -0
  31. package/src/common/icons/payment-marks.ts +73 -0
  32. package/src/common/icons/social-marks.ts +61 -0
  33. package/src/common/index.ts +15 -0
  34. package/src/common/payment-icons.tsx +54 -0
  35. package/src/common/powered-by-cartbase.tsx +47 -0
  36. package/src/common/social-links.tsx +65 -0
  37. package/src/lib/stripe-env.ts +25 -0
  38. package/src/locales/bg.ts +39 -0
  39. package/src/locales/es.ts +37 -0
  40. package/src/store/labels.ts +10 -0
  41. package/src/tracking/attribution.ts +83 -0
  42. package/src/tracking/consent.ts +53 -0
  43. package/src/tracking/events.ts +113 -0
  44. package/src/tracking/fbq.ts +48 -0
  45. package/src/tracking/gtag.ts +32 -0
  46. package/src/tracking/index.ts +32 -1
  47. package/src/tracking/once.ts +137 -0
  48. package/src/tracking/rybbit-events.ts +42 -0
  49. package/src/tracking/ttq.ts +24 -0
  50. package/src/tracking/types.ts +34 -0
package/src/api/http.ts CHANGED
@@ -62,6 +62,7 @@ const CACHEABLE_READ_PREFIXES = [
62
62
  "/api/store/product-types",
63
63
  "/api/store/product-tags",
64
64
  "/api/store/reviews",
65
+ "/api/store/payment-marks",
65
66
  "/api/store/currencies",
66
67
  "/api/store/countries",
67
68
  "/api/store/locales",
@@ -97,6 +98,15 @@ export class StorefrontClient {
97
98
  }
98
99
  }
99
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
+
100
110
  /** The low-level typed request. Domain modules call this — apps rarely should. */
101
111
  async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
102
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
+ }
package/src/api/store.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * storefront needs to title, head and foot itself without hardcoding a
6
6
  * name. One anonymous read at layout level. Docs: docs/storefront/store.md.
7
7
  */
8
+ import type { PaymentMethod } from "../common/icons/payment-marks"
9
+ import type { SocialNetwork } from "../common/icons/social-marks"
8
10
  import type { StorefrontClient } from "./http"
9
11
 
10
12
  export interface StoreBrand {
@@ -17,8 +19,16 @@ export interface StoreBrand {
17
19
  /** `#rrggbb` or null. */
18
20
  color_secondary: string | null
19
21
  slogan: string | null
22
+ /**
23
+ * The store's social profiles by network, only the ones set (Settings,
24
+ * Brand, Social links). Absent from platforms before 2026-09-17.
25
+ */
26
+ social?: StoreSocialLinks
20
27
  }
21
28
 
29
+ /** A profile address per network the kit has a mark for; `SocialLinks` (common) draws them. */
30
+ export type StoreSocialLinks = Partial<Record<SocialNetwork, string>>
31
+
22
32
  export interface StoreIdentity {
23
33
  /** The store's public id: what `<PlatformInit storeId>` mounts. */
24
34
  id: string
@@ -33,3 +43,14 @@ export interface StoreIdentity {
33
43
  export function getStore(client: StorefrontClient): Promise<{ store: StoreIdentity }> {
34
44
  return client.request<{ store: StoreIdentity }>("/api/store/store")
35
45
  }
46
+
47
+ /**
48
+ * GET /api/store/payment-marks — anon. The payment marks the store's
49
+ * Stripe account allows, in the kit's order (every card brand when cards
50
+ * are on; Apple Pay, Google Pay, PayPal, Revolut, Klarna, Bancontact,
51
+ * iDEAL each when its method is on); empty with no Stripe. `PaymentIcons`
52
+ * (common) draws them. Platforms before 2026-09-17 answer 404.
53
+ */
54
+ export function getPaymentMarks(client: StorefrontClient): Promise<{ payment_marks: PaymentMethod[] }> {
55
+ return client.get<{ payment_marks: PaymentMethod[] }>("/api/store/payment-marks")
56
+ }
@@ -1,96 +1,36 @@
1
- "use client"
2
-
3
- import type { ReactNode } from "react"
4
-
5
- /**
6
- * CartPaymentBadges — row of small payment-method logos shown near the
7
- * checkout button.
8
- *
9
- * Ported from `@1click/ui/src/cart-drawer/payment-badges.tsx` (v2.3.1);
10
- * originally extracted from mindpages-storefront
11
- * src/modules/cart-drawer/cart-payment-badges.tsx.
12
- * Inline SVGs are kept so stores don't need to provide asset files for the
13
- * default set, but stores can pass their own badges map via the `badges`
14
- * prop to override.
15
- */
16
-
17
- type BadgeKey = "visa" | "mastercard" | "applepay" | "googlepay" | "amex"
18
-
19
- const defaultBadges: Record<BadgeKey, ReactNode> = {
20
- visa: (
21
- <svg viewBox="0 0 48 32" width="42" height="28" fill="none">
22
- <rect width="48" height="32" rx="4" fill="#fff" stroke="#e4e4e7" strokeWidth="1" />
23
- <path
24
- d="M20.3 21h-2.7l1.7-10.5h2.7L20.3 21zm-4.7 0l-2.5-7.2-.3-1.5c-.5-1.3-1.7-1.8-1.7-1.8h-.1l-1 .2L7 21h2.8l.6-1.5h3.4l.3 1.5h2.5zm-3.2-3.6l1.4-3.8.8 3.8h-2.2zM32 10.5L29.3 21h-2.5l1.5-8.2-.1-.2-2 8.4h-2l-2-8.4v.3L20.7 21h-2.4l2.8-10.5h3.4l1.7 7.2 1.7-7.2H32zm8.8 0L38 21h-2.5l2.8-10.5h2.5z"
25
- fill="#1a1f71"
26
- />
27
- </svg>
28
- ),
29
- mastercard: (
30
- <svg viewBox="0 0 48 32" width="42" height="28" fill="none">
31
- <rect width="48" height="32" rx="4" fill="#fff" stroke="#e4e4e7" strokeWidth="1" />
32
- <circle cx="19" cy="16" r="8" fill="#eb001b" />
33
- <circle cx="29" cy="16" r="8" fill="#f79e1b" />
34
- <path d="M24 9.8a8 8 0 010 12.4 8 8 0 000-12.4z" fill="#ff5f00" />
35
- </svg>
36
- ),
37
- applepay: (
38
- <svg viewBox="0 0 48 32" width="42" height="28" fill="none">
39
- <rect width="48" height="32" rx="4" fill="#000" />
40
- <g fill="#fff">
41
- <path d="M15.2 11.8c-.6.7-1.5 1.2-2.4 1.1-.1-1 .4-2 .9-2.6.6-.7 1.5-1.2 2.3-1.2.1 1-.3 2-.8 2.7zm.8 1.4c-1.3-.1-2.5.8-3.1.8s-1.6-.7-2.7-.7c-1.4 0-2.7.8-3.4 2.1-1.4 2.5-.4 6.2 1 8.2.7 1 1.5 2.1 2.6 2s1.4-.7 2.7-.7 1.6.7 2.7.7 1.8-1 2.5-2c.8-1.1 1.1-2.2 1.1-2.3-1.2-.5-2-1.8-2-3.3 0-1.3.7-2.5 1.8-3.1-.7-1-1.8-1.7-3.2-1.7z" />
42
- <path d="M25 12.5v10.8h1.7v-3.7h2.3c2.1 0 3.6-1.5 3.6-3.6s-1.5-3.5-3.5-3.5H25zm1.7 1.4h1.9c1.5 0 2.3.8 2.3 2.1s-.8 2.1-2.3 2.1h-1.9v-4.2zm8.8 9.5c1.1 0 2.1-.5 2.5-1.4h0v1.3H39.6v-5.2c0-1.8-1.4-2.9-3.5-2.9-2 0-3.4 1.1-3.4 2.8h1.6c.1-.8.9-1.4 1.8-1.4 1.2 0 1.8.5 1.8 1.5v.7l-2.4.1c-2.2.1-3.4 1-3.4 2.6 0 1.6 1.2 2.7 2.9 2.7v-.8zm.4-1.3c-1 0-1.7-.5-1.7-1.3 0-.8.6-1.3 1.8-1.4l2.1-.1v.7c0 1.2-1 2.1-2.2 2.1z" />
43
- </g>
44
- </svg>
45
- ),
46
- googlepay: (
47
- <svg viewBox="0 0 48 32" width="42" height="28" fill="none">
48
- <rect width="48" height="32" rx="4" fill="#fff" stroke="#e4e4e7" strokeWidth="1" />
49
- <path
50
- d="M23.5 16.7v3.1h-1v-7.6h2.7c.7 0 1.2.2 1.7.6.5.4.7.9.7 1.5s-.2 1.1-.7 1.5c-.5.4-1 .6-1.7.6h-1.7zm0-3.5v2.5h1.7c.4 0 .8-.2 1.1-.4.3-.3.4-.6.4-1s-.1-.7-.4-1c-.3-.3-.6-.4-1.1-.4h-1.7z"
51
- fill="#3c4043"
52
- />
53
- <path
54
- d="M30.6 14.2c.7 0 1.3.2 1.8.6.4.4.7 1 .7 1.7v3.3h-1v-.7c-.4.6-.9.9-1.6.9-.6 0-1.1-.2-1.5-.5-.4-.3-.6-.8-.6-1.3 0-.5.2-1 .5-1.3.4-.3.9-.5 1.5-.5.6 0 1.1.2 1.4.5v-.3c0-.4-.2-.7-.4-.9-.3-.2-.6-.4-1-.4-.6 0-1 .2-1.3.7l-.8-.5c.5-.7 1.1-1 2-1h.3zm-1 4.6c.3.2.6.3 1 .3s.7-.1 1-.3c.3-.2.4-.5.4-.8s-.1-.6-.4-.8c-.3-.2-.6-.3-1-.3s-.7.1-1 .3c-.3.2-.4.5-.4.8s.2.6.4.8z"
55
- fill="#3c4043"
56
- />
57
- <path d="M37 14.4l-2.4 5.5h-1l.9-1.9-1.6-3.6h1.1l1 2.5 1-2.5H37z" fill="#3c4043" />
58
- <path d="M18.3 16c0-.3 0-.6-.1-.9h-4.6v1.7h2.7c-.1.6-.4 1.1-.9 1.4v1.2h1.5c.9-.8 1.4-2 1.4-3.4z" fill="#4285f4" />
59
- <path d="M13.6 19.7c1.2 0 2.2-.4 3-1.1l-1.5-1.2c-.4.3-.9.4-1.5.4-1.2 0-2.1-.8-2.5-1.8H9.7v1.2c.7 1.5 2.2 2.5 3.9 2.5z" fill="#34a853" />
60
- <path d="M11.1 16c0-.3.1-.7.2-1v-1.2H9.7c-.4.7-.6 1.4-.6 2.2s.2 1.5.6 2.2l1.6-1.2c-.1-.3-.2-.7-.2-1z" fill="#fbbc04" />
61
- <path d="M13.6 12.2c.7 0 1.3.2 1.8.7l1.3-1.3c-.8-.8-1.9-1.2-3.1-1.2-1.7 0-3.2 1-3.9 2.5l1.6 1.2c.4-1.1 1.3-1.9 2.3-1.9z" fill="#ea4335" />
62
- </svg>
63
- ),
64
- amex: (
65
- <svg viewBox="0 0 48 32" width="42" height="28" fill="none">
66
- <rect width="48" height="32" rx="4" fill="#006fcf" />
67
- <text x="24" y="20" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle" fontFamily="Arial, sans-serif">
68
- AMEX
69
- </text>
70
- </svg>
71
- ),
72
- }
73
-
74
- type CartPaymentBadgesProps = {
75
- methods?: BadgeKey[]
76
- badges?: Partial<Record<BadgeKey, ReactNode>>
77
- }
78
-
79
- export function CartPaymentBadges({
80
- methods = ["visa", "mastercard", "googlepay", "applepay"],
81
- badges,
82
- }: CartPaymentBadgesProps) {
83
- const resolved = { ...defaultBadges, ...badges }
84
- return (
85
- <div className="flex items-center justify-center gap-2 px-5 py-3">
86
- {methods.map((method) => (
87
- <div
88
- key={method}
89
- className="opacity-70 hover:opacity-100 transition-opacity"
90
- >
91
- {resolved[method]}
92
- </div>
93
- ))}
94
- </div>
95
- )
96
- }
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import { PaymentIcon, type PaymentMethod } from "../common/payment-icons"
5
+
6
+ /**
7
+ * CartPaymentBadges — row of small payment-method logos shown near the
8
+ * checkout button.
9
+ *
10
+ * Ported from `@1click/ui/src/cart-drawer/payment-badges.tsx` (v2.3.1);
11
+ * originally extracted from mindpages-storefront
12
+ * src/modules/cart-drawer/cart-payment-badges.tsx. Since 2026-09-17 the
13
+ * badges are the kit's payment marks (`common/payment-icons`), the same the
14
+ * footer shows, so any mark the kit carries can be named; a store can still
15
+ * pass its own badge per method through `badges`.
16
+ */
17
+
18
+ type CartPaymentBadgesProps = {
19
+ methods?: PaymentMethod[]
20
+ badges?: Partial<Record<PaymentMethod, ReactNode>>
21
+ }
22
+
23
+ export function CartPaymentBadges({
24
+ methods = ["visa", "mastercard", "googlepay", "applepay"],
25
+ badges,
26
+ }: CartPaymentBadgesProps) {
27
+ return (
28
+ <div className="flex items-center justify-center gap-2 px-5 py-3">
29
+ {methods.map((method) => (
30
+ <div key={method} className="opacity-70 transition-opacity hover:opacity-100">
31
+ {badges?.[method] ?? <PaymentIcon method={method} className="h-7" />}
32
+ </div>
33
+ ))}
34
+ </div>
35
+ )
36
+ }
@@ -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