@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,245 +1,250 @@
1
- import { DualPrice } from "../lib/dual-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`, `cod_fee_total`, `cod_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
- * COD fee display, in preference order (production fix preserved):
21
- * 1. Cartbase-native fee: `cod_fee_total` > 0 (never a line item;
22
- * already folded into `total` server-side), labeled by the
23
- * admin-editable `cod_fee_label`.
24
- * 2. legacy Medusa-era fee LINE ITEM (`metadata.is_cod_fee`) found in
25
- * `items` its net is subtracted from the visible subtotal so the
26
- * Subtotal row stays product-only while Tax keeps the fee's tax
27
- * portion (v2.3.1 fix).
28
- * 3. label fallbacks: `codFeeLabel` prop → source `cod_fee_label` →
29
- * fee line's own titletranslated `labels.codFee`.
30
- */
31
-
32
- /** Structural totals source — `api/carts` `Cart` satisfies it directly. */
33
- export interface OrderTotalsSource {
34
- /** Product-only net subtotal (cart path). Preferred. */
35
- item_subtotal?: number | null
36
- /** Combined items+shipping net (summary path fallback). */
37
- subtotal?: number | null
38
- /** Shipping net (cart path). Preferred for the Shipping row. */
39
- shipping_subtotal?: number | null
40
- /** Shipping gross (summary path fallback). */
41
- shipping_total?: number | null
42
- discount_total?: number | null
43
- tax_total?: number | null
44
- total?: number | null
45
- /** Cartbase-native COD fee (already folded into `total`). */
46
- cod_fee_total?: number | null
47
- /** Admin-editable COD fee label from the server. */
48
- cod_fee_label?: string | null
49
- [key: string]: unknown
50
- }
51
-
52
- /**
53
- * Adapt `order_summaries.totals` (shape:
54
- * `{subtotal, total, tax_total, discount_total, shipping_total,
55
- * cod_fee_total}` — src/app/api/store/carts/[id]/complete/_copy.ts) to an
56
- * `OrderTotalsSource`. Accepts the raw `CompletedOrder["summary"]` value
57
- * (embed row, array of rows, or the totals object itself); null when no
58
- * totals can be found.
59
- */
60
- export function orderTotalsFromSummary(
61
- summary: unknown
62
- ): OrderTotalsSource | null {
63
- if (!summary || typeof summary !== "object") return null
64
- const first = Array.isArray(summary) ? summary[0] : summary
65
- if (!first || typeof first !== "object") return null
66
- const rec = first as Record<string, unknown>
67
- const totals =
68
- rec.totals && typeof rec.totals === "object"
69
- ? (rec.totals as Record<string, unknown>)
70
- : rec
71
- if (typeof totals.total !== "number") return null
72
- return totals as OrderTotalsSource
73
- }
74
-
75
- export type OrderTotalsRow = {
76
- key: "subtotal" | "shipping" | "codFee" | "discount" | "tax"
77
- /** Resolved display label (codFee row only — others use the label pack). */
78
- label?: string
79
- amount: number
80
- /** Shipping row at 0 renders the FREE badge instead of a price. */
81
- free?: boolean
82
- /** Discount row renders negated. */
83
- negative?: boolean
84
- }
85
-
86
- /**
87
- * Pure row selection — the entire display policy of the breakdown, kept
88
- * out of JSX so it is unit-testable (tests/unit/storefront-order-reviews).
89
- */
90
- export function selectOrderTotalsRows(
91
- source: OrderTotalsSource,
92
- opts: {
93
- items?: readonly LineLike[] | null
94
- codFeeLabel?: string
95
- labels?: Pick<OrderLabels, "codFee">
96
- } = {}
97
- ): { rows: OrderTotalsRow[]; total: number } {
98
- const l = { ...defaultOrderLabels, ...opts.labels }
99
- const rows: OrderTotalsRow[] = []
100
-
101
- // Legacy fee LINE (Medusa-era injection) Cartbase-native carts never
102
- // have one, but ported data / hybrid stores might.
103
- const codFeeItem = findFeeLine(opts.items || [])
104
- const nativeFee = Number(source.cod_fee_total ?? 0)
105
- const lineFee = Number(codFeeItem?.total ?? 0)
106
- const codFeeAmount = nativeFee > 0 ? nativeFee : lineFee
107
-
108
- // Subtotal: prefer the product-only net; fall back to combined-minus-
109
- // shipping, then combined. Subtract a legacy fee line's net so the
110
- // visible Subtotal stays product-only (v2.3.1 production fix).
111
- const baseSubtotal =
112
- source.item_subtotal ??
113
- (source.subtotal !== null &&
114
- source.subtotal !== undefined &&
115
- source.shipping_subtotal !== null &&
116
- source.shipping_subtotal !== undefined
117
- ? Number(source.subtotal) - Number(source.shipping_subtotal)
118
- : source.subtotal ?? 0)
119
- const feeNet = codFeeItem
120
- ? Number(codFeeItem.subtotal ?? codFeeItem.total ?? 0)
121
- : 0
122
- rows.push({
123
- key: "subtotal",
124
- amount: Math.max(0, Number(baseSubtotal ?? 0) - feeNet),
125
- })
126
-
127
- const shippingAmount = Number(
128
- source.shipping_subtotal ?? source.shipping_total ?? 0
129
- )
130
- rows.push({
131
- key: "shipping",
132
- amount: shippingAmount,
133
- free: shippingAmount === 0,
134
- })
135
-
136
- if (codFeeAmount > 0) {
137
- rows.push({
138
- key: "codFee",
139
- amount: codFeeAmount,
140
- label:
141
- opts.codFeeLabel ??
142
- source.cod_fee_label ??
143
- ((codFeeItem as { title?: string } | null)?.title || undefined) ??
144
- l.codFee,
145
- })
146
- }
147
-
148
- const discount = Number(source.discount_total ?? 0)
149
- if (discount > 0) {
150
- rows.push({ key: "discount", amount: discount, negative: true })
151
- }
152
-
153
- rows.push({ key: "tax", amount: Number(source.tax_total ?? 0) })
154
-
155
- return { rows, total: Number(source.total ?? 0) }
156
- }
157
-
158
- type OrderTotalsProps = {
159
- totals: OrderTotalsSource
160
- currencyCode: string
161
- /** Lines from the same source — used only to detect a legacy fee line. */
162
- items?: readonly LineLike[] | null
163
- labels?: Pick<
164
- OrderLabels,
165
- "subtotal" | "shipping" | "discount" | "tax" | "total" | "free" | "codFee"
166
- >
167
- /**
168
- * Per-store override for the cash-on-delivery fee row label. Optional —
169
- * falls back to the server's `cod_fee_label`, then the fee line's title,
170
- * then the translated `labels.codFee` default.
171
- */
172
- codFeeLabel?: string
173
- }
174
-
175
- export function OrderTotals({
176
- totals,
177
- currencyCode,
178
- items,
179
- labels,
180
- codFeeLabel,
181
- }: OrderTotalsProps) {
182
- const l = { ...defaultOrderLabels, ...labels }
183
- const { rows, total } = selectOrderTotalsRows(totals, {
184
- items,
185
- codFeeLabel,
186
- labels: l,
187
- })
188
-
189
- const labelFor = (row: OrderTotalsRow): string => {
190
- switch (row.key) {
191
- case "subtotal":
192
- return l.subtotal
193
- case "shipping":
194
- return l.shipping
195
- case "codFee":
196
- return row.label ?? l.codFee
197
- case "discount":
198
- return l.discount
199
- case "tax":
200
- return l.tax
201
- }
202
- }
203
-
204
- return (
205
- <div className="pt-4 border-t border-border">
206
- <div className="flex flex-col gap-2 text-sm">
207
- {rows.map((row) => (
208
- <div key={row.key} className="flex justify-between">
209
- <span className="text-muted-foreground">{labelFor(row)}</span>
210
- {row.free ? (
211
- <span className="text-sm font-medium text-success">{l.free}</span>
212
- ) : row.negative ? (
213
- <span className="text-sm text-success">
214
- -{" "}
215
- <DualPrice
216
- amount={row.amount}
217
- currencyCode={currencyCode}
218
- className="text-sm text-success"
219
- />
220
- </span>
221
- ) : (
222
- <DualPrice
223
- amount={row.amount}
224
- currencyCode={currencyCode}
225
- className="text-sm text-foreground"
226
- />
227
- )}
228
- </div>
229
- ))}
230
- </div>
231
-
232
- <div className="h-px bg-border my-3" />
233
- <div className="flex justify-between items-baseline">
234
- <span className="text-[15px] font-bold text-foreground">{l.total}</span>
235
- <DualPrice
236
- amount={total}
237
- currencyCode={currencyCode}
238
- className="text-xl font-bold text-foreground tracking-tight"
239
- />
240
- </div>
241
- </div>
242
- )
243
- }
244
-
245
- export { type OrderTotalsProps }
1
+ import { DualPrice } from "../lib/dual-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
+ <DualPrice
221
+ amount={row.amount}
222
+ currencyCode={currencyCode}
223
+ className="text-sm text-success"
224
+ />
225
+ </span>
226
+ ) : (
227
+ <DualPrice
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
+ <DualPrice
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 }