@cartbase/storefront 0.2.0 → 0.4.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.
@@ -1,196 +1,197 @@
1
- "use client"
2
-
3
- import type { AppliedGiftCard } from "../api/carts"
4
- import { convertToLocale } from "../lib/money"
5
- import { useCartDrawer } from "./context"
6
-
7
- /**
8
- * CartSummaryBreakdown — full totals breakdown for the drawer (or a cart
9
- * page): subtotal, discount, shipping, tax, COD fee, total, applied
10
- * gift-card tender and the remainder. Alternative to the compact
11
- * `CartStickyFooter` (which deliberately shows a product-only subtotal in
12
- * pre-checkout context).
13
- *
14
- * Ported from `@1click/ui/src/cart-drawer/summary-breakdown.tsx` (v2.3.1)
15
- * and EXPANDED for Cartbase: the source rendered a client-summed product
16
- * subtotal only; Cartbase's decorated cart carries server-computed totals on
17
- * every read (`subtotal`, `discount_total`, `shipping_total`, `tax_total`,
18
- * `cod_fee_total`/`cod_fee_label`, `gift_cards[]`, `gift_card_total`,
19
- * `gift_card_remainder`, `total` — all EUR major units), so this component
20
- * renders EXACTLY what the server returns and computes no money. The only
21
- * client-side transformation is the display sign on deductions.
22
- *
23
- * Row rules (see `selectSummaryRows`):
24
- * - discount / tax / COD-fee rows appear only when their total is > 0.
25
- * - COD fee renders the server's `cod_fee_label` (labels.codFee fallback).
26
- * - shipping renders the amount once a shipping method is set (0 → FREE);
27
- * before that it shows "calculated at checkout".
28
- * - gift-card tender renders one row per applied card (masked `last4`,
29
- * negative display) plus the `gift_card_remainder` row the amount the
30
- * remainder provider actually charges. A depleted card stays listed at 0
31
- * (server keeps it in `gift_cards` at amount 0).
32
- */
33
-
34
- /** The decorated-cart fields the breakdown consumes (structural subset). */
35
- export type SummaryCartTotals = {
36
- subtotal: number
37
- discount_total: number
38
- shipping_total: number
39
- tax_total: number
40
- total: number
41
- cod_fee_total?: number
42
- cod_fee_label?: string | null
43
- gift_cards?: AppliedGiftCard[]
44
- gift_card_total?: number
45
- gift_card_remainder?: number
46
- shipping_methods?: ReadonlyArray<unknown>
47
- }
48
-
49
- export type SummaryRow =
50
- | { kind: "subtotal"; amount: number }
51
- | { kind: "discount"; amount: number }
52
- | { kind: "shipping"; amount: number }
53
- | { kind: "shipping_pending" }
54
- | { kind: "tax"; amount: number }
55
- | { kind: "cod_fee"; amount: number; label: string | null }
56
- | { kind: "total"; amount: number }
57
- | { kind: "gift_card"; last4: string; amount: number }
58
- | { kind: "remainder"; amount: number }
59
-
60
- /**
61
- * Pure row selection over the decorated-cart totals — exported for unit
62
- * tests. Amounts pass through verbatim (server truth); deduction rows
63
- * (discount, gift cards) carry a negated amount for display only.
64
- */
65
- export function selectSummaryRows(cart: SummaryCartTotals): SummaryRow[] {
66
- const rows: SummaryRow[] = []
67
- rows.push({ kind: "subtotal", amount: cart.subtotal })
68
-
69
- if ((cart.discount_total ?? 0) > 0) {
70
- rows.push({ kind: "discount", amount: -cart.discount_total })
71
- }
72
-
73
- if (cart.shipping_methods && cart.shipping_methods.length > 0) {
74
- rows.push({ kind: "shipping", amount: cart.shipping_total })
75
- } else {
76
- rows.push({ kind: "shipping_pending" })
77
- }
78
-
79
- if ((cart.tax_total ?? 0) > 0) {
80
- rows.push({ kind: "tax", amount: cart.tax_total })
81
- }
82
-
83
- if ((cart.cod_fee_total ?? 0) > 0) {
84
- rows.push({
85
- kind: "cod_fee",
86
- amount: cart.cod_fee_total!,
87
- label: cart.cod_fee_label ?? null,
88
- })
89
- }
90
-
91
- rows.push({ kind: "total", amount: cart.total })
92
-
93
- if (cart.gift_cards && cart.gift_cards.length > 0) {
94
- for (const card of cart.gift_cards) {
95
- // `|| 0` normalizes -0 (a depleted card at amount 0) so Intl never
96
- // renders a stray "-€0.00".
97
- rows.push({ kind: "gift_card", last4: card.last4, amount: -card.amount || 0 })
98
- }
99
- rows.push({ kind: "remainder", amount: cart.gift_card_remainder ?? 0 })
100
- }
101
-
102
- return rows
103
- }
104
-
105
- export function CartSummaryBreakdown() {
106
- const { cart, labels } = useCartDrawer()
107
- if (!cart) return null
108
-
109
- const currencyCode = cart.currency_code
110
- const money = (amount: number) =>
111
- convertToLocale({ amount, currency_code: currencyCode })
112
- const rows = selectSummaryRows(cart)
113
-
114
- return (
115
- <div className="px-6 py-5 space-y-2">
116
- {rows.map((row, i) => {
117
- switch (row.kind) {
118
- case "subtotal":
119
- return (
120
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
121
- <span>{labels.subtotal}</span>
122
- <span className="tabular-nums">{money(row.amount)}</span>
123
- </div>
124
- )
125
- case "discount":
126
- return (
127
- <div key={i} className="flex justify-between items-center text-[13px] text-success">
128
- <span>{labels.discount}</span>
129
- <span className="tabular-nums">{money(row.amount)}</span>
130
- </div>
131
- )
132
- case "shipping":
133
- return (
134
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
135
- <span>{labels.shipping}</span>
136
- <span className="tabular-nums">
137
- {row.amount === 0 ? labels.free : money(row.amount)}
138
- </span>
139
- </div>
140
- )
141
- case "shipping_pending":
142
- return (
143
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
144
- <span>{labels.shipping}</span>
145
- <span>{labels.calculatedAtCheckout}</span>
146
- </div>
147
- )
148
- case "tax":
149
- return (
150
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
151
- <span>{labels.tax}</span>
152
- <span className="tabular-nums">{money(row.amount)}</span>
153
- </div>
154
- )
155
- case "cod_fee":
156
- return (
157
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
158
- <span>{row.label ?? labels.codFee}</span>
159
- <span className="tabular-nums">{money(row.amount)}</span>
160
- </div>
161
- )
162
- case "total":
163
- return (
164
- <div
165
- key={i}
166
- className="flex justify-between items-center pt-2 mt-1 border-t border-border"
167
- >
168
- <span className="text-[15px] font-bold text-foreground">
169
- {labels.total}
170
- </span>
171
- <span className="text-[17px] font-bold text-foreground tracking-tight tabular-nums">
172
- {money(row.amount)}
173
- </span>
174
- </div>
175
- )
176
- case "gift_card":
177
- return (
178
- <div key={i} className="flex justify-between items-center text-[13px] text-success">
179
- <span>
180
- {labels.giftCard} ••{row.last4}
181
- </span>
182
- <span className="tabular-nums">{money(row.amount)}</span>
183
- </div>
184
- )
185
- case "remainder":
186
- return (
187
- <div key={i} className="flex justify-between items-center text-[13px] font-semibold text-foreground">
188
- <span>{labels.remainingToPay}</span>
189
- <span className="tabular-nums">{money(row.amount)}</span>
190
- </div>
191
- )
192
- }
193
- })}
194
- </div>
195
- )
196
- }
1
+ "use client"
2
+
3
+ import type { AppliedGiftCard } from "../api/carts"
4
+ import { convertToLocale } from "../lib/money"
5
+ import { useCartDrawer } from "./context"
6
+
7
+ /**
8
+ * CartSummaryBreakdown — full totals breakdown for the drawer (or a cart
9
+ * page): subtotal, discount, shipping, tax, COD fee, total, applied
10
+ * gift-card tender and the remainder. Alternative to the compact
11
+ * `CartStickyFooter` (which deliberately shows a product-only subtotal in
12
+ * pre-checkout context).
13
+ *
14
+ * Ported from `@1click/ui/src/cart-drawer/summary-breakdown.tsx` (v2.3.1)
15
+ * and EXPANDED for Cartbase: the source rendered a client-summed product
16
+ * subtotal only; Cartbase's decorated cart carries server-computed totals on
17
+ * every read (`subtotal`, `discount_total`, `shipping_total`, `tax_total`,
18
+ * `payment_method_fee_total`/`payment_method_fee_label`, `gift_cards[]`, `gift_card_total`,
19
+ * `gift_card_remainder`, `total` — all EUR major units), so this component
20
+ * renders EXACTLY what the server returns and computes no money. The only
21
+ * client-side transformation is the display sign on deductions.
22
+ *
23
+ * Row rules (see `selectSummaryRows`):
24
+ * - discount / tax / method-fee rows appear only when their total is > 0.
25
+ * - the method fee renders the server's `payment_method_fee_label`
26
+ * (labels.paymentMethodFee fallback).
27
+ * - shipping renders the amount once a shipping method is set (0 → FREE);
28
+ * before that it shows "calculated at checkout".
29
+ * - gift-card tender renders one row per applied card (masked `last4`,
30
+ * negative display) plus the `gift_card_remainder` row the amount the
31
+ * remainder provider actually charges. A depleted card stays listed at 0
32
+ * (server keeps it in `gift_cards` at amount 0).
33
+ */
34
+
35
+ /** The decorated-cart fields the breakdown consumes (structural subset). */
36
+ export type SummaryCartTotals = {
37
+ subtotal: number
38
+ discount_total: number
39
+ shipping_total: number
40
+ tax_total: number
41
+ total: number
42
+ payment_method_fee_total?: number
43
+ payment_method_fee_label?: string | null
44
+ gift_cards?: AppliedGiftCard[]
45
+ gift_card_total?: number
46
+ gift_card_remainder?: number
47
+ shipping_methods?: ReadonlyArray<unknown>
48
+ }
49
+
50
+ export type SummaryRow =
51
+ | { kind: "subtotal"; amount: number }
52
+ | { kind: "discount"; amount: number }
53
+ | { kind: "shipping"; amount: number }
54
+ | { kind: "shipping_pending" }
55
+ | { kind: "tax"; amount: number }
56
+ | { kind: "payment_method_fee"; amount: number; label: string | null }
57
+ | { kind: "total"; amount: number }
58
+ | { kind: "gift_card"; last4: string; amount: number }
59
+ | { kind: "remainder"; amount: number }
60
+
61
+ /**
62
+ * Pure row selection over the decorated-cart totals exported for unit
63
+ * tests. Amounts pass through verbatim (server truth); deduction rows
64
+ * (discount, gift cards) carry a negated amount for display only.
65
+ */
66
+ export function selectSummaryRows(cart: SummaryCartTotals): SummaryRow[] {
67
+ const rows: SummaryRow[] = []
68
+ rows.push({ kind: "subtotal", amount: cart.subtotal })
69
+
70
+ if ((cart.discount_total ?? 0) > 0) {
71
+ rows.push({ kind: "discount", amount: -cart.discount_total })
72
+ }
73
+
74
+ if (cart.shipping_methods && cart.shipping_methods.length > 0) {
75
+ rows.push({ kind: "shipping", amount: cart.shipping_total })
76
+ } else {
77
+ rows.push({ kind: "shipping_pending" })
78
+ }
79
+
80
+ if ((cart.tax_total ?? 0) > 0) {
81
+ rows.push({ kind: "tax", amount: cart.tax_total })
82
+ }
83
+
84
+ if ((cart.payment_method_fee_total ?? 0) > 0) {
85
+ rows.push({
86
+ kind: "payment_method_fee",
87
+ amount: cart.payment_method_fee_total!,
88
+ label: cart.payment_method_fee_label ?? null,
89
+ })
90
+ }
91
+
92
+ rows.push({ kind: "total", amount: cart.total })
93
+
94
+ if (cart.gift_cards && cart.gift_cards.length > 0) {
95
+ for (const card of cart.gift_cards) {
96
+ // `|| 0` normalizes -0 (a depleted card at amount 0) so Intl never
97
+ // renders a stray "-€0.00".
98
+ rows.push({ kind: "gift_card", last4: card.last4, amount: -card.amount || 0 })
99
+ }
100
+ rows.push({ kind: "remainder", amount: cart.gift_card_remainder ?? 0 })
101
+ }
102
+
103
+ return rows
104
+ }
105
+
106
+ export function CartSummaryBreakdown() {
107
+ const { cart, labels } = useCartDrawer()
108
+ if (!cart) return null
109
+
110
+ const currencyCode = cart.currency_code
111
+ const money = (amount: number) =>
112
+ convertToLocale({ amount, currency_code: currencyCode })
113
+ const rows = selectSummaryRows(cart)
114
+
115
+ return (
116
+ <div className="px-6 py-5 space-y-2">
117
+ {rows.map((row, i) => {
118
+ switch (row.kind) {
119
+ case "subtotal":
120
+ return (
121
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
122
+ <span>{labels.subtotal}</span>
123
+ <span className="tabular-nums">{money(row.amount)}</span>
124
+ </div>
125
+ )
126
+ case "discount":
127
+ return (
128
+ <div key={i} className="flex justify-between items-center text-[13px] text-success">
129
+ <span>{labels.discount}</span>
130
+ <span className="tabular-nums">{money(row.amount)}</span>
131
+ </div>
132
+ )
133
+ case "shipping":
134
+ return (
135
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
136
+ <span>{labels.shipping}</span>
137
+ <span className="tabular-nums">
138
+ {row.amount === 0 ? labels.free : money(row.amount)}
139
+ </span>
140
+ </div>
141
+ )
142
+ case "shipping_pending":
143
+ return (
144
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
145
+ <span>{labels.shipping}</span>
146
+ <span>{labels.calculatedAtCheckout}</span>
147
+ </div>
148
+ )
149
+ case "tax":
150
+ return (
151
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
152
+ <span>{labels.tax}</span>
153
+ <span className="tabular-nums">{money(row.amount)}</span>
154
+ </div>
155
+ )
156
+ case "payment_method_fee":
157
+ return (
158
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
159
+ <span>{row.label ?? labels.paymentMethodFee}</span>
160
+ <span className="tabular-nums">{money(row.amount)}</span>
161
+ </div>
162
+ )
163
+ case "total":
164
+ return (
165
+ <div
166
+ key={i}
167
+ className="flex justify-between items-center pt-2 mt-1 border-t border-border"
168
+ >
169
+ <span className="text-[15px] font-bold text-foreground">
170
+ {labels.total}
171
+ </span>
172
+ <span className="text-[17px] font-bold text-foreground tracking-tight tabular-nums">
173
+ {money(row.amount)}
174
+ </span>
175
+ </div>
176
+ )
177
+ case "gift_card":
178
+ return (
179
+ <div key={i} className="flex justify-between items-center text-[13px] text-success">
180
+ <span>
181
+ {labels.giftCard} ••{row.last4}
182
+ </span>
183
+ <span className="tabular-nums">{money(row.amount)}</span>
184
+ </div>
185
+ )
186
+ case "remainder":
187
+ return (
188
+ <div key={i} className="flex justify-between items-center text-[13px] font-semibold text-foreground">
189
+ <span>{labels.remainingToPay}</span>
190
+ <span className="tabular-nums">{money(row.amount)}</span>
191
+ </div>
192
+ )
193
+ }
194
+ })}
195
+ </div>
196
+ )
197
+ }
@@ -13,7 +13,6 @@ import { CheckoutShippingMethodList } from "./shipping-method-list"
13
13
  import { PaymentWrapper } from "./payment-wrapper"
