@cartbase/storefront 0.20.0 → 0.21.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/LICENSE +21 -21
- package/package.json +4 -1
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +181 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +56 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/payment-badges.tsx +36 -96
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/icons/cartbase-mark.ts +9 -0
- package/src/common/icons/payment-marks.ts +73 -0
- package/src/common/icons/social-marks.ts +61 -0
- package/src/common/index.ts +35 -20
- package/src/common/market-select.tsx +57 -57
- package/src/common/payment-icons.tsx +54 -0
- package/src/common/powered-by-cartbase.tsx +47 -0
- package/src/common/social-links.tsx +65 -0
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +3 -0
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -0
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/store/labels.ts +10 -0
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
|
@@ -1,250 +1,250 @@
|
|
|
1
|
-
import { Price } from "../lib/price"
|
|
2
|
-
import { findFeeLine, type LineLike } from "../lib/cart-helpers"
|
|
3
|
-
import { defaultOrderLabels, type OrderLabels } from "./labels"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Order totals breakdown. Ported from `@1click/ui/src/order/order-totals.tsx`
|
|
7
|
-
* (v2.3.1) with the Cartbase money seam:
|
|
8
|
-
*
|
|
9
|
-
* Totals are SERVER truth and the Cartbase store order read
|
|
10
|
-
* (`StoreOrderDetail`) carries NONE of them — they live where the server
|
|
11
|
-
* computed them: the decorated cart the app holds on the confirmation page
|
|
12
|
-
* (`api/carts` `Cart`: `item_subtotal`, `shipping_subtotal`,
|
|
13
|
-
* `discount_total`, `tax_total`, `total`, `payment_method_fee_total`, `payment_method_fee_label`)
|
|
14
|
-
* or the order's summary snapshot (`order_summaries.totals` — reachable via
|
|
15
|
-
* `completeCart().order.summary`). The component takes a structural
|
|
16
|
-
* `OrderTotalsSource` both of those satisfy (`orderTotalsFromSummary()`
|
|
17
|
-
* adapts the summary shape) and only SELECTS + FORMATS rows — it never
|
|
18
|
-
* computes money beyond the two production subtractions documented below.
|
|
19
|
-
*
|
|
20
|
-
* Method-fee display, in preference order (production fix preserved):
|
|
21
|
-
* 1. Cartbase-native fee: `payment_method_fee_total` > 0 (never a line
|
|
22
|
-
* item; already folded into `total` server-side), labeled by the
|
|
23
|
-
* merchant-editable `payment_method_fee_label`. Pre-rename summaries
|
|
24
|
-
* (2026-08-11) spelled the keys cod_fee_* — read as legacy fallback.
|
|
25
|
-
* 2. legacy Medusa-era fee LINE ITEM (`metadata.is_cod_fee`) found in
|
|
26
|
-
* `items` — its net is subtracted from the visible subtotal so the
|
|
27
|
-
* Subtotal row stays product-only while Tax keeps the fee's tax
|
|
28
|
-
* portion (v2.3.1 fix).
|
|
29
|
-
* 3. label fallbacks: `methodFeeLabel` prop → source label →
|
|
30
|
-
* fee line's own title → translated `labels.paymentMethodFee`.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
/** Structural totals source — `api/carts` `Cart` satisfies it directly. */
|
|
34
|
-
export interface OrderTotalsSource {
|
|
35
|
-
/** Product-only net subtotal (cart path). Preferred. */
|
|
36
|
-
item_subtotal?: number | null
|
|
37
|
-
/** Combined items+shipping net (summary path fallback). */
|
|
38
|
-
subtotal?: number | null
|
|
39
|
-
/** Shipping net (cart path). Preferred for the Shipping row. */
|
|
40
|
-
shipping_subtotal?: number | null
|
|
41
|
-
/** Shipping gross (summary path fallback). */
|
|
42
|
-
shipping_total?: number | null
|
|
43
|
-
discount_total?: number | null
|
|
44
|
-
tax_total?: number | null
|
|
45
|
-
total?: number | null
|
|
46
|
-
/** Cartbase-native method fee (already folded into `total`). */
|
|
47
|
-
payment_method_fee_total?: number | null
|
|
48
|
-
/** The method's fee label from the server. */
|
|
49
|
-
payment_method_fee_label?: string | null
|
|
50
|
-
/** LEGACY pre-rename summary keys (2026-08-11) — read-only fallback. */
|
|
51
|
-
cod_fee_total?: number | null
|
|
52
|
-
cod_fee_label?: string | null
|
|
53
|
-
[key: string]: unknown
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Adapt `order_summaries.totals` (shape:
|
|
58
|
-
* `{subtotal, total, tax_total, discount_total, shipping_total,
|
|
59
|
-
* payment_method_fee_total}` — src/app/api/store/carts/[id]/complete/_copy.ts) to an
|
|
60
|
-
* `OrderTotalsSource`. Accepts the raw `CompletedOrder["summary"]` value
|
|
61
|
-
* (embed row, array of rows, or the totals object itself); null when no
|
|
62
|
-
* totals can be found.
|
|
63
|
-
*/
|
|
64
|
-
export function orderTotalsFromSummary(
|
|
65
|
-
summary: unknown
|
|
66
|
-
): OrderTotalsSource | null {
|
|
67
|
-
if (!summary || typeof summary !== "object") return null
|
|
68
|
-
const first = Array.isArray(summary) ? summary[0] : summary
|
|
69
|
-
if (!first || typeof first !== "object") return null
|
|
70
|
-
const rec = first as Record<string, unknown>
|
|
71
|
-
const totals =
|
|
72
|
-
rec.totals && typeof rec.totals === "object"
|
|
73
|
-
? (rec.totals as Record<string, unknown>)
|
|
74
|
-
: rec
|
|
75
|
-
if (typeof totals.total !== "number") return null
|
|
76
|
-
return totals as OrderTotalsSource
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export type OrderTotalsRow = {
|
|
80
|
-
key: "subtotal" | "shipping" | "methodFee" | "discount" | "tax"
|
|
81
|
-
/** Resolved display label (methodFee row only — others use the label pack). */
|
|
82
|
-
label?: string
|
|
83
|
-
amount: number
|
|
84
|
-
/** Shipping row at 0 renders the FREE badge instead of a price. */
|
|
85
|
-
free?: boolean
|
|
86
|
-
/** Discount row renders negated. */
|
|
87
|
-
negative?: boolean
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Pure row selection — the entire display policy of the breakdown, kept
|
|
92
|
-
* out of JSX so it is unit-testable (tests/unit/storefront-order-reviews).
|
|
93
|
-
*/
|
|
94
|
-
export function selectOrderTotalsRows(
|
|
95
|
-
source: OrderTotalsSource,
|
|
96
|
-
opts: {
|
|
97
|
-
items?: readonly LineLike[] | null
|
|
98
|
-
methodFeeLabel?: string
|
|
99
|
-
labels?: Pick<OrderLabels, "paymentMethodFee">
|
|
100
|
-
} = {}
|
|
101
|
-
): { rows: OrderTotalsRow[]; total: number } {
|
|
102
|
-
const l = { ...defaultOrderLabels, ...opts.labels }
|
|
103
|
-
const rows: OrderTotalsRow[] = []
|
|
104
|
-
|
|
105
|
-
// Legacy fee LINE (Medusa-era injection) — Cartbase-native carts never
|
|
106
|
-
// have one, but ported data / hybrid stores might.
|
|
107
|
-
const feeItem = findFeeLine(opts.items || [])
|
|
108
|
-
const nativeFee = Number(source.payment_method_fee_total ?? source.cod_fee_total ?? 0)
|
|
109
|
-
const lineFee = Number(feeItem?.total ?? 0)
|
|
110
|
-
const methodFeeAmount = nativeFee > 0 ? nativeFee : lineFee
|
|
111
|
-
|
|
112
|
-
// Subtotal: prefer the product-only net; fall back to combined-minus-
|
|
113
|
-
// shipping, then combined. Subtract a legacy fee line's net so the
|
|
114
|
-
// visible Subtotal stays product-only (v2.3.1 production fix).
|
|
115
|
-
const baseSubtotal =
|
|
116
|
-
source.item_subtotal ??
|
|
117
|
-
(source.subtotal !== null &&
|
|
118
|
-
source.subtotal !== undefined &&
|
|
119
|
-
source.shipping_subtotal !== null &&
|
|
120
|
-
source.shipping_subtotal !== undefined
|
|
121
|
-
? Number(source.subtotal) - Number(source.shipping_subtotal)
|
|
122
|
-
: source.subtotal ?? 0)
|
|
123
|
-
const feeNet = feeItem
|
|
124
|
-
? Number(feeItem.subtotal ?? feeItem.total ?? 0)
|
|
125
|
-
: 0
|
|
126
|
-
rows.push({
|
|
127
|
-
key: "subtotal",
|
|
128
|
-
amount: Math.max(0, Number(baseSubtotal ?? 0) - feeNet),
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
const shippingAmount = Number(
|
|
132
|
-
source.shipping_subtotal ?? source.shipping_total ?? 0
|
|
133
|
-
)
|
|
134
|
-
rows.push({
|
|
135
|
-
key: "shipping",
|
|
136
|
-
amount: shippingAmount,
|
|
137
|
-
free: shippingAmount === 0,
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
if (methodFeeAmount > 0) {
|
|
141
|
-
rows.push({
|
|
142
|
-
key: "methodFee",
|
|
143
|
-
amount: methodFeeAmount,
|
|
144
|
-
label:
|
|
145
|
-
opts.methodFeeLabel ??
|
|
146
|
-
source.payment_method_fee_label ??
|
|
147
|
-
source.cod_fee_label ??
|
|
148
|
-
((feeItem as { title?: string } | null)?.title || undefined) ??
|
|
149
|
-
l.paymentMethodFee,
|
|
150
|
-
})
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const discount = Number(source.discount_total ?? 0)
|
|
154
|
-
if (discount > 0) {
|
|
155
|
-
rows.push({ key: "discount", amount: discount, negative: true })
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
rows.push({ key: "tax", amount: Number(source.tax_total ?? 0) })
|
|
159
|
-
|
|
160
|
-
return { rows, total: Number(source.total ?? 0) }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
type OrderTotalsProps = {
|
|
164
|
-
totals: OrderTotalsSource
|
|
165
|
-
currencyCode: string
|
|
166
|
-
/** Lines from the same source — used only to detect a legacy fee line. */
|
|
167
|
-
items?: readonly LineLike[] | null
|
|
168
|
-
labels?: Pick<
|
|
169
|
-
OrderLabels,
|
|
170
|
-
"subtotal" | "shipping" | "discount" | "tax" | "total" | "free" | "paymentMethodFee"
|
|
171
|
-
>
|
|
172
|
-
/**
|
|
173
|
-
* Per-store override for the method-fee row label. Optional — falls back
|
|
174
|
-
* to the server's `payment_method_fee_label`, then the fee line's title,
|
|
175
|
-
* then the translated `labels.paymentMethodFee` default.
|
|
176
|
-
*/
|
|
177
|
-
methodFeeLabel?: string
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function OrderTotals({
|
|
181
|
-
totals,
|
|
182
|
-
currencyCode,
|
|
183
|
-
items,
|
|
184
|
-
labels,
|
|
185
|
-
methodFeeLabel,
|
|
186
|
-
}: OrderTotalsProps) {
|
|
187
|
-
const l = { ...defaultOrderLabels, ...labels }
|
|
188
|
-
const { rows, total } = selectOrderTotalsRows(totals, {
|
|
189
|
-
items,
|
|
190
|
-
methodFeeLabel,
|
|
191
|
-
labels: l,
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
const labelFor = (row: OrderTotalsRow): string => {
|
|
195
|
-
switch (row.key) {
|
|
196
|
-
case "subtotal":
|
|
197
|
-
return l.subtotal
|
|
198
|
-
case "shipping":
|
|
199
|
-
return l.shipping
|
|
200
|
-
case "methodFee":
|
|
201
|
-
return row.label ?? l.paymentMethodFee
|
|
202
|
-
case "discount":
|
|
203
|
-
return l.discount
|
|
204
|
-
case "tax":
|
|
205
|
-
return l.tax
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return (
|
|
210
|
-
<div className="pt-4 border-t border-border">
|
|
211
|
-
<div className="flex flex-col gap-2 text-sm">
|
|
212
|
-
{rows.map((row) => (
|
|
213
|
-
<div key={row.key} className="flex justify-between">
|
|
214
|
-
<span className="text-muted-foreground">{labelFor(row)}</span>
|
|
215
|
-
{row.free ? (
|
|
216
|
-
<span className="text-sm font-medium text-success">{l.free}</span>
|
|
217
|
-
) : row.negative ? (
|
|
218
|
-
<span className="text-sm text-success">
|
|
219
|
-
-{" "}
|
|
220
|
-
<Price
|
|
221
|
-
amount={row.amount}
|
|
222
|
-
currencyCode={currencyCode}
|
|
223
|
-
className="text-sm text-success"
|
|
224
|
-
/>
|
|
225
|
-
</span>
|
|
226
|
-
) : (
|
|
227
|
-
<Price
|
|
228
|
-
amount={row.amount}
|
|
229
|
-
currencyCode={currencyCode}
|
|
230
|
-
className="text-sm text-foreground"
|
|
231
|
-
/>
|
|
232
|
-
)}
|
|
233
|
-
</div>
|
|
234
|
-
))}
|
|
235
|
-
</div>
|
|
236
|
-
|
|
237
|
-
<div className="h-px bg-border my-3" />
|
|
238
|
-
<div className="flex justify-between items-baseline">
|
|
239
|
-
<span className="text-[15px] font-bold text-foreground">{l.total}</span>
|
|
240
|
-
<Price
|
|
241
|
-
amount={total}
|
|
242
|
-
currencyCode={currencyCode}
|
|
243
|
-
className="text-xl font-bold text-foreground tracking-tight"
|
|
244
|
-
/>
|
|
245
|
-
</div>
|
|
246
|
-
</div>
|
|
247
|
-
)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export { type OrderTotalsProps }
|
|
1
|
+
import { Price } from "../lib/price"
|
|
2
|
+
import { findFeeLine, type LineLike } from "../lib/cart-helpers"
|
|
3
|
+
import { defaultOrderLabels, type OrderLabels } from "./labels"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Order totals breakdown. Ported from `@1click/ui/src/order/order-totals.tsx`
|
|
7
|
+
* (v2.3.1) with the Cartbase money seam:
|
|
8
|
+
*
|
|
9
|
+
* Totals are SERVER truth and the Cartbase store order read
|
|
10
|
+
* (`StoreOrderDetail`) carries NONE of them — they live where the server
|
|
11
|
+
* computed them: the decorated cart the app holds on the confirmation page
|
|
12
|
+
* (`api/carts` `Cart`: `item_subtotal`, `shipping_subtotal`,
|
|
13
|
+
* `discount_total`, `tax_total`, `total`, `payment_method_fee_total`, `payment_method_fee_label`)
|
|
14
|
+
* or the order's summary snapshot (`order_summaries.totals` — reachable via
|
|
15
|
+
* `completeCart().order.summary`). The component takes a structural
|
|
16
|
+
* `OrderTotalsSource` both of those satisfy (`orderTotalsFromSummary()`
|
|
17
|
+
* adapts the summary shape) and only SELECTS + FORMATS rows — it never
|
|
18
|
+
* computes money beyond the two production subtractions documented below.
|
|
19
|
+
*
|
|
20
|
+
* Method-fee display, in preference order (production fix preserved):
|
|
21
|
+
* 1. Cartbase-native fee: `payment_method_fee_total` > 0 (never a line
|
|
22
|
+
* item; already folded into `total` server-side), labeled by the
|
|
23
|
+
* merchant-editable `payment_method_fee_label`. Pre-rename summaries
|
|
24
|
+
* (2026-08-11) spelled the keys cod_fee_* — read as legacy fallback.
|
|
25
|
+
* 2. legacy Medusa-era fee LINE ITEM (`metadata.is_cod_fee`) found in
|
|
26
|
+
* `items` — its net is subtracted from the visible subtotal so the
|
|
27
|
+
* Subtotal row stays product-only while Tax keeps the fee's tax
|
|
28
|
+
* portion (v2.3.1 fix).
|
|
29
|
+
* 3. label fallbacks: `methodFeeLabel` prop → source label →
|
|
30
|
+
* fee line's own title → translated `labels.paymentMethodFee`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Structural totals source — `api/carts` `Cart` satisfies it directly. */
|
|
34
|
+
export interface OrderTotalsSource {
|
|
35
|
+
/** Product-only net subtotal (cart path). Preferred. */
|
|
36
|
+
item_subtotal?: number | null
|
|
37
|
+
/** Combined items+shipping net (summary path fallback). */
|
|
38
|
+
subtotal?: number | null
|
|
39
|
+
/** Shipping net (cart path). Preferred for the Shipping row. */
|
|
40
|
+
shipping_subtotal?: number | null
|
|
41
|
+
/** Shipping gross (summary path fallback). */
|
|
42
|
+
shipping_total?: number | null
|
|
43
|
+
discount_total?: number | null
|
|
44
|
+
tax_total?: number | null
|
|
45
|
+
total?: number | null
|
|
46
|
+
/** Cartbase-native method fee (already folded into `total`). */
|
|
47
|
+
payment_method_fee_total?: number | null
|
|
48
|
+
/** The method's fee label from the server. */
|
|
49
|
+
payment_method_fee_label?: string | null
|
|
50
|
+
/** LEGACY pre-rename summary keys (2026-08-11) — read-only fallback. */
|
|
51
|
+
cod_fee_total?: number | null
|
|
52
|
+
cod_fee_label?: string | null
|
|
53
|
+
[key: string]: unknown
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Adapt `order_summaries.totals` (shape:
|
|
58
|
+
* `{subtotal, total, tax_total, discount_total, shipping_total,
|
|
59
|
+
* payment_method_fee_total}` — src/app/api/store/carts/[id]/complete/_copy.ts) to an
|
|
60
|
+
* `OrderTotalsSource`. Accepts the raw `CompletedOrder["summary"]` value
|
|
61
|
+
* (embed row, array of rows, or the totals object itself); null when no
|
|
62
|
+
* totals can be found.
|
|
63
|
+
*/
|
|
64
|
+
export function orderTotalsFromSummary(
|
|
65
|
+
summary: unknown
|
|
66
|
+
): OrderTotalsSource | null {
|
|
67
|
+
if (!summary || typeof summary !== "object") return null
|
|
68
|
+
const first = Array.isArray(summary) ? summary[0] : summary
|
|
69
|
+
if (!first || typeof first !== "object") return null
|
|
70
|
+
const rec = first as Record<string, unknown>
|
|
71
|
+
const totals =
|
|
72
|
+
rec.totals && typeof rec.totals === "object"
|
|
73
|
+
? (rec.totals as Record<string, unknown>)
|
|
74
|
+
: rec
|
|
75
|
+
if (typeof totals.total !== "number") return null
|
|
76
|
+
return totals as OrderTotalsSource
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type OrderTotalsRow = {
|
|
80
|
+
key: "subtotal" | "shipping" | "methodFee" | "discount" | "tax"
|
|
81
|
+
/** Resolved display label (methodFee row only — others use the label pack). */
|
|
82
|
+
label?: string
|
|
83
|
+
amount: number
|
|
84
|
+
/** Shipping row at 0 renders the FREE badge instead of a price. */
|
|
85
|
+
free?: boolean
|
|
86
|
+
/** Discount row renders negated. */
|
|
87
|
+
negative?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Pure row selection — the entire display policy of the breakdown, kept
|
|
92
|
+
* out of JSX so it is unit-testable (tests/unit/storefront-order-reviews).
|
|
93
|
+
*/
|
|
94
|
+
export function selectOrderTotalsRows(
|
|
95
|
+
source: OrderTotalsSource,
|
|
96
|
+
opts: {
|
|
97
|
+
items?: readonly LineLike[] | null
|
|
98
|
+
methodFeeLabel?: string
|
|
99
|
+
labels?: Pick<OrderLabels, "paymentMethodFee">
|
|
100
|
+
} = {}
|
|
101
|
+
): { rows: OrderTotalsRow[]; total: number } {
|
|
102
|
+
const l = { ...defaultOrderLabels, ...opts.labels }
|
|
103
|
+
const rows: OrderTotalsRow[] = []
|
|
104
|
+
|
|
105
|
+
// Legacy fee LINE (Medusa-era injection) — Cartbase-native carts never
|
|
106
|
+
// have one, but ported data / hybrid stores might.
|
|
107
|
+
const feeItem = findFeeLine(opts.items || [])
|
|
108
|
+
const nativeFee = Number(source.payment_method_fee_total ?? source.cod_fee_total ?? 0)
|
|
109
|
+
const lineFee = Number(feeItem?.total ?? 0)
|
|
110
|
+
const methodFeeAmount = nativeFee > 0 ? nativeFee : lineFee
|
|
111
|
+
|
|
112
|
+
// Subtotal: prefer the product-only net; fall back to combined-minus-
|
|
113
|
+
// shipping, then combined. Subtract a legacy fee line's net so the
|
|
114
|
+
// visible Subtotal stays product-only (v2.3.1 production fix).
|
|
115
|
+
const baseSubtotal =
|
|
116
|
+
source.item_subtotal ??
|
|
117
|
+
(source.subtotal !== null &&
|
|
118
|
+
source.subtotal !== undefined &&
|
|
119
|
+
source.shipping_subtotal !== null &&
|
|
120
|
+
source.shipping_subtotal !== undefined
|
|
121
|
+
? Number(source.subtotal) - Number(source.shipping_subtotal)
|
|
122
|
+
: source.subtotal ?? 0)
|
|
123
|
+
const feeNet = feeItem
|
|
124
|
+
? Number(feeItem.subtotal ?? feeItem.total ?? 0)
|
|
125
|
+
: 0
|
|
126
|
+
rows.push({
|
|
127
|
+
key: "subtotal",
|
|
128
|
+
amount: Math.max(0, Number(baseSubtotal ?? 0) - feeNet),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const shippingAmount = Number(
|
|
132
|
+
source.shipping_subtotal ?? source.shipping_total ?? 0
|
|
133
|
+
)
|
|
134
|
+
rows.push({
|
|
135
|
+
key: "shipping",
|
|
136
|
+
amount: shippingAmount,
|
|
137
|
+
free: shippingAmount === 0,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
if (methodFeeAmount > 0) {
|
|
141
|
+
rows.push({
|
|
142
|
+
key: "methodFee",
|
|
143
|
+
amount: methodFeeAmount,
|
|
144
|
+
label:
|
|
145
|
+
opts.methodFeeLabel ??
|
|
146
|
+
source.payment_method_fee_label ??
|
|
147
|
+
source.cod_fee_label ??
|
|
148
|
+
((feeItem as { title?: string } | null)?.title || undefined) ??
|
|
149
|
+
l.paymentMethodFee,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const discount = Number(source.discount_total ?? 0)
|
|
154
|
+
if (discount > 0) {
|
|
155
|
+
rows.push({ key: "discount", amount: discount, negative: true })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
rows.push({ key: "tax", amount: Number(source.tax_total ?? 0) })
|
|
159
|
+
|
|
160
|
+
return { rows, total: Number(source.total ?? 0) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type OrderTotalsProps = {
|
|
164
|
+
totals: OrderTotalsSource
|
|
165
|
+
currencyCode: string
|
|
166
|
+
/** Lines from the same source — used only to detect a legacy fee line. */
|
|
167
|
+
items?: readonly LineLike[] | null
|
|
168
|
+
labels?: Pick<
|
|
169
|
+
OrderLabels,
|
|
170
|
+
"subtotal" | "shipping" | "discount" | "tax" | "total" | "free" | "paymentMethodFee"
|
|
171
|
+
>
|
|
172
|
+
/**
|
|
173
|
+
* Per-store override for the method-fee row label. Optional — falls back
|
|
174
|
+
* to the server's `payment_method_fee_label`, then the fee line's title,
|
|
175
|
+
* then the translated `labels.paymentMethodFee` default.
|
|
176
|
+
*/
|
|
177
|
+
methodFeeLabel?: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function OrderTotals({
|
|
181
|
+
totals,
|
|
182
|
+
currencyCode,
|
|
183
|
+
items,
|
|
184
|
+
labels,
|
|
185
|
+
methodFeeLabel,
|
|
186
|
+
}: OrderTotalsProps) {
|
|
187
|
+
const l = { ...defaultOrderLabels, ...labels }
|
|
188
|
+
const { rows, total } = selectOrderTotalsRows(totals, {
|
|
189
|
+
items,
|
|
190
|
+
methodFeeLabel,
|
|
191
|
+
labels: l,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const labelFor = (row: OrderTotalsRow): string => {
|
|
195
|
+
switch (row.key) {
|
|
196
|
+
case "subtotal":
|
|
197
|
+
return l.subtotal
|
|
198
|
+
case "shipping":
|
|
199
|
+
return l.shipping
|
|
200
|
+
case "methodFee":
|
|
201
|
+
return row.label ?? l.paymentMethodFee
|
|
202
|
+
case "discount":
|
|
203
|
+
return l.discount
|
|
204
|
+
case "tax":
|
|
205
|
+
return l.tax
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="pt-4 border-t border-border">
|
|
211
|
+
<div className="flex flex-col gap-2 text-sm">
|
|
212
|
+
{rows.map((row) => (
|
|
213
|
+
<div key={row.key} className="flex justify-between">
|
|
214
|
+
<span className="text-muted-foreground">{labelFor(row)}</span>
|
|
215
|
+
{row.free ? (
|
|
216
|
+
<span className="text-sm font-medium text-success">{l.free}</span>
|
|
217
|
+
) : row.negative ? (
|
|
218
|
+
<span className="text-sm text-success">
|
|
219
|
+
-{" "}
|
|
220
|
+
<Price
|
|
221
|
+
amount={row.amount}
|
|
222
|
+
currencyCode={currencyCode}
|
|
223
|
+
className="text-sm text-success"
|
|
224
|
+
/>
|
|
225
|
+
</span>
|
|
226
|
+
) : (
|
|
227
|
+
<Price
|
|
228
|
+
amount={row.amount}
|
|
229
|
+
currencyCode={currencyCode}
|
|
230
|
+
className="text-sm text-foreground"
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div className="h-px bg-border my-3" />
|
|
238
|
+
<div className="flex justify-between items-baseline">
|
|
239
|
+
<span className="text-[15px] font-bold text-foreground">{l.total}</span>
|
|
240
|
+
<Price
|
|
241
|
+
amount={total}
|
|
242
|
+
currencyCode={currencyCode}
|
|
243
|
+
className="text-xl font-bold text-foreground tracking-tight"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { type OrderTotalsProps }
|