@cartbase/storefront 0.1.2 → 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.
Files changed (106) hide show
  1. package/README.md +3 -3
  2. package/package.json +231 -226
  3. package/src/api/auth.ts +1 -1
  4. package/src/api/carts.ts +5 -3
  5. package/src/api/checkout.ts +91 -40
  6. package/src/api/consent.ts +2 -2
  7. package/src/api/integrations.ts +4 -17
  8. package/src/api/regions.ts +1 -1
  9. package/src/cart-drawer/context.tsx +4 -4
  10. package/src/cart-drawer/cross-sell-carousel.tsx +1 -1
  11. package/src/cart-drawer/cross-sell-sidebar.tsx +1 -1
  12. package/src/cart-drawer/header.tsx +1 -1
  13. package/src/cart-drawer/item/index.tsx +2 -2
  14. package/src/cart-drawer/item/quantity.tsx +2 -2
  15. package/src/cart-drawer/item/upsell.tsx +3 -3
  16. package/src/cart-drawer/item/variant.tsx +2 -2
  17. package/src/cart-drawer/labels-bg.ts +2 -2
  18. package/src/cart-drawer/labels.ts +119 -119
  19. package/src/cart-drawer/summary-breakdown.tsx +197 -196
  20. package/src/cart-drawer/template.tsx +2 -2
  21. package/src/cart-drawer/tiered-progress.tsx +1 -1
  22. package/src/checkout/address-error-copy.ts +3 -3
  23. package/src/checkout/address-form.tsx +2 -2
  24. package/src/checkout/address-select.tsx +1 -1
  25. package/src/checkout/boxnow-locker-selector.tsx +3 -3
  26. package/src/checkout/checkout-client.tsx +15 -12
  27. package/src/checkout/company-details.tsx +1 -1
  28. package/src/checkout/compare-addresses.ts +1 -1
  29. package/src/checkout/discount-section.tsx +5 -5
  30. package/src/checkout/econt-office-selector.tsx +2 -2
  31. package/src/checkout/gift-card-section.tsx +2 -2
  32. package/src/checkout/index.ts +0 -1
  33. package/src/checkout/labels-bg.ts +2 -2
  34. package/src/checkout/labels.ts +263 -263
  35. package/src/checkout/line-item-card.tsx +2 -2
  36. package/src/checkout/order-summary.tsx +48 -48
  37. package/src/checkout/payment-button.tsx +4 -4
  38. package/src/checkout/payment-error-copy.ts +6 -6
  39. package/src/checkout/payment-method-list.tsx +93 -33
  40. package/src/checkout/payment-wrapper.tsx +8 -11
  41. package/src/checkout/promotion-error-copy.ts +2 -2
  42. package/src/checkout/shipping-method-list.tsx +2 -2
  43. package/src/checkout/stripe-wrapper.tsx +1 -1
  44. package/src/checkout/use-checkout-orchestration.ts +108 -98
  45. package/src/common/cart-button-client.tsx +1 -1
  46. package/src/common/cart-button.tsx +1 -1
  47. package/src/common/country-select.tsx +2 -2
  48. package/src/common/delete-button.tsx +1 -1
  49. package/src/common/index.ts +1 -1
  50. package/src/common/language-select.tsx +2 -2
  51. package/src/common/localized-link.tsx +1 -1
  52. package/src/lib/cart-helpers.ts +2 -2
  53. package/src/lib/cookie-names.ts +41 -0
  54. package/src/lib/get-product-price.ts +3 -3
  55. package/src/lib/payment-constants.ts +53 -66
  56. package/src/lib/store-api-error.ts +1 -1
  57. package/src/order/index.ts +1 -1
  58. package/src/order/labels-bg.ts +39 -39
  59. package/src/order/labels.ts +79 -79
  60. package/src/order/order-address-card.tsx +1 -1
  61. package/src/order/order-completed-template.tsx +20 -12
  62. package/src/order/order-confirmation-header.tsx +1 -1
  63. package/src/order/order-delivery-card.tsx +3 -3
  64. package/src/order/order-item.tsx +2 -2
  65. package/src/order/order-items-list.tsx +3 -3
  66. package/src/order/order-payment-card.tsx +27 -36
  67. package/src/order/order-timeline.tsx +2 -2
  68. package/src/order/order-totals.tsx +250 -245
  69. package/src/platform/identity.ts +18 -0
  70. package/src/platform/index.ts +8 -0
  71. package/src/platform/metadata.ts +26 -0
  72. package/src/platform/platform-init.tsx +46 -0
  73. package/src/products/image-gallery.tsx +1 -1
  74. package/src/products/mobile-actions.tsx +1 -1
  75. package/src/products/option-select.tsx +1 -1
  76. package/src/products/preview-price.tsx +1 -1
  77. package/src/products/product-actions.tsx +6 -6
  78. package/src/products/product-info.tsx +1 -1
  79. package/src/products/product-preview.tsx +1 -1
  80. package/src/products/product-price.tsx +2 -2
  81. package/src/products/product-tabs.tsx +1 -1
  82. package/src/products/purchase-options.tsx +1 -1
  83. package/src/products/related-products.tsx +3 -3
  84. package/src/products/variant-matching.ts +4 -4
  85. package/src/reviews-ui/labels.ts +1 -1
  86. package/src/reviews-ui/photo-upload.tsx +1 -1
  87. package/src/reviews-ui/review-list.tsx +1 -1
  88. package/src/reviews-ui/review-widget.tsx +1 -1
  89. package/src/reviews-ui/review-wizard.tsx +1 -1
  90. package/src/reviews-ui/wizard-state.ts +1 -1
  91. package/src/store/category-template.tsx +2 -2
  92. package/src/store/collection-template.tsx +2 -2
  93. package/src/store/labels.ts +2 -2
  94. package/src/store/paginated-products.tsx +1 -1
  95. package/src/store/search-params.ts +1 -1
  96. package/src/store/search-template.tsx +1 -1
  97. package/src/tracking/consent-banner.tsx +1 -1
  98. package/src/tracking/consent-init.tsx +1 -1
  99. package/src/tracking/consent.ts +6 -6
  100. package/src/tracking/fbq.ts +2 -2
  101. package/src/tracking/get-tracking-attribution.ts +1 -1
  102. package/src/tracking/get-tracking-config.ts +2 -2
  103. package/src/tracking/index.ts +2 -2
  104. package/src/tracking/meta-pixel.tsx +1 -1
  105. package/src/tracking/rybbit.tsx +1 -1
  106. package/src/tracking/types.ts +6 -6