14
14
  import {
15
15
  useCheckoutOrchestration,
16
- type CheckoutCodConfig,
17
16
  type CheckoutLogError,
18
17
  type PaymentProviderLike,
19
18
  } from "./use-checkout-orchestration"
@@ -35,9 +34,9 @@ import {
35
34
  * - The listings arrive as SDK DTOs: `listShippingOptions` /
36
35
  * `listPaymentProviders` (both rule-filtered server-side) — fetch them
37
36
  * in the host and pass down (docs/storefront/checkout.md).
38
- * - `codConfig` = the integrations config `cod` block
39
- * (`getIntegrationsConfig(client).cod`) the COD fee prediction is
40
- * NEVER hardcoded (docs/storefront/integrations.md).
37
+ * - Fee prediction is NEVER hardcoded: the payment listing entries carry
38
+ * each method's own `fee_amount`/`fee_label` (the integrations `cod`
39
+ * block died 2026-08-11 — any method may carry a fee).
41
40
  * - `onCartChange` closes the loop for summary mutations: the decorated
42
41
  * cart returned by promo/gift-card/quantity calls is handed up so the
43
42
  * host updates its cart state (`router.refresh()` in an RSC app) AND
@@ -61,8 +60,6 @@ type CheckoutClientProps = {
61
60
  methods: PaymentProviderLike[] | null,
62
61
  selectedShippingOption: StoreShippingOption | null
63
62
  ) => PaymentProviderLike[] | null
