@cartbase/storefront 0.23.0 → 0.24.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.23.0",
3
+ "version": "0.24.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": {
@@ -177,9 +177,11 @@ export interface StorePickupPoint {
177
177
  provider: string
178
178
  mode: "office" | "locker"
179
179
  /** THEIR id, exactly as the order will carry it. */
180
- id: string
181
- name: string
182
- city: string
180
+ id: string
181
+ name: string
182
+ /** Alternate script/transliteration supplied by the carrier. */
183
+ secondary_name?: string
184
+ city: string
183
185
  address: string
184
186
  postal_code: string
185
187
  phone: string
@@ -5,7 +5,8 @@ import type { Appearance, StripeElementsOptions } from "@stripe/stripe-js"
5
5
  import type { StorefrontClient } from "../api/http"
6
6
  import type { Cart } from "../api/carts"
7
7
  import type { StoreShippingOption } from "../api/checkout"
8
- import type { StoreCustomer } from "../api/customers"
8
+ import type { StoreCustomer } from "../api/customers"
9
+ import type { PaymentMethod } from "../common/payment-icons"
9
10
  import { CheckoutAddressForm } from "./address-form"
10
11
  import { DiscountSection } from "./discount-section"
11
12
  import { MobileCheckoutBottomBar } from "./mobile-checkout-bottom-bar"
@@ -59,7 +60,11 @@ type CheckoutClientProps = {
59
60
  cart: Cart
60
61
  customer: StoreCustomer | null
61
62
  availableShippingMethods: StoreShippingOption[] | null
62
- availablePaymentMethods: PaymentProviderLike[] | null
63
+ availablePaymentMethods: PaymentProviderLike[] | null
64
+ /** Card and wallet marks returned by `GET /api/store/payment-marks`. */
65
+ paymentMarks?: readonly PaymentMethod[]
66
+ /** Store-owned terms notice, including links to its content pages. */
67
+ termsContent?: React.ReactNode
63
68
  /**
64
69
  * The country to PRESELECT when the cart has no shipping address yet.
65
70
  * Optional, like the hook's own: the offer comes from the store
@@ -76,8 +81,10 @@ type CheckoutClientProps = {
76
81
  methods: PaymentProviderLike[] | null,
77
82
  selectedShippingOption: StoreShippingOption | null
78
83
  ) => PaymentProviderLike[] | null
79
- /** Show the gift-card widget in the summary (default true). */
80
- showGiftCards?: boolean
84
+ /** Show the gift-card widget in the summary (default true). */
85
+ showGiftCards?: boolean
86
+ /** Show the security reassurance below the desktop total (default true). */
87
+ showSecureCheckout?: boolean
81
88
  logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
82
89
  /** Stripe Elements appearance + fonts forwarded to PaymentWrapper. */
83
90
  appearance?: Appearance
@@ -107,11 +114,14 @@ export function CheckoutClient({
107
114
  cart,
108
115
  customer,
109
116
  availableShippingMethods,
110
- availablePaymentMethods,
117
+ availablePaymentMethods,
118
+ paymentMarks,
119
+ termsContent,
111
120
  countryCode,
112
121
  countries,
113
122
  paymentMethodFilter,
114
- showGiftCards = true,
123
+ showGiftCards = true,
124
+ showSecureCheckout = true,
115
125
  logoByFulfillmentOptionId,
116
126
  appearance,
117
127
  fonts,
@@ -165,9 +175,10 @@ export function CheckoutClient({
165
175
  <MobileCheckoutTopBar
166
176
  client={client}
167
177
  cart={o.summaryCart}
168
- optimisticShippingCost={o.optimisticShippingCost}
169
- optimisticMethodFee={o.optimisticMethodFee}
170
- showGiftCards={showGiftCards}
178
+ optimisticShippingCost={o.optimisticShippingCost}
179
+ optimisticMethodFee={o.optimisticMethodFee}
180
+ methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
181
+ showGiftCards={showGiftCards}
171
182
  onCartChange={handleCartChange}
172
183
  />
173
184
 
@@ -218,9 +229,13 @@ export function CheckoutClient({
218
229
 
219
230
  <CheckoutPaymentMethodList
220
231
  cart={cart}
221
- hasCard={o.hasCard}
222
- hasCod={o.hasCod}
223
- paymentTab={o.paymentTab}
232
+ hasCard={o.hasCard}
233
+ hasCod={o.hasCod}
234
+ offlineMethod={o.offlineMethod}
235
+ paymentMethodOrder={o.paymentMethodOrder}
236
+ paymentMarks={paymentMarks}
237
+ termsContent={termsContent}
238
+ paymentTab={o.paymentTab}
224
239
  onPaymentTab={o.handlePaymentTab}
225
240
  deliveryReady={o.deliveryReady}
226
241
  nothingToPay={o.nothingToPay}
@@ -252,9 +267,10 @@ export function CheckoutClient({
252
267
  <MobileCheckoutBottomBar
253
268
  client={client}
254
269
  cart={o.summaryCart}
255
- optimisticShippingCost={o.optimisticShippingCost}
256
- optimisticMethodFee={o.optimisticMethodFee}
257
- showGiftCards={showGiftCards}
270
+ optimisticShippingCost={o.optimisticShippingCost}
271
+ optimisticMethodFee={o.optimisticMethodFee}
272
+ methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
273
+ showGiftCards={showGiftCards}
258
274
  hideDiscount
259
275
  onCartChange={handleCartChange}
260
276
  />
@@ -273,10 +289,12 @@ export function CheckoutClient({
273
289
  onOptimisticShippingClear={() =>
274
290
  o.setOptimisticShippingCost(null)
275
291
  }
276
- optimisticMethodFee={o.optimisticMethodFee}
277
- onOptimisticMethodFeeClear={() => o.setOptimisticMethodFee(null)}
278
- showGiftCards={showGiftCards}
279
- onCartChange={handleCartChange}
292
+ optimisticMethodFee={o.optimisticMethodFee}
293
+ onOptimisticMethodFeeClear={() => o.setOptimisticMethodFee(null)}
294
+ methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
295
+ showGiftCards={showGiftCards}
296
+ showSecureCheckout={showSecureCheckout}
297
+ onCartChange={handleCartChange}
280
298
  />
281
299
  </div>
282
300
  </div>
@@ -168,7 +168,9 @@ type OrderSummaryProps = {
168
168
  /** Show the gift-card widget. Default true; pass false for stores that
169
169
  * haven't enabled gift cards (the widget itself is harmless either way —
170
170
  * an unknown code gets the generic refusal). */
171
- showGiftCards?: boolean
171
+ showGiftCards?: boolean
172
+ /** Show the security reassurance below the total. Default true. */
173
+ showSecureCheckout?: boolean
172
174
  /**
173
175
  * Receives every decorated cart returned by a summary mutation
174
176
  * (quantity change, promo apply, gift-card apply/remove). Hosts update
@@ -186,9 +188,10 @@ export function OrderSummary({
186
188
  onOptimisticShippingClear,
187
189
  optimisticMethodFee,
188
190
  onOptimisticMethodFeeClear,
189
- methodFeeLabel,
190
- showGiftCards = true,
191
- onCartChange,
191
+ methodFeeLabel,
192
+ showGiftCards = true,
193
+ showSecureCheckout = true,
194
+ onCartChange,
192
195
  }: OrderSummaryProps) {
193
196
  const labels = useCheckoutLabels()
194
197
 
@@ -377,7 +380,11 @@ export function OrderSummary({
377
380
  )}
378
381
  </div>
379
382
 
380
- <div className="mx-5 sm:mx-6 mt-4 pt-4 border-t border-border">
383
+ <div
384
+ className={`mx-5 sm:mx-6 mt-4 pt-4 border-t border-border ${
385
+ showSecureCheckout ? "" : "pb-5"
386
+ }`}
387
+ >
381
388
  <div className="flex justify-between items-center">
382
389
  <span className="text-lg font-bold text-foreground">
383
390
  {labels.total}
@@ -420,22 +427,24 @@ export function OrderSummary({
420
427
  )}
421
428
  </div>
422
429
 
423
- <div className="px-5 sm:px-6 pb-5 pt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
424
- <svg
425
- className="w-3.5 h-3.5"
426
- fill="none"
427
- viewBox="0 0 24 24"
428
- stroke="currentColor"
429
- strokeWidth={2}
430
- >
431
- <path
432
- strokeLinecap="round"
433
- strokeLinejoin="round"
434
- d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
435
- />
436
- </svg>
437
- <span>{labels.secureCheckout}</span>
438
- </div>
430
+ {showSecureCheckout && (
431
+ <div className="px-5 sm:px-6 pb-5 pt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
432
+ <svg
433
+ className="w-3.5 h-3.5"
434
+ fill="none"
435
+ viewBox="0 0 24 24"
436
+ stroke="currentColor"
437
+ strokeWidth={2}
438
+ >
439
+ <path
440
+ strokeLinecap="round"
441
+ strokeLinejoin="round"
442
+ d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
443
+ />
444
+ </svg>
445
+ <span>{labels.secureCheckout}</span>
446
+ </div>
447
+ )}
439
448
  </div>
440
449
  </div>
441
450
  )
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { PaymentElement } from "@stripe/react-stripe-js"
4
4
  import type { StripePaymentElementChangeEvent } from "@stripe/stripe-js"
5
- import { useContext, useEffect, useState } from "react"
6
-
7
- import { cn } from "../lib/utils"
5
+ import { useContext, useEffect, useState } from "react"
6
+
7
+ import type { StorePaymentMethodEntry } from "../api/checkout"
8
+ import { PaymentIcon, type PaymentMethod } from "../common/payment-icons"
9
+ import { cn } from "../lib/utils"
8
10
  import { useCheckoutLabels } from "./context"
9
11
  import { ErrorMessage } from "./error-message"
10
12
  import { PaymentButton } from "./payment-button"
@@ -34,9 +36,17 @@ import type { CheckoutLogError } from "./use-checkout-orchestration"
34
36
  type CheckoutPaymentMethodListProps = {
35
37
  /** Structural cart — forwarded to PaymentButton (`id`/`total`/`currency_code`). */
36
38
  cart: { id: string; total?: number | null; currency_code: string }
37
- hasCard: boolean
38
- hasCod: boolean
39
- paymentTab: "card" | "cod"
39
+ hasCard: boolean
40
+ hasCod: boolean
41
+ /** The merchant-configured method shown by the offline/COD tab. */
42
+ offlineMethod: StorePaymentMethodEntry | null
43
+ /** Platform-defined order after checkout rules have been applied. */
44
+ paymentMethodOrder: Array<"card" | "cod">
45
+ /** Card and wallet marks enabled for the connected payment provider. */
46
+ paymentMarks?: readonly PaymentMethod[]
47
+ /** Store-owned terms notice. Falls back to the locale's plain text. */
48
+ termsContent?: React.ReactNode
49
+ paymentTab: "card" | "cod"
40
50
  onPaymentTab: (tab: "card" | "cod") => void
41
51
  deliveryReady: boolean
42
52
  paymentError: string | null
@@ -117,9 +127,13 @@ type CheckoutPaymentMethodListProps = {
117
127
 
118
128
  export function CheckoutPaymentMethodList({
119
129
  cart,
120
- hasCard,
121
- hasCod,
122
- paymentTab,
130
+ hasCard,
131
+ hasCod,
132
+ offlineMethod,
133
+ paymentMethodOrder,
134
+ paymentMarks = [],
135
+ termsContent,
136
+ paymentTab,
123
137
  onPaymentTab,
124
138
  deliveryReady,
125
139
  paymentError,
@@ -138,7 +152,13 @@ export function CheckoutPaymentMethodList({
138
152
 
139
153
  // Which extra tab is active, if any. Selecting card/cod clears it; the
140
154
  // radio group stays single-select across built-in and extra tabs.
141
- const [extraTab, setExtraTab] = useState<string | null>(null)
155
+ const [extraTab, setExtraTab] = useState<string | null>(null)
156
+ const methodPosition = (method: "card" | "cod") => {
157
+ const position = paymentMethodOrder.indexOf(method)
158
+ return position === -1 ? paymentMethodOrder.length : position
159
+ }
160
+ const visiblePaymentMarks = paymentMarks.slice(0, 3)
161
+ const hiddenPaymentMarkCount = Math.max(0, paymentMarks.length - visiblePaymentMarks.length)
142
162
 
143
163
  // When the store opts out of gating (gatePaymentUntilDelivery=false),
144
164
  // the payment section is always shown + interactive regardless of
@@ -212,7 +232,9 @@ export function CheckoutPaymentMethodList({
212
232
  <h2 className="text-lg font-semibold text-foreground mb-1 tracking-tight">
213
233
  {labels.paymentMethod}
214
234
  </h2>
215
- <p className="text-xs text-muted-foreground mb-4">{labels.termsText}</p>
235
+ <p className="text-xs text-muted-foreground mb-4">
236
+ {termsContent ?? labels.termsText}
237
+ </p>
216
238
 
217
239
  {paymentGated ? (
218
240
  <div className="p-4 bg-muted rounded-lg border border-border">
@@ -239,7 +261,7 @@ export function CheckoutPaymentMethodList({
239
261
  // and tracking refs upstream are NOT torn down. That's the
240
262
  // whole point of moving away from PaymentWrapper-wraps-all.
241
263
  <StripeElementsScope passthrough>
242
- <div className="space-y-2">
264
+ <div className="flex flex-col gap-2">
243
265
  {nothingToPay && (
244
266
  // One line instead of a tender list. The shopper owes
245
267
  // nothing, so every radio here would be a question with no
@@ -250,8 +272,9 @@ export function CheckoutPaymentMethodList({
250
272
  </div>
251
273
  )}
252
274
  {!nothingToPay && hasCard && (
253
- <div
254
- className={cn(
275
+ <div
276
+ style={{ order: methodPosition("card") }}
277
+ className={cn(
255
278
  "rounded-lg border overflow-hidden transition-colors duration-150",
256
279
  paymentTab === "card" && !extraTab
257
280
  ? "border-primary bg-primary/5"
@@ -276,10 +299,22 @@ export function CheckoutPaymentMethodList({
276
299
  <div className="w-2 h-2 rounded-full bg-primary" />
277
300
  )}
278
301
  </div>
279
- <span className="text-sm font-medium text-foreground">
280
- {labels.payOnline}
281
- </span>
282
- </button>
302
+ <span className="text-sm font-medium text-foreground">
303
+ {labels.payOnline}
304
+ </span>
305
+ {visiblePaymentMarks.length > 0 && (
306
+ <span className="ml-auto flex items-center gap-1.5 pl-3">
307
+ {visiblePaymentMarks.map((method) => (
308
+ <PaymentIcon key={method} method={method} className="h-6" />
309
+ ))}
310
+ {hiddenPaymentMarkCount > 0 && (
311
+ <span className="flex h-6 min-w-8 items-center justify-center rounded border border-border bg-card px-1.5 text-xs font-medium text-foreground">
312
+ +{hiddenPaymentMarkCount}
313
+ </span>
314
+ )}
315
+ </span>
316
+ )}
317
+ </button>
283
318
 
284
319
  {paymentTab === "card" && !extraTab && (
285
320
  <div className="px-4 pb-4 pt-2">
@@ -329,8 +364,9 @@ export function CheckoutPaymentMethodList({
329
364
  )}
330
365
 
331
366
  {!nothingToPay && hasCod && (
332
- <div
333
- className={cn(
367
+ <div
368
+ style={{ order: methodPosition("cod") }}
369
+ className={cn(
334
370
  "rounded-lg border overflow-hidden transition-colors duration-150",
335
371
  paymentTab === "cod" && !extraTab
336
372
  ? "border-primary bg-primary/5"
@@ -355,23 +391,24 @@ export function CheckoutPaymentMethodList({
355
391
  <div className="w-2 h-2 rounded-full bg-primary" />
356
392
  )}
357
393
  </div>
358
- <span className="text-sm font-medium text-foreground">
359
- {labels.cashOnDelivery}
360
- </span>
394
+ <span className="text-sm font-medium text-foreground">
395
+ {offlineMethod?.name ?? labels.cashOnDelivery}
396
+ </span>
361
397
  </button>
362
398
  {paymentTab === "cod" && !extraTab && (
363
399
  <div className="px-4 pb-3 pt-0">
364
- <p className="text-xs text-muted-foreground ml-[30px]">
365
- {labels.codNote}
366
- </p>
400
+ <p className="text-xs text-muted-foreground ml-[30px]">
401
+ {offlineMethod?.instructions ?? labels.codNote}
402
+ </p>
367
403
  </div>
368
404
  )}
369
405
  </div>
370
406
  )}
371
407
 
372
- {!nothingToPay && extraTabs?.map((tab) => (
373
- <div
374
- key={tab.id}
408
+ {!nothingToPay && extraTabs?.map((tab, index) => (
409
+ <div
410
+ key={tab.id}
411
+ style={{ order: paymentMethodOrder.length + index }}
375
412
  className={cn(
376
413
  "rounded-lg border overflow-hidden transition-colors duration-150",
377
414
  extraTab === tab.id
@@ -177,10 +177,11 @@ export function PickupPointSelector({
177
177
  <div className="flex-1 min-w-0">
178
178
  <p className="text-sm font-medium text-foreground leading-tight">
179
179
  {point.name}
180
- </p>
181
- <p className="text-xs text-muted-foreground mt-0.5">
182
- {[point.address, point.city].filter(Boolean).join(", ")}
183
- </p>
180
+ </p>
181
+ <p className="text-xs text-muted-foreground mt-0.5">
182
+ {point.secondary_name ||
183
+ [point.address, point.city].filter(Boolean).join(", ")}
184
+ </p>
184
185
  </div>
185
186
 
186
187
  {distance !== null && distance > 0 && (
@@ -977,9 +977,37 @@ export function useCheckoutOrchestration({
977
977
  // The offline tab = a merchant METHOD (the COD switch, else the first
978
978
  // manual method) — sessions initiate by payment_method_id, provider NULL.
979
979
  const offlineMethod = findOfflineMethod(effectiveAvailablePaymentMethods)
980
- const hasCod = !!offlineMethod
981
- const codMethodId = offlineMethod?.payment_method_id
982
- const offlineIsCodKind = offlineMethod?.kind === "cod"
980
+ const hasCod = !!offlineMethod
981
+ const codMethodId = offlineMethod?.payment_method_id
982
+ const offlineIsCodKind = offlineMethod?.kind === "cod"
983
+
984
+ // Preserve the platform listing order all the way into the checkout UI.
985
+ // The API has already applied checkout_method_order; collapsing the rows
986
+ // into card/COD booleans used to throw that order away.
987
+ const paymentMethodOrder = useMemo(() => {
988
+ const order: Array<"card" | "cod"> = []
989
+ for (const entry of effectiveAvailablePaymentMethods ?? []) {
990
+ if (
991
+ hasCard &&
992
+ "id" in entry &&
993
+ entry.id === cardEntry?.id &&
994
+ !order.includes("card")
995
+ ) {
996
+ order.push("card")
997
+ }
998
+ if (
999
+ hasCod &&
1000
+ "payment_method_id" in entry &&
1001
+ entry.payment_method_id === codMethodId &&
1002
+ !order.includes("cod")
1003
+ ) {
1004
+ order.push("cod")
1005
+ }
1006
+ }
1007
+ if (hasCard && !order.includes("card")) order.push("card")
1008
+ if (hasCod && !order.includes("cod")) order.push("cod")
1009
+ return order
1010
+ }, [effectiveAvailablePaymentMethods, hasCard, cardEntry?.id, hasCod, codMethodId])
983
1011
 
984
1012
  // The store offers a card it cannot mount: reported once, to the merchant,
985
1013
  // because the shopper must never be the one who discovers it. Pre-2026-09-18
@@ -996,12 +1024,12 @@ export function useCheckoutOrchestration({
996
1024
  )
997
1025
  }, [cardEntry, cardOffer.mountable, logError])
998
1026
 
999
- // Default tab: card when available, else COD. The eager-session model
1000
- // used to seed from the cart's pending session provider; in the
1001
- // deferred-intent model there is no session at mount.
1002
- const [paymentTab, setPaymentTab] = useState<"card" | "cod">(
1003
- hasCard ? "card" : "cod"
1004
- )
1027
+ // Default tab: the first method in the merchant's checkout order. The eager-session model
1028
+ // used to seed from the cart's pending session provider; in the
1029
+ // deferred-intent model there is no session at mount.
1030
+ const [paymentTab, setPaymentTab] = useState<"card" | "cod">(
1031
+ paymentMethodOrder[0] ?? (hasCard ? "card" : "cod")
1032
+ )
1005
1033
 
1006
1034
  // Optimistic method-fee state. Painted instantly on tab toggle so the
1007
1035
  // totals row shows the predicted fee BEFORE the server applies it (at
@@ -1789,8 +1817,9 @@ export function useCheckoutOrchestration({
1789
1817
  selectedPickup,
1790
1818
 
1791
1819
  // Payment
1792
- paymentTab,
1793
- hasCard,
1820
+ paymentTab,
1821
+ paymentMethodOrder,
1822
+ hasCard,
1794
1823
  hasCod,
1795
1824
  cardId,
1796
1825
  /** The store's own Stripe, off the card entry — what `PaymentWrapper`