@cartbase/storefront 0.2.0 → 0.3.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.
@@ -19,12 +19,12 @@ import { GiftCardSection } from "./gift-card-section"
19
19
  * Ported from `@1click/ui/src/checkout/order-summary.tsx` (v2.3.1) with
20
20
  * the Cartbase data seam:
21
21
  *
22
- * - COD fee is CART-LEVEL decoration on the Cartbase wire —
23
- * `cart.cod_fee_total` / `cart.cod_fee_label`, folded into `cart.total`
24
- * while a live pp_cod session exists (docs/storefront/checkout.md) —
25
- * NOT a metadata-flagged fee line item. The fee row + optimistic math
26
- * read those fields; `isProductLine` is kept as a defensive filter for
27
- * any legacy fee-line carts.
22
+ * - The payment method fee is CART-LEVEL decoration on the Cartbase wire —
23
+ * `cart.payment_method_fee_total` / `payment_method_fee_label`, folded
24
+ * into `cart.total` while a live method session exists
25
+ * (docs/storefront/checkout.md) — NOT a metadata-flagged fee line item.
26
+ * The fee row + optimistic math read those fields; `isProductLine` is
27
+ * kept as a defensive filter for any legacy fee-line carts.
28
28
  * - Gift-card tender rows (Cartbase feature): `gift_card_total` renders as
29
29
  * a negative tender row and `gift_card_remainder` as "Remaining to pay"
30
30
  * UNDER the (unchanged) total — tender never moves totals