64
- /** The integrations config `cod` block — drives the optimistic COD fee. */
65
- codConfig?: CheckoutCodConfig
66
63
  /** Show the gift-card widget in the summary (default true). */
67
64
  showGiftCards?: boolean
68
65
  logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
@@ -81,6 +78,12 @@ type CheckoutClientProps = {
81
78
  logError?: CheckoutLogError
82
79
  /** Receives every decorated cart returned by a summary mutation. */
83
80
  onCartChange?: (cart: Cart) => void
81
+ /**
82
+ * Extra payment tabs forwarded to CheckoutPaymentMethodList — the seam
83
+ * for embedded third-party providers rendered in the same radio-card
84
+ * style as card/cod. See the prop's JSDoc there.
85
+ */
86
+ extraPaymentTabs?: Array<{ id: string; label: string; content: React.ReactNode }>
84
87
  }
85
88
 
86
89
  export function CheckoutClient({
@@ -92,7 +95,6 @@ export function CheckoutClient({
92
95
  countryCode,
93
96
  countries,
94
97
  paymentMethodFilter,
95
- codConfig,
96
98
  showGiftCards = true,
97
99
  logoByFulfillmentOptionId,
98
100
  appearance,
@@ -102,6 +104,7 @@ export function CheckoutClient({
102
104
  resolveTrackingMetadata,
103
105
  logError,
104
106
  onCartChange,
107
+ extraPaymentTabs,
105
108
  }: CheckoutClientProps) {
106
109
  const o = useCheckoutOrchestration({
107
110
  client,
@@ -112,7 +115,6 @@ export function CheckoutClient({
112
115
  countryCode,
113
116
  countries,
114
117
  paymentMethodFilter,
115
- codConfig,
116
118
  orderConfirmedPath,
117
119
  onOrderPlaced,
118
120
  resolveTrackingMetadata,
@@ -195,6 +197,7 @@ export function CheckoutClient({
195
197
  }
196
198
  total={o.optimisticTotal}
197
199
  logError={logError}
200
+ extraTabs={extraPaymentTabs}
198
201
  />
199
202
  </div>
200
203
 
@@ -208,8 +211,8 @@ export function CheckoutClient({
208
211
  onOptimisticShippingClear={() =>
209
212
  o.setOptimisticShippingCost(null)
210
213
  }
211
- optimisticCodFee={o.optimisticCodFee}
212
- onOptimisticCodFeeClear={() => o.setOptimisticCodFee(null)}
214
+ optimisticMethodFee={o.optimisticMethodFee}
215
+ onOptimisticMethodFeeClear={() => o.setOptimisticMethodFee(null)}
213
216
  showGiftCards={showGiftCards}
214
217
  onCartChange={handleCartChange}
215
218
  />
@@ -45,7 +45,6 @@ export {
45
45
  useCheckoutOrchestration,
46
46
  resolveOrderConfirmedPath,
47
47
  type UseCheckoutOrchestrationOptions,
48
- type CheckoutCodConfig,
49
48
  type CheckoutLogError,
50
49
  type PaymentProviderLike,
51
50
  } from "./use-checkout-orchestration"
@@ -73,7 +73,7 @@ export const bulgarianCheckoutLabels: CheckoutLabels = {
73
73
  discount: "Отстъпка",
74
74
  discountCode: "Код за отстъпка",
75
75
  discountSub: "Спестете с промо код",
76
- codFee: "Такса наложен платеж",
76
+ paymentMethodFee: "Такса за плащане",
77
77
  recommended: "Препоръчано за вас",
78
78
  recommendedSoon: "Скоро тук",
79
79
  recommendedSoonSub: "Персонализирани предложения за вас",