@@ -1,119 +1,119 @@
1
- /**
2
- * Cart drawer labels — default English copy.
3
- *
4
- * Every user-facing string in the cart drawer comes from a `CartDrawerLabels`
5
- * object. Stores override strings by passing a partial labels object to
6
- * `CartDrawerProvider`. English is the baseline; stores wanting Bulgarian,
7
- * German, etc. pass their own translations.
8
- *
9
- * Ported from `@1click/ui/src/cart-drawer/labels.ts` (v2.3.1). Barter
10
- * additions: the summary-breakdown row labels (`discount`, `tax`, `codFee`,
11
- * `giftCard`, `remainingToPay`) — the decorated cart carries
12
- * discount/tax/COD-fee/gift-card tender fields that the breakdown renders,
13
- * so the copy for those rows lives here too.
14
- */
15
-
16
- export type CartDrawerLabels = {
17
- // Header
18
- yourCart: string
19
- item: string
20
- items: string
21
- closeCart: string
22
-
23
- // Empty state
24
- emptyCartTitle: string
25
- emptyCartMessage: string
26
- browseProducts: string
27
-
28
- // Tiered progress
29
- awayFromFreeShipping: string
30
- allRewardsUnlocked: string
31
-
32
- // Gift wrap / notes
33
- addGiftWrap: string
34
- giftNote: string
35
- orderNote: string
36
- giftNotePlaceholder: string
37
- orderNotePlaceholder: string
38
-
39
- // Rewards
40
- estimatedRewardPoints: string
41
-
42
- // Cross-sell
43
- pairsWellWith: string
44
- youllLoveThis: string
45
- youMightAlsoLike: string
46
- addToCart: string
47
-
48
- // Summary / footer
49
- subtotal: string
50
- taxAndShipping: string
51
- shipping: string
52
- free: string
53
- calculatedAtCheckout: string
54
- total: string
55
- secureCheckout: string
56
- shippingAndTaxCalculatedAtCheckout: string
57
-
58
- // Summary breakdown rows (barter decorated-cart fields)
59
- discount: string
60
- tax: string
61
- /** Fallback only — the server's `cod_fee_label` wins when present. */
62
- codFee: string
63
- /** Prefix for an applied gift-card row; rendered as `<giftCard> ••1234`. */
64
- giftCard: string
65
- /** Label for `gift_card_remainder` — what the remainder provider charges. */
66
- remainingToPay: string
67
-
68
- // Continue shopping
69
- continueShopping: string
70
-
71
- // A11y / buttons
72
- remove: string
73
- }
74
-
75
- export const defaultCartDrawerLabels: CartDrawerLabels = {
76
- yourCart: "Your cart",
77
- item: "item",
78
- items: "items",
79
- closeCart: "Close cart",
80
-
81
- emptyCartTitle: "Your cart is empty",
82
- emptyCartMessage: "You haven't added any products yet.",
83
- browseProducts: "Browse products",
84
-
85
- awayFromFreeShipping: "away from free shipping",
86
- allRewardsUnlocked: "All rewards unlocked",
87
-
88
- addGiftWrap: "Add gift wrapping",
89
- giftNote: "Gift note",
90
- orderNote: "Order note",
91
- giftNotePlaceholder: "Add a personal message...",
92
- orderNotePlaceholder: "Special instructions for this order...",
93
-
94
- estimatedRewardPoints: "Estimated reward points",
95
-
96
- pairsWellWith: "Pairs well with",
97
- youllLoveThis: "You'll love this",
98
- youMightAlsoLike: "You might also like",
99
- addToCart: "Add",
100
-
101
- subtotal: "Subtotal",
102
- taxAndShipping: "VAT included. Shipping calculated at checkout.",
103
- shipping: "Shipping",
104
- free: "FREE",
105
- calculatedAtCheckout: "Calculated at checkout",
106
- total: "Total",
107
- secureCheckout: "Checkout",
108
- shippingAndTaxCalculatedAtCheckout: "Shipping and tax calculated at checkout",
109
-
110
- discount: "Discount",
111
- tax: "VAT",
112
- codFee: "Cash on delivery fee",
113
- giftCard: "Gift card",
114
- remainingToPay: "Remaining to pay",
115
-
116
- continueShopping: "Continue shopping",
117
-
118
- remove: "Remove",
119
- }
1
+ /**
2
+ * Cart drawer labels — default English copy.
3
+ *
4
+ * Every user-facing string in the cart drawer comes from a `CartDrawerLabels`
5
+ * object. Stores override strings by passing a partial labels object to
6
+ * `CartDrawerProvider`. English is the baseline; stores wanting Bulgarian,
7
+ * German, etc. pass their own translations.
8
+ *
9
+ * Ported from `@1click/ui/src/cart-drawer/labels.ts` (v2.3.1). Cartbase
10
+ * additions: the summary-breakdown row labels (`discount`, `tax`, `paymentMethodFee`,
11
+ * `giftCard`, `remainingToPay`) — the decorated cart carries
12
+ * discount/tax/COD-fee/gift-card tender fields that the breakdown renders,
13
+ * so the copy for those rows lives here too.
14
+ */
15
+
16
+ export type CartDrawerLabels = {
17
+ // Header
18
+ yourCart: string
19
+ item: string
20
+ items: string
21
+ closeCart: string
22
+
23
+ // Empty state
24
+ emptyCartTitle: string
25
+ emptyCartMessage: string
26
+ browseProducts: string
27
+
28
+ // Tiered progress
29
+ awayFromFreeShipping: string
30
+ allRewardsUnlocked: string
31
+
32
+ // Gift wrap / notes
33
+ addGiftWrap: string
34
+ giftNote: string
35
+ orderNote: string
36
+ giftNotePlaceholder: string
37
+ orderNotePlaceholder: string
38
+
39
+ // Rewards
40
+ estimatedRewardPoints: string
41
+
42
+ // Cross-sell
43
+ pairsWellWith: string
44
+ youllLoveThis: string
45
+ youMightAlsoLike: string
46
+ addToCart: string
47
+
48
+ // Summary / footer
49
+ subtotal: string
50
+ taxAndShipping: string
51
+ shipping: string
52
+ free: string
53
+ calculatedAtCheckout: string
54
+ total: string
55
+ secureCheckout: string
56
+ shippingAndTaxCalculatedAtCheckout: string
57
+
58
+ // Summary breakdown rows (Cartbase decorated-cart fields)
59
+ discount: string
60
+ tax: string
61
+ /** Fallback only — the server's `payment_method_fee_label` wins when present. */
62
+ paymentMethodFee: string
63
+ /** Prefix for an applied gift-card row; rendered as `<giftCard> ••1234`. */
64
+ giftCard: string
65
+ /** Label for `gift_card_remainder` — what the remainder provider charges. */
66
+ remainingToPay: string
67
+
68
+ // Continue shopping
69
+ continueShopping: string
70
+
71
+ // A11y / buttons
72
+ remove: string
73
+ }
74
+
75
+ export const defaultCartDrawerLabels: CartDrawerLabels = {
76
+ yourCart: "Your cart",
77
+ item: "item",
78
+ items: "items",
79
+ closeCart: "Close cart",
80
+
81
+ emptyCartTitle: "Your cart is empty",
82
+ emptyCartMessage: "You haven't added any products yet.",
83
+ browseProducts: "Browse products",
84
+
85
+ awayFromFreeShipping: "away from free shipping",
86
+ allRewardsUnlocked: "All rewards unlocked",
87
+
88
+ addGiftWrap: "Add gift wrapping",
89
+ giftNote: "Gift note",
90
+ orderNote: "Order note",
91
+ giftNotePlaceholder: "Add a personal message...",
92
+ orderNotePlaceholder: "Special instructions for this order...",
93
+
94
+ estimatedRewardPoints: "Estimated reward points",
95
+
96
+ pairsWellWith: "Pairs well with",
97
+ youllLoveThis: "You'll love this",
98
+ youMightAlsoLike: "You might also like",
99
+ addToCart: "Add",
100
+
101
+ subtotal: "Subtotal",
102
+ taxAndShipping: "VAT included. Shipping calculated at checkout.",
103
+ shipping: "Shipping",
104
+ free: "FREE",
105
+ calculatedAtCheckout: "Calculated at checkout",
106
+ total: "Total",
107
+ secureCheckout: "Checkout",
108
+ shippingAndTaxCalculatedAtCheckout: "Shipping and tax calculated at checkout",
109
+
110
+ discount: "Discount",
111
+ tax: "VAT",
112
+ paymentMethodFee: "Payment method fee",
113
+ giftCard: "Gift card",
114
+ remainingToPay: "Remaining to pay",
115
+
116
+ continueShopping: "Continue shopping",
117
+
118
+ remove: "Remove",
119
+ }
@@ -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 barter: the source rendered a client-summed product
16
- * subtotal only; barter'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
+ }