@@ -211,22 +211,22 @@ type OrderSummaryProps = {
211
211
  optimisticShippingCost: number | null
212
212
  onOptimisticShippingClear?: () => void
213
213
  /**
214
- * Optimistic COD-fee prediction. Three values:
214
+ * Optimistic method-fee prediction. Three values:
215
215
  * - null → no prediction; render whatever the cart says.
216
- * - 0 → predict no fee (toggling away from COD).
216
+ * - 0 → predict no fee (toggling to a fee-less tender).
217
217
  * - positive → predict the fee at this amount.
218
- * Pass `useCheckoutOrchestration`'s `optimisticCodFee`. Needed because
219
- * the deferred-checkout architecture defers the pp_cod session (and so
220
- * the native fee) until Buy click.
218
+ * Pass `useCheckoutOrchestration`'s `optimisticMethodFee`. Needed because
219
+ * the deferred-checkout architecture defers the method session (and so
220
+ * the fee) until Buy click.
221
221
  */
222
- optimisticCodFee?: number | null
223
- onOptimisticCodFeeClear?: () => void
222
+ optimisticMethodFee?: number | null
223
+ onOptimisticMethodFeeClear?: () => void
224
224
  /**
225
- * Override label for the cash-on-delivery fee row. Optional — falls
226
- * back to the cart's server-provided `cod_fee_label` (admin-configured
227
- * on the COD integration), then to `labels.codFee`.
225
+ * Override label for the payment-method fee row. Optional — falls back
226
+ * to the cart's server-provided `payment_method_fee_label` (the method's
227
+ * own fee_label), then to `labels.paymentMethodFee`.
228
228
  */
229
- codFeeLabel?: string
229
+ methodFeeLabel?: string
230
230
  /** Show the gift-card widget. Default true; pass false for stores that
231
231
  * haven't enabled gift cards (the widget itself is harmless either way —
232
232
  * an unknown code gets the generic refusal). */
@@ -246,9 +246,9 @@ export function OrderSummary({
246
246
  cart,
247
247
  optimisticShippingCost,
248
248
  onOptimisticShippingClear,
249
- optimisticCodFee,
250
- onOptimisticCodFeeClear,
251
- codFeeLabel,
249
+ optimisticMethodFee,
250
+ onOptimisticMethodFeeClear,
251
+ methodFeeLabel,
252
252
  showGiftCards = true,
253
253
  onCartChange,
254
254
  }: OrderSummaryProps) {
@@ -268,39 +268,39 @@ export function OrderSummary({
268
268
  optimisticShippingCost !== null ? optimisticShippingCost : cart.shipping_total
269
269
  const shippingKnown = shippingCost !== null && shippingCost !== undefined
270
270
 
271
- // Effective COD fee: optimistic prediction wins until the cart catches
272
- // up. Three states (null / 0 / positive). Cartbase server truth is
273
- // `cart.cod_fee_total` (folded into cart.total while a pp_cod session
274
- // lives — docs/storefront/checkout.md).
275
- const realCodFeeAmount = cart.cod_fee_total ?? 0
276
- const effectiveCodFee =
277
- optimisticCodFee !== null && optimisticCodFee !== undefined
278
- ? optimisticCodFee
279
- : realCodFeeAmount
271
+ // Effective method fee: optimistic prediction wins until the cart
272
+ // catches up. Three states (null / 0 / positive). Server truth is
273
+ // `cart.payment_method_fee_total` (folded into cart.total while a live
274
+ // method session exists — docs/storefront/checkout.md).
275
+ const realMethodFeeAmount = cart.payment_method_fee_total ?? 0
276
+ const effectiveMethodFee =
277
+ optimisticMethodFee !== null && optimisticMethodFee !== undefined
278
+ ? optimisticMethodFee
279
+ : realMethodFeeAmount
280
280
 
281
281
  const displayTotal = useMemo(() => {
282
282
  let total = cart.total ?? 0
283
283
  if (optimisticShippingCost !== null) {
284
284
  total = total - (cart.shipping_total ?? 0) + optimisticShippingCost
285
285
  }
286
- if (optimisticCodFee !== null && optimisticCodFee !== undefined) {
287
- total = total - realCodFeeAmount + effectiveCodFee
286
+ if (optimisticMethodFee !== null && optimisticMethodFee !== undefined) {
287
+ total = total - realMethodFeeAmount + effectiveMethodFee
288
288
  }
289
289
  return total
290
290
  }, [
291
291
  cart.total,
292
292
  cart.shipping_total,
293
293
  optimisticShippingCost,
294
- optimisticCodFee,
295
- effectiveCodFee,
296
- realCodFeeAmount,
294
+ optimisticMethodFee,
295
+ effectiveMethodFee,
296
+ realMethodFeeAmount,
297
297
  ])
298
298
 
299
- // Subtotal: Cartbase's `item_total` is products-only (the COD fee is not a
299
+ // Subtotal: Cartbase's `item_total` is products-only (the fee is not a
300
300
  // line item), so no fee subtraction is needed — direct server truth.
301
301
  const productSubtotal = cart.item_total ?? 0
302
302
 
303
- const codFeeAmount = effectiveCodFee
303
+ const methodFeeAmount = effectiveMethodFee
304
304
 
305
305
  // Gift-card tender decoration — SERVER truth, rendered as-is. Totals
306
306
  // never move; the tender rows sit under the total.
@@ -317,13 +317,13 @@ export function OrderSummary({
317
317
  }
318
318
  }, [cart.shipping_total, optimisticShippingCost, onOptimisticShippingClear])
319
319
 
320
- // Clear optimistic COD fee once the cart's decoration matches the
321
- // prediction (Cartbase: cod_fee_total, not a fee line item).
320
+ // Clear the optimistic fee once the cart's decoration matches the
321
+ // prediction (payment_method_fee_total, not a fee line item).
322
322
  useEffect(() => {
323
- if (optimisticCodFee === null || optimisticCodFee === undefined) return
324
- const synced = realCodFeeAmount === optimisticCodFee
325
- if (synced) onOptimisticCodFeeClear?.()
326
- }, [optimisticCodFee, realCodFeeAmount, onOptimisticCodFeeClear])
323
+ if (optimisticMethodFee === null || optimisticMethodFee === undefined) return
324
+ const synced = realMethodFeeAmount === optimisticMethodFee
325
+ if (synced) onOptimisticMethodFeeClear?.()
326
+ }, [optimisticMethodFee, realMethodFeeAmount, onOptimisticMethodFeeClear])
327
327
 
328
328
  return (
329
329
  <div>
@@ -404,13 +404,13 @@ export function OrderSummary({
404
404
  )}
405
405
  </div>
406
406
 
407
- {codFeeAmount > 0 && (
407
+ {methodFeeAmount > 0 && (
408
408
  <div className="flex justify-between text-sm">
409
409
  <span className="text-muted-foreground">
410
- {codFeeLabel ?? cart.cod_fee_label ?? labels.codFee}
410
+ {methodFeeLabel ?? cart.payment_method_fee_label ?? labels.paymentMethodFee}
411
411
  </span>
412
412
  <DualPrice
413
- amount={codFeeAmount}
413
+ amount={methodFeeAmount}
414
414
  currencyCode={cart.currency_code}
415
415
  className="font-medium text-foreground"
416
416
  />
@@ -21,13 +21,14 @@ import type { CheckoutLogError } from "./use-checkout-orchestration"
21
21
  * Ported from `@1click/ui/src/checkout/payment-method-list.tsx` (v2.3.1)
22
22
  * with the Cartbase data seam: the `cart` prop is structural, and the
23
23
  * `logCheckoutError` Supabase writer becomes the optional `logError`
24
- * callback. Provider semantics on the Cartbase wire: the "card" tab is
25
- * `pp_stripe`, the "cod" tab is `pp_cod` (native fee via the integrations
26
- * `cod` block) or `pp_manual` (offline, no fee) the orchestration hook
27
- * resolves the ids (`hasCard`/`hasCod`/`cardId`/`codId`); both listings
28
- * arrive rule-filtered server-side (`checkout.listPaymentProviders`,
29
- * docs/storefront/checkout.md). COD stays a separate rail — it's a plain
30
- * provider session, not a Stripe payment method.
24
+ * callback. Tender semantics on the Cartbase wire (pp_* kill): the "card"
25
+ * tab is the connected processor (`pp_stripe`); the "cod" tab is a
26
+ * merchant METHOD the COD switch (native fee via the integrations `cod`
27
+ * block) or a manual method (no fee) the orchestration hook resolves
28
+ * them (`hasCard`/`hasCod`/`cardId`/`codMethodId`); both listings arrive
29
+ * rule-filtered server-side (`checkout.listPaymentProviders`,
30
+ * docs/storefront/checkout.md). The method tab stays a separate rail
31
+ * it's a provider-less session, not a Stripe payment method.
31
32
  */
32
33
 
33
34
  type CheckoutPaymentMethodListProps = {
@@ -87,6 +88,15 @@ type CheckoutPaymentMethodListProps = {
87
88
  total?: number
88
89
  /** Operational-visibility sink (successor of @1click's logCheckoutError). */
89
90
  logError?: CheckoutLogError
91
+ /**
92
+ * Additional payment tabs rendered as radio cards in the same visual
93
+ * language as the built-in card/cod tabs. The seam for embedded
94
+ * third-party providers (a myPOS-style frame carries its own pay
95
+ * action, so while an extra tab is active the PaymentButton is hidden
96
+ * and the tab's `content` owns completion). Absent = exact previous
97
+ * behavior.
98
+ */
99
+ extraTabs?: Array<{ id: string; label: string; content: React.ReactNode }>
90
100
  }
91
101
 
92
102
  export function CheckoutPaymentMethodList({
@@ -105,9 +115,14 @@ export function CheckoutPaymentMethodList({
105
115
  beforePaymentButton,
106
116
  total,
107
117
  logError,
118
+ extraTabs,
108
119
  }: CheckoutPaymentMethodListProps) {
109
120
  const labels = useCheckoutLabels()
110
121
 
122
+ // Which extra tab is active, if any. Selecting card/cod clears it; the
123
+ // radio group stays single-select across built-in and extra tabs.
124
+ const [extraTab, setExtraTab] = useState<string | null>(null)
125
+
111
126
  // When the store opts out of gating (gatePaymentUntilDelivery=false),
112
127
  // the payment section is always shown + interactive regardless of
113
128
  // delivery readiness. The Buy button remains independently gated by
@@ -203,23 +218,26 @@ export function CheckoutPaymentMethodList({
203
218
  <div
204
219
  className={cn(
205
220
  "rounded-lg border overflow-hidden transition-colors duration-150",
206
- paymentTab === "card"
221
+ paymentTab === "card" && !extraTab
207
222
  ? "border-primary bg-primary/5"
208
223
  : "border-border bg-card hover:border-muted-foreground"
209
224
  )}
210
225
  >
211
226
  <button
212
227
  type="button"
213
- onClick={() => onPaymentTab("card")}
228
+ onClick={() => {
229
+ setExtraTab(null)
230
+ onPaymentTab("card")
231
+ }}
214
232
  className="flex items-center w-full px-4 py-3.5 text-left"
215
233
  >
216
234
  <div
217
235
  className={cn(
218
236
  "w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 mr-3 transition-colors",
219
- paymentTab === "card" ? "border-primary" : "border-border"
237
+ paymentTab === "card" && !extraTab ? "border-primary" : "border-border"
220
238
  )}
221
239
  >
222
- {paymentTab === "card" && (
240
+ {paymentTab === "card" && !extraTab && (
223
241
  <div className="w-2 h-2 rounded-full bg-primary" />
224
242
  )}
225
243
  </div>
@@ -228,7 +246,7 @@ export function CheckoutPaymentMethodList({
228
246
  </span>
229
247
  </button>
230
248
 
231
- {paymentTab === "card" && (
249
+ {paymentTab === "card" && !extraTab && (
232
250
  <div className="px-4 pb-4 pt-2">
233
251
  {/*
234
252
  Skeleton vs PaymentElement gated by stripeReady.
@@ -279,23 +297,26 @@ export function CheckoutPaymentMethodList({
279
297
  <div
280
298
  className={cn(
281
299
  "rounded-lg border overflow-hidden transition-colors duration-150",
282
- paymentTab === "cod"
300
+ paymentTab === "cod" && !extraTab
283
301
  ? "border-primary bg-primary/5"
284
302
  : "border-border bg-card hover:border-muted-foreground"
285
303
  )}
286
304
  >
287
305
  <button
288
306
  type="button"
289
- onClick={() => onPaymentTab("cod")}
307
+ onClick={() => {
308
+ setExtraTab(null)
309
+ onPaymentTab("cod")
310
+ }}
290
311
  className="flex items-center w-full px-4 py-3.5 text-left"
291
312
  >
292
313
  <div
293
314
  className={cn(
294
315
  "w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 mr-3 transition-colors",
295
- paymentTab === "cod" ? "border-primary" : "border-border"
316
+ paymentTab === "cod" && !extraTab ? "border-primary" : "border-border"
296
317
  )}
297
318
  >
298
- {paymentTab === "cod" && (
319
+ {paymentTab === "cod" && !extraTab && (
299
320
  <div className="w-2 h-2 rounded-full bg-primary" />
300
321
  )}
301
322
  </div>
@@ -303,7 +324,7 @@ export function CheckoutPaymentMethodList({
303
324
  {labels.cashOnDelivery}
304
325
  </span>
305
326
  </button>
306
- {paymentTab === "cod" && (
327
+ {paymentTab === "cod" && !extraTab && (
307
328
  <div className="px-4 pb-3 pt-0">
308
329
  <p className="text-xs text-muted-foreground ml-[30px]">
309
330
  {labels.codNote}
@@ -312,6 +333,41 @@ export function CheckoutPaymentMethodList({
312
333
  )}
313
334
  </div>
314
335
  )}
336
+
337
+ {extraTabs?.map((tab) => (
338
+ <div
339
+ key={tab.id}
340
+ className={cn(
341
+ "rounded-lg border overflow-hidden transition-colors duration-150",
342
+ extraTab === tab.id
343
+ ? "border-primary bg-primary/5"
344
+ : "border-border bg-card hover:border-muted-foreground"
345
+ )}
346
+ >
347
+ <button
348
+ type="button"
349
+ onClick={() => setExtraTab(tab.id)}
350
+ className="flex items-center w-full px-4 py-3.5 text-left"
351
+ >
352
+ <div
353
+ className={cn(
354
+ "w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 mr-3 transition-colors",
355
+ extraTab === tab.id ? "border-primary" : "border-border"
356
+ )}
357
+ >
358
+ {extraTab === tab.id && (
359
+ <div className="w-2 h-2 rounded-full bg-primary" />
360
+ )}
361
+ </div>
362
+ <span className="text-sm font-medium text-foreground">
363
+ {tab.label}
364
+ </span>
365
+ </button>
366
+ {extraTab === tab.id && (
367
+ <div className="px-4 pb-4 pt-2">{tab.content}</div>
368
+ )}
369
+ </div>
370
+ ))}
315
371
  </div>
316
372
 
317
373
  <ErrorMessage
@@ -323,7 +379,7 @@ export function CheckoutPaymentMethodList({
323
379
  <div className="mt-5">{beforePaymentButton}</div>
324
380
  )}
325
381
 
326
- {buyButtonNotReady && buyButtonNotReadyReason && (
382
+ {!extraTab && buyButtonNotReady && buyButtonNotReadyReason && (
327
383
  <div className="mt-4 flex items-center justify-center gap-1.5 text-muted-foreground">
328
384
  <svg
329
385
  viewBox="0 0 24 24"
@@ -344,20 +400,24 @@ export function CheckoutPaymentMethodList({
344
400
  </div>
345
401
  )}
346
402
 
347
- <div className="mt-5">
348
- <PaymentButton
349
- cart={cart}
350
- paymentTab={paymentTab}
351
- notReady={buyButtonNotReady}
352
- performBuyClick={performBuyClick}
353
- paymentElementComplete={
354
- paymentTab === "cod" ? true : paymentElementComplete
355
- }
356
- total={total}
357
- logError={logError}
358
- data-testid="submit-order-button"
359
- />
360
- </div>
403
+ {/* An active extra tab owns its own pay action (embedded
404
+ provider frames ship their own button), so ours hides. */}
405
+ {!extraTab && (
406
+ <div className="mt-5">
407
+ <PaymentButton
408
+ cart={cart}
409
+ paymentTab={paymentTab}
410
+ notReady={buyButtonNotReady}
411
+ performBuyClick={performBuyClick}
412
+ paymentElementComplete={
413
+ paymentTab === "cod" ? true : paymentElementComplete
414
+ }
415
+ total={total}
416
+ logError={logError}
417
+ data-testid="submit-order-button"
418
+ />
419
+ </div>
420
+ )}
361
421
  </StripeElementsScope>
362
422
  )}
363
423
  </div>