@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.
Files changed (109) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +4 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +181 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +56 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/payment-badges.tsx +36 -96
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/icons/cartbase-mark.ts +9 -0
  49. package/src/common/icons/payment-marks.ts +73 -0
  50. package/src/common/icons/social-marks.ts +61 -0
  51. package/src/common/index.ts +35 -20
  52. package/src/common/market-select.tsx +57 -57
  53. package/src/common/payment-icons.tsx +54 -0
  54. package/src/common/powered-by-cartbase.tsx +47 -0
  55. package/src/common/social-links.tsx +65 -0
  56. package/src/index.ts +12 -12
  57. package/src/lib/country-name.ts +59 -59
  58. package/src/lib/get-product-price.ts +133 -133
  59. package/src/lib/media-image.tsx +39 -39
  60. package/src/lib/payment-constants.ts +53 -53
  61. package/src/lib/platform.ts +13 -13
  62. package/src/lib/price.tsx +39 -39
  63. package/src/lib/store-api-error.ts +36 -36
  64. package/src/lib/variant-caption.ts +32 -32
  65. package/src/locales/bg.ts +3 -0
  66. package/src/locales/context.ts +37 -37
  67. package/src/locales/en.ts +26 -26
  68. package/src/locales/es.ts +3 -0
  69. package/src/locales/index.ts +19 -19
  70. package/src/locales/provider.tsx +59 -59
  71. package/src/locales/types.ts +77 -77
  72. package/src/order/index.ts +62 -62
  73. package/src/order/labels.ts +79 -79
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-info.tsx +5 -5
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/purchase-options.tsx +130 -130
  84. package/src/products/sets.ts +110 -110
  85. package/src/products/variant-matching.ts +71 -71
  86. package/src/products/variant-url.ts +74 -74
  87. package/src/reviews-ui/lightbox-state.ts +46 -46
  88. package/src/reviews-ui/review-header.tsx +166 -166
  89. package/src/reviews-ui/review-lightbox.tsx +271 -271
  90. package/src/reviews-ui/review-list.tsx +193 -193
  91. package/src/reviews-ui/review-widget.tsx +219 -218
  92. package/src/reviews-ui/widget-options.ts +55 -55
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/store/labels.ts +10 -0
  96. package/src/tracking/chatgpt-pixel.tsx +99 -99
  97. package/src/tracking/consent-init.tsx +62 -62
  98. package/src/tracking/events.ts +348 -348
  99. package/src/tracking/ga4.tsx +93 -93
  100. package/src/tracking/google-ads.ts +84 -84
  101. package/src/tracking/gtm.tsx +60 -60
  102. package/src/tracking/inline-script.ts +49 -49
  103. package/src/tracking/oaiq.ts +206 -206
  104. package/src/tracking/tiktok-pixel.tsx +91 -91
  105. package/src/tracking/track-init.tsx +56 -56
  106. package/src/tracking/track-order-purchase.tsx +122 -122
  107. package/src/tracking/ttq.ts +180 -180
  108. package/src/tracking/use-tracking-config.ts +54 -54
  109. package/theme/index.css +25 -25
package/src/api/carts.ts CHANGED
@@ -1,523 +1,523 @@
1
- /**
2
- * @cartbase/storefront/api/carts — cart lifecycle.
3
- *
4
- * Wraps the store cart routes (src/app/api/store/carts/**). Every mutation
5
- * returns the FULL decorated cart (loadAndDecorate → decorateCartTotals +
6
- * gift-card tender fields) so the storefront never recomputes money —
7
- * totals are SERVER truth (DOCTRINE: the SDK displays, the API owns math).
8
- *
9
- * Auth: all cart routes are anon-writable — `x-client-id` (sent by the
10
- * client) is the only requirement. A customer JWT (`authorization: Bearer`)
11
- * is OPTIONAL and changes behavior where noted (customer attach, B2B group
12
- * pricing). Amounts are EUR decimal major units.
13
- *
14
- * Doc: docs/storefront/carts.md · Errors envelope: `{error, code, details?}`
15
- * → thrown as StoreApiError.
16
- */
17
- import { readVisitorId } from "../lib/visitor"
18
- import type { StorefrontClient } from "./http"
19
- import type { IsoDateString, MajorUnitAmount } from "./types"
20
-
21
- // ---------------------------------------------------------------------------
22
- // DTOs — ground truth: carts/route.ts, carts/_create.ts, carts/_helpers.ts,
23
- // carts/_totals.ts, carts/_address.ts, [id]/line-items, [id]/customer,
24
- // [id]/complete/_flow.ts.
25
- // ---------------------------------------------------------------------------
26
-
27
- /**
28
- * Address input (create/update). All fields optional on the generic cart
29
- * routes (`AddressSchema.passthrough()` server-side — extra keys are kept).
30
- * NOTE: prepare-checkout has its OWN stricter address schema (see
31
- * `checkout.ts` PrepareCheckoutAddress — most fields required there).
32
- */
33
- export interface CartAddressInput {
34
- first_name?: string
35
- last_name?: string
36
- company?: string
37
- address_1?: string
38
- address_2?: string
39
- city?: string
40
- /** Lowercased server-side. */
41
- country_code?: string
42
- province?: string
43
- postal_code?: string
44
- phone?: string
45
- metadata?: Record<string, unknown>
46
- [key: string]: unknown
47
- }
48
-
49
- /** Stored cart address row (`cart_addresses`). Unset fields come back null. */
50
- export interface CartAddress {
51
- id: string
52
- first_name: string | null
53
- last_name: string | null
54
- company: string | null
55
- address_1: string | null
56
- address_2: string | null
57
- city: string | null
58
- country_code: string | null
59
- province: string | null
60
- postal_code: string | null
61
- phone: string | null
62
- metadata: Record<string, unknown> | null
63
- [key: string]: unknown
64
- }
65
-
66
- export interface CartLineItemAdjustment {
67
- id: string
68
- amount: MajorUnitAmount
69
- promotion_id?: string | null
70
- [key: string]: unknown
71
- }
72
-
73
- export interface CartTaxLine {
74
- id: string
75
- /** Percentage, e.g. 20 for BG VAT. */
76
- rate: number
77
- [key: string]: unknown
78
- }
79
-
80
- /**
81
- * Per-line money decoration (carts/_totals.ts `decorateItem`) — present on
82
- * every item and shipping method in every cart response.
83
- */
84
- export interface LineTotals {
85
- subtotal: MajorUnitAmount
86
- total: MajorUnitAmount
87
- original_total: MajorUnitAmount
88
- tax_total: MajorUnitAmount
89
- original_tax_total: MajorUnitAmount
90
- discount_total: MajorUnitAmount
91
- discount_subtotal: MajorUnitAmount
92
- discount_tax_total: MajorUnitAmount
93
- }
94
-
95
- /** Decorated cart line item (`cart_line_items` row + LineTotals). */
96
- export interface CartLineItem extends LineTotals {
97
- id: string
98
- cart_id: string
99
- variant_id: string | null
100
- product_id: string | null
101
- title: string | null
102
- subtitle: string | null
103
- product_title: string | null
104
- product_handle: string | null
105
- thumbnail: string | null
106
- variant_title: string | null
107
- variant_sku: string | null
108
- quantity: number
109
- unit_price: MajorUnitAmount
110
- is_tax_inclusive: boolean
111
- /** false for gift-card lines — they are never discountable. */
112
- is_discountable: boolean
113
- /** Gift-card product flag, copied onto the line at add time. */
114
- is_giftcard: boolean
115
- /** false for digital gift cards — lets digital-only carts complete without a shipping method. */
116
- requires_shipping: boolean
117
- metadata: Record<string, unknown> | null
118
- adjustments: CartLineItemAdjustment[]
119
- tax_lines: CartTaxLine[]
120
- [key: string]: unknown
121
- }
122
-
123
- /** Decorated cart shipping method (`cart_shipping_methods` row + LineTotals). */
124
- export interface CartShippingMethod extends LineTotals {
125
- id: string
126
- cart_id: string
127
- shipping_option_id: string | null
128
- name: string | null
129
- amount: MajorUnitAmount
130
- is_tax_inclusive: boolean
131
- data: Record<string, unknown> | null
132
- adjustments: CartLineItemAdjustment[]
133
- tax_lines: CartTaxLine[]
134
- [key: string]: unknown
135
- }
136
-
137
- /**
138
- * Applied gift card as decorated onto every cart read (gift-card tender —
139
- * see `gift-cards.ts` for apply/remove). `amount` is derived from the LIVE
140
- * ledger at every read; a disabled/expired/depleted card stays listed at 0.
141
- */
142
- export interface AppliedGiftCard {
143
- id: string
144
- /** Masked — the code itself is never echoed. */
145
- last4: string
146
- amount: MajorUnitAmount
147
- }
148
-
149
- /**
150
- * The decorated cart — every cart-returning endpoint sends this shape
151
- * (`{cart: Cart}`). Totals fields are computed by decorateCartTotals
152
- * (carts/_totals.ts); gift-card fields by resolveGiftCardTender; the COD fee
153
- * by resolveCodFee. All numbers are EUR decimal major units.
154
- */
155
- export interface Cart {
156
- id: string
157
- region_id: string | null
158
- currency_code: string
159
- email: string | null
160
- /** Set ONLY via the auth-derived paths (create with JWT / setCartCustomer). */
161
- customer_id: string | null
162
- sales_channel_id: string | null
163
- locale: string | null
164
- completed_at: IsoDateString | null
165
- metadata: Record<string, unknown> | null
166
- created_at: IsoDateString
167
- updated_at: IsoDateString
168
- region?: Record<string, unknown> | null
169
- items: CartLineItem[]
170
- shipping_methods: CartShippingMethod[]
171
- shipping_address: CartAddress | null
172
- billing_address: CartAddress | null
173
- credit_lines: Array<{ amount: MajorUnitAmount; [key: string]: unknown }>
174
- promotions: Array<Record<string, unknown>>
175
- /** Embedded pivot → payment collection (+ its payment_sessions). */
176
- payment_collection: unknown
177
- // — cart-level totals (decorateCartTotals) —
178
- total: MajorUnitAmount
179
- subtotal: MajorUnitAmount
180
- tax_total: MajorUnitAmount
181
- discount_total: MajorUnitAmount
182
- discount_subtotal: MajorUnitAmount
183
- discount_tax_total: MajorUnitAmount
184
- shipping_total: MajorUnitAmount
185
- shipping_subtotal: MajorUnitAmount
186
- shipping_tax_total: MajorUnitAmount
187
- shipping_discount_total: MajorUnitAmount
188
- original_shipping_total: MajorUnitAmount
189
- original_shipping_subtotal: MajorUnitAmount
190
- original_shipping_tax_total: MajorUnitAmount
191
- item_total: MajorUnitAmount
192
- item_subtotal: MajorUnitAmount
193
- item_tax_total: MajorUnitAmount
194
- item_discount_total: MajorUnitAmount
195
- original_item_total: MajorUnitAmount
196
- original_item_subtotal: MajorUnitAmount
197
- original_item_tax_total: MajorUnitAmount
198
- original_total: MajorUnitAmount
199
- original_subtotal: MajorUnitAmount
200
- original_tax_total: MajorUnitAmount
201
- credit_line_total: MajorUnitAmount
202
- credit_line_subtotal: MajorUnitAmount
203
- credit_line_tax_total: MajorUnitAmount
204
- /** The payment method fee — the chosen method's fee_amount (any method
205
- * may carry one); non-zero only while a live method session is selected.
206
- * Folded into `total`; its own totals row, never a line item. */
207
- payment_method_fee_total: MajorUnitAmount
208
- payment_method_fee_label: string | null
209
- // — gift-card tender decoration (totals NEVER move; see gift-cards.ts) —
210
- gift_cards: AppliedGiftCard[]
211
- /** Σ of applied-card coverage (= the pp_giftcard session amount). */
212
- gift_card_total: MajorUnitAmount
213
- /** `max(total − gift_card_total, 0)` — what the remainder provider charges. */
214
- gift_card_remainder: MajorUnitAmount
215
- [key: string]: unknown
216
- }
217
-
218
- export interface CartResponse {
219
- cart: Cart
220
- }
221
-
222
- /**
223
- * Body of POST /api/store/carts (`.strict()` — unknown keys → 400
224
- * `validation_failed`). `customer_id` is deliberately NOT a field: the
225
- * customer is derived from the Bearer JWT (forgery-vector guard G2).
226
- */
227
- export interface CreateCartInput {
228
- /** Falls back to `stores.default_region_id`; neither → 400 `region_required`. */
229
- region_id?: string
230
- email?: string
231
- /** Must be enabled for the store; region currency is the default. */
232
- currency_code?: string
233
- items?: Array<{ variant_id: string; quantity: number; selling_plan_id?: string }>
234
- /**
235
- * B2B v1: with a publishable key, omitting this defaults to the key's
236
- * channel; a channel OUTSIDE the key's scope → 400 `invalid_sales_channel`.
237
- */
238
- sales_channel_id?: string
239
- promo_codes?: string[]
240
- shipping_address?: CartAddressInput
241
- billing_address?: CartAddressInput
242
- metadata?: Record<string, unknown>
243
- locale?: string
244
- /**
245
- * THE JOIN KEY: this browser's visitor id, which is what connects the
246
- * browsing that led here to the money. Attached automatically by
247
- * `createCart` when the page can read the cookie, so a storefront never
248
- * has to remember it. Stamped at birth or never.
249
- */
250
- device_id?: string
251
- }
252
-
253
- /**
254
- * Body of POST /api/store/carts/:id (partial update, `.strict()`).
255
- * Addresses: object = set/replace in place, null = clear, absent = untouched.
256
- * A region change re-resolves currency, CLEARS the shipping address (unless
257
- * a new one is provided in the same call), drops custom-priced items and
258
- * re-prices the rest.
259
- */
260
- export interface UpdateCartInput {
261
- region_id?: string
262
- email?: string
263
- currency_code?: string
264
- sales_channel_id?: string
265
- metadata?: Record<string, unknown>
266
- locale?: string
267
- /** Write-once: a cart that already carries a visitor keeps it. */
268
- device_id?: string
269
- shipping_address?: CartAddressInput | null
270
- billing_address?: CartAddressInput | null
271
- }
272
-
273
- export interface AddLineItemInput {
274
- variant_id: string
275
- /**
276
- * Positive integer. Adding a variant already in the cart UNDER THE SAME
277
- * PLAN (or both one-time) bumps its quantity; a one-time line and a
278
- * subscription line of the same variant stay separate.
279
- */
280
- quantity: number
281
- /**
282
- * Subscription plan for this line (from
283
- * `products.listSellingPlans`). The server validates the plan is enabled
284
- * and attached to the variant's product (else 400 `invalid_selling_plan`)
285
- * and applies the plan price. Omit for a one-time purchase.
286
- */
287
- selling_plan_id?: string
288
- metadata?: Record<string, unknown>
289
- }
290
-
291
- export interface UpdateLineItemInput {
292
- /**
293
- * Integer ≥ 0 — REQUIRED. 0 deletes the line. NOTE (code wins over
294
- * store-api.md): `metadata` is NOT accepted on update, only on add.
295
- */
296
- quantity: number
297
- }
298
-
299
- /**
300
- * Success shape of POST /api/store/carts/:id/complete.
301
- *
302
- * NOTE (code wins over store-api.md): the route NEVER returns the Medusa
303
- * `{type:"cart", cart, error}` failure union — failures THROW the standard
304
- * error envelope (StoreApiError) and the cart stays open/retryable.
305
- */
306
- export interface CompleteCartResult {
307
- type: "order"
308
- order: CompletedOrder
309
- }
310
-
311
- /** Order shape returned by complete (complete/_flow.ts loadOrder). */
312
- export interface CompletedOrder {
313
- id: string
314
- display_id?: number
315
- status: string
316
- email: string | null
317
- currency_code: string
318
- customer_id: string | null
319
- sales_channel_id?: string | null
320
- created_at: IsoDateString
321
- summary: Array<Record<string, unknown>> | Record<string, unknown> | null
322
- /** Flattened line items (order_line_items + pivot quantity). */
323
- items: Array<Record<string, unknown> & { quantity: number }>
324
- [key: string]: unknown
325
- }
326
-
327
- // ---------------------------------------------------------------------------
328
- // Functions
329
- // ---------------------------------------------------------------------------
330
-
331
- /**
332
- * Create a cart. → 201 `{cart}`.
333
- *
334
- * Auth: anon `x-client-id`; optional Bearer JWT attaches the customer
335
- * (customer_id + email) and prices initial items with the customer's B2B
336
- * groups; optional publishable key applies channel scope.
337
- * Errors: 400 `validation_failed` | `invalid_region` | `region_required` |
338
- * `invalid_sales_channel` | `price_not_found` (an initial item has no price
339
- * in the cart currency), 404 `variant_not_found`. An unsupported
340
- * `currency_code` does NOT error — it silently falls back to the region
341
- * currency (only `store_currencies` rows are honored).
342
- * Settings: store default region; enabled store currencies; publishable-key
343
- * channel scope; automatic promotions re-applied on create.
344
- */
345
- export async function createCart(
346
- client: StorefrontClient,
347
- input: CreateCartInput = {}
348
- ): Promise<CartResponse> {
349
- // The visitor id rides along automatically. It exists only in this
350
- // browser at this moment, so a cart created without it can never be
351
- // attributed afterwards — which is why this is here and not left to the
352
- // caller to remember.
353
- const device_id = input.device_id ?? readVisitorId()
354
- return client.post("/api/store/carts", device_id ? { ...input, device_id } : input)
355
- }
356
-
357
- /**
358
- * Retrieve the decorated cart. → 200 `{cart}`.
359
- * Auth: anon `x-client-id`. Errors: 404 `cart_not_found`.
360
- * Every read re-runs tax recalc + totals + gift-card tender — amounts are
361
- * always current (a gift-card balance spent elsewhere shrinks this cart's
362
- * tender at the next read).
363
- */
364
- export async function retrieveCart(
365
- client: StorefrontClient,
366
- cartId: string
367
- ): Promise<CartResponse> {
368
- return client.get(`/api/store/carts/${cartId}`)
369
- }
370
-
371
- /**
372
- * Partial-update the cart. → 200 `{cart}`.
373
- * Auth: anon `x-client-id`.
374
- * Errors: 404 `cart_not_found`, 409 `cart_completed`, 400
375
- * `validation_failed` | `invalid_region` (unsupported `currency_code` falls
376
- * back to the region currency instead of erroring).
377
- * Settings: region change re-prices lines using the cart customer's groups
378
- * (B2B price lists) and re-applies promotions.
379
- */
380
- export async function updateCart(
381
- client: StorefrontClient,
382
- cartId: string,
383
- input: UpdateCartInput
384
- ): Promise<CartResponse> {
385
- return client.post(`/api/store/carts/${cartId}`, input)
386
- }
387
-
388
- /**
389
- * Attach the AUTHENTICATED customer to the cart. Body is empty by contract
390
- * (`z.object({}).strict()`) — the customer comes from the Bearer JWT, never
391
- * from the body (forgery-vector guard). → 200 `{cart}`.
392
- * Auth: `x-client-id` + REQUIRED Bearer JWT.
393
- * Errors: 401 `unauthenticated`, 404 `cart_not_found`, 409 `cart_completed`,
394
- * 400 `validation_failed` (any body key).
395
- */
396
- export async function setCartCustomer(
397
- client: StorefrontClient,
398
- cartId: string
399
- ): Promise<CartResponse> {
400
- return client.post(`/api/store/carts/${cartId}/customer`, {})
401
- }
402
-
403
- /**
404
- * Add a line item (same-variant add bumps quantity). → 200 `{cart}`.
405
- * Auth: anon `x-client-id`.
406
- * Errors: 404 `cart_not_found` | `variant_not_found`, 409 `cart_completed`,
407
- * 400 `insufficient_inventory` (kit-aware: every linked inventory component
408
- * is checked; details carry `{variant_id, inventory_item_id, available,
409
- * requested}`) | `validation_failed` | `price_not_found` (variant has no
410
- * price in the cart currency).
411
- * Settings: B2B price lists (via the cart's attached customer), gift-card
412
- * product flag (`is_giftcard`/`requires_shipping` ride the line), automatic
413
- * promotions re-applied.
414
- */
415
- export async function addLineItem(
416
- client: StorefrontClient,
417
- cartId: string,
418
- input: AddLineItemInput
419
- ): Promise<CartResponse> {
420
- return client.post(`/api/store/carts/${cartId}/line-items`, input)
421
- }
422
-
423
- /**
424
- * Set a line's quantity (0 deletes it). → 200 `{cart}`.
425
- * Auth: anon `x-client-id`.
426
- * Errors: 404 `cart_not_found` | `line_item_not_found`, 409 `cart_completed`,
427
- * 400 `insufficient_inventory` | `validation_failed`.
428
- */
429
- export async function updateLineItem(
430
- client: StorefrontClient,
431
- cartId: string,
432
- lineItemId: string,
433
- input: UpdateLineItemInput
434
- ): Promise<CartResponse> {
435
- return client.post(`/api/store/carts/${cartId}/line-items/${lineItemId}`, input)
436
- }
437
-
438
- /**
439
- * Remove a line item (idempotent — removing an already-gone line still
440
- * returns the cart). → 200 `{cart}`.
441
- * Auth: anon `x-client-id`. Errors: 404 `cart_not_found` (bad cart id).
442
- */
443
- export async function deleteLineItem(
444
- client: StorefrontClient,
445
- cartId: string,
446
- lineItemId: string
447
- ): Promise<CartResponse> {
448
- return client.delete(`/api/store/carts/${cartId}/line-items/${lineItemId}`)
449
- }
450
-
451
- /**
452
- * Complete the cart → place the order. → 200 `{type:"order", order}`.
453
- * Idempotent: recalling for a completed cart returns the SAME order
454
- * (order_cart link); concurrent completes are serialized by a CAS lock
455
- * (the loser either returns the winner's order or 409 `cart_locked`).
456
- *
457
- * Auth: anon `x-client-id` (guest checkout) — but when the store setting
458
- * `accounts_mode='required'`, a guest cart (no attached customer) → 403
459
- * `account_required`.
460
- *
461
- * Validation errors (400): `cart_email_required`, `cart_empty`,
462
- * `shipping_address_required`, `shipping_method_required` (only when a line
463
- * `requires_shipping` — digital-only carts skip it),
464
- * `payment_collection_required`, `payment_session_required`,
465
- * `checkout_method_hidden` (checkout-rules completion guard), 400
466
- * `insufficient_inventory` (reserve step). Payment errors (402):
467
- * `requires_action` (3DS — details carry `client_secret`),
468
- * `payment_not_authorized`, `payment_not_initiated`, `payment_incomplete`,
469
- * `gift_card_insufficient_balance`, `gift_card_not_redeemable`. 409
470
- * `cart_locked`. Any failure compensates fully (order deleted, inventory
471
- * released, gift tender reversed, cart unlocked) — the cart stays retryable.
472
- *
473
- * Settings: checkout rules (completion guard), accounts_mode, COD fee
474
- * (carried onto the order via order_summaries.totals), gift-card tender
475
- * (zero-remainder carts complete on the gift session alone).
476
- * Emits `order.placed` on the durable bus after commit.
477
- */
478
- export async function completeCart(
479
- client: StorefrontClient,
480
- cartId: string
481
- ): Promise<CompleteCartResult> {
482
- return client.post(`/api/store/carts/${cartId}/complete`, {})
483
- }
484
-
485
- // ---------------------------------------------------------------------------
486
- // Promotions — promo codes on the cart (batch-10 merge: the checkout
487
- // DiscountSection consumed the route raw before this wrapper existed).
488
- // ---------------------------------------------------------------------------
489
-
490
- /**
491
- * Apply promo code(s) to the cart. → 200 `{cart}` (decorated — totals and
492
- * `promotions` recomputed server-side; adding is idempotent per code).
493
- * Auth: anon `x-client-id`.
494
- * Errors: 404 `cart_not_found`, 400 zod (`promo_codes` must be a non-empty
495
- * string array), 404 `promotion_not_found` (unknown code on ADD), 400
496
- * `promotion_inactive` (draft/expired code on ADD).
497
- */
498
- export async function applyPromotions(
499
- client: StorefrontClient,
500
- cartId: string,
501
- promoCodes: string[]
502
- ): Promise<CartResponse> {
503
- return client.post(`/api/store/carts/${cartId}/promotions`, {
504
- promo_codes: promoCodes,
505
- })
506
- }
507
-
508
- /**
509
- * Remove promo code(s) from the cart. → 200 `{cart}` (decorated). Unknown
510
- * codes silently no-op on remove (Medusa parity).
511
- * Auth: anon `x-client-id`.
512
- * Errors: 404 `cart_not_found`, 400 zod.
513
- */
514
- export async function removePromotions(
515
- client: StorefrontClient,
516
- cartId: string,
517
- promoCodes: string[]
518
- ): Promise<CartResponse> {
519
- return client.request(`/api/store/carts/${cartId}/promotions`, {
520
- method: "DELETE",
521
- body: { promo_codes: promoCodes },
522
- })
523
- }
1
+ /**
2
+ * @cartbase/storefront/api/carts — cart lifecycle.
3
+ *
4
+ * Wraps the store cart routes (src/app/api/store/carts/**). Every mutation
5
+ * returns the FULL decorated cart (loadAndDecorate → decorateCartTotals +
6
+ * gift-card tender fields) so the storefront never recomputes money —
7
+ * totals are SERVER truth (DOCTRINE: the SDK displays, the API owns math).
8
+ *
9
+ * Auth: all cart routes are anon-writable — `x-client-id` (sent by the
10
+ * client) is the only requirement. A customer JWT (`authorization: Bearer`)
11
+ * is OPTIONAL and changes behavior where noted (customer attach, B2B group
12
+ * pricing). Amounts are EUR decimal major units.
13
+ *
14
+ * Doc: docs/storefront/carts.md · Errors envelope: `{error, code, details?}`
15
+ * → thrown as StoreApiError.
16
+ */
17
+ import { readVisitorId } from "../lib/visitor"
18
+ import type { StorefrontClient } from "./http"
19
+ import type { IsoDateString, MajorUnitAmount } from "./types"
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // DTOs — ground truth: carts/route.ts, carts/_create.ts, carts/_helpers.ts,
23
+ // carts/_totals.ts, carts/_address.ts, [id]/line-items, [id]/customer,
24
+ // [id]/complete/_flow.ts.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Address input (create/update). All fields optional on the generic cart
29
+ * routes (`AddressSchema.passthrough()` server-side — extra keys are kept).
30
+ * NOTE: prepare-checkout has its OWN stricter address schema (see
31
+ * `checkout.ts` PrepareCheckoutAddress — most fields required there).
32
+ */
33
+ export interface CartAddressInput {
34
+ first_name?: string
35
+ last_name?: string
36
+ company?: string
37
+ address_1?: string
38
+ address_2?: string
39
+ city?: string
40
+ /** Lowercased server-side. */
41
+ country_code?: string
42
+ province?: string
43
+ postal_code?: string
44
+ phone?: string
45
+ metadata?: Record<string, unknown>
46
+ [key: string]: unknown
47
+ }
48
+
49
+ /** Stored cart address row (`cart_addresses`). Unset fields come back null. */
50
+ export interface CartAddress {
51
+ id: string
52
+ first_name: string | null
53
+ last_name: string | null
54
+ company: string | null
55
+ address_1: string | null
56
+ address_2: string | null
57
+ city: string | null
58
+ country_code: string | null
59
+ province: string | null
60
+ postal_code: string | null
61
+ phone: string | null
62
+ metadata: Record<string, unknown> | null
63
+ [key: string]: unknown
64
+ }
65
+
66
+ export interface CartLineItemAdjustment {
67
+ id: string
68
+ amount: MajorUnitAmount
69
+ promotion_id?: string | null
70
+ [key: string]: unknown
71
+ }
72
+
73
+ export interface CartTaxLine {
74
+ id: string
75
+ /** Percentage, e.g. 20 for BG VAT. */
76
+ rate: number
77
+ [key: string]: unknown
78
+ }
79
+
80
+ /**
81
+ * Per-line money decoration (carts/_totals.ts `decorateItem`) — present on
82
+ * every item and shipping method in every cart response.
83
+ */
84
+ export interface LineTotals {
85
+ subtotal: MajorUnitAmount
86
+ total: MajorUnitAmount
87
+ original_total: MajorUnitAmount
88
+ tax_total: MajorUnitAmount
89
+ original_tax_total: MajorUnitAmount
90
+ discount_total: MajorUnitAmount
91
+ discount_subtotal: MajorUnitAmount
92
+ discount_tax_total: MajorUnitAmount
93
+ }
94
+
95
+ /** Decorated cart line item (`cart_line_items` row + LineTotals). */
96
+ export interface CartLineItem extends LineTotals {
97
+ id: string
98
+ cart_id: string
99
+ variant_id: string | null
100
+ product_id: string | null
101
+ title: string | null
102
+ subtitle: string | null
103
+ product_title: string | null
104
+ product_handle: string | null
105
+ thumbnail: string | null
106
+ variant_title: string | null
107
+ variant_sku: string | null
108
+ quantity: number
109
+ unit_price: MajorUnitAmount
110
+ is_tax_inclusive: boolean
111
+ /** false for gift-card lines — they are never discountable. */
112
+ is_discountable: boolean
113
+ /** Gift-card product flag, copied onto the line at add time. */
114
+ is_giftcard: boolean
115
+ /** false for digital gift cards — lets digital-only carts complete without a shipping method. */
116
+ requires_shipping: boolean
117
+ metadata: Record<string, unknown> | null
118
+ adjustments: CartLineItemAdjustment[]
119
+ tax_lines: CartTaxLine[]
120
+ [key: string]: unknown
121
+ }
122
+
123
+ /** Decorated cart shipping method (`cart_shipping_methods` row + LineTotals). */
124
+ export interface CartShippingMethod extends LineTotals {
125
+ id: string
126
+ cart_id: string
127
+ shipping_option_id: string | null
128
+ name: string | null
129
+ amount: MajorUnitAmount
130
+ is_tax_inclusive: boolean
131
+ data: Record<string, unknown> | null
132
+ adjustments: CartLineItemAdjustment[]
133
+ tax_lines: CartTaxLine[]
134
+ [key: string]: unknown
135
+ }
136
+
137
+ /**
138
+ * Applied gift card as decorated onto every cart read (gift-card tender —
139
+ * see `gift-cards.ts` for apply/remove). `amount` is derived from the LIVE
140
+ * ledger at every read; a disabled/expired/depleted card stays listed at 0.
141
+ */
142
+ export interface AppliedGiftCard {
143
+ id: string
144
+ /** Masked — the code itself is never echoed. */
145
+ last4: string
146
+ amount: MajorUnitAmount
147
+ }
148
+
149
+ /**
150
+ * The decorated cart — every cart-returning endpoint sends this shape
151
+ * (`{cart: Cart}`). Totals fields are computed by decorateCartTotals
152
+ * (carts/_totals.ts); gift-card fields by resolveGiftCardTender; the COD fee
153
+ * by resolveCodFee. All numbers are EUR decimal major units.
154
+ */
155
+ export interface Cart {
156
+ id: string
157
+ region_id: string | null
158
+ currency_code: string
159
+ email: string | null
160
+ /** Set ONLY via the auth-derived paths (create with JWT / setCartCustomer). */
161
+ customer_id: string | null
162
+ sales_channel_id: string | null
163
+ locale: string | null
164
+ completed_at: IsoDateString | null
165
+ metadata: Record<string, unknown> | null
166
+ created_at: IsoDateString
167
+ updated_at: IsoDateString
168
+ region?: Record<string, unknown> | null
169
+ items: CartLineItem[]
170
+ shipping_methods: CartShippingMethod[]
171
+ shipping_address: CartAddress | null
172
+ billing_address: CartAddress | null
173
+ credit_lines: Array<{ amount: MajorUnitAmount; [key: string]: unknown }>
174
+ promotions: Array<Record<string, unknown>>
175
+ /** Embedded pivot → payment collection (+ its payment_sessions). */
176
+ payment_collection: unknown
177
+ // — cart-level totals (decorateCartTotals) —
178
+ total: MajorUnitAmount
179
+ subtotal: MajorUnitAmount
180
+ tax_total: MajorUnitAmount
181
+ discount_total: MajorUnitAmount
182
+ discount_subtotal: MajorUnitAmount
183
+ discount_tax_total: MajorUnitAmount
184
+ shipping_total: MajorUnitAmount
185
+ shipping_subtotal: MajorUnitAmount
186
+ shipping_tax_total: MajorUnitAmount
187
+ shipping_discount_total: MajorUnitAmount
188
+ original_shipping_total: MajorUnitAmount
189
+ original_shipping_subtotal: MajorUnitAmount
190
+ original_shipping_tax_total: MajorUnitAmount
191
+ item_total: MajorUnitAmount
192
+ item_subtotal: MajorUnitAmount
193
+ item_tax_total: MajorUnitAmount
194
+ item_discount_total: MajorUnitAmount
195
+ original_item_total: MajorUnitAmount
196
+ original_item_subtotal: MajorUnitAmount
197
+ original_item_tax_total: MajorUnitAmount
198
+ original_total: MajorUnitAmount
199
+ original_subtotal: MajorUnitAmount
200
+ original_tax_total: MajorUnitAmount
201
+ credit_line_total: MajorUnitAmount
202
+ credit_line_subtotal: MajorUnitAmount
203
+ credit_line_tax_total: MajorUnitAmount
204
+ /** The payment method fee — the chosen method's fee_amount (any method
205
+ * may carry one); non-zero only while a live method session is selected.
206
+ * Folded into `total`; its own totals row, never a line item. */
207
+ payment_method_fee_total: MajorUnitAmount
208
+ payment_method_fee_label: string | null
209
+ // — gift-card tender decoration (totals NEVER move; see gift-cards.ts) —
210
+ gift_cards: AppliedGiftCard[]
211
+ /** Σ of applied-card coverage (= the pp_giftcard session amount). */
212
+ gift_card_total: MajorUnitAmount
213
+ /** `max(total − gift_card_total, 0)` — what the remainder provider charges. */
214
+ gift_card_remainder: MajorUnitAmount
215
+ [key: string]: unknown
216
+ }
217
+
218
+ export interface CartResponse {
219
+ cart: Cart
220
+ }
221
+
222
+ /**
223
+ * Body of POST /api/store/carts (`.strict()` — unknown keys → 400
224
+ * `validation_failed`). `customer_id` is deliberately NOT a field: the
225
+ * customer is derived from the Bearer JWT (forgery-vector guard G2).
226
+ */
227
+ export interface CreateCartInput {
228
+ /** Falls back to `stores.default_region_id`; neither → 400 `region_required`. */
229
+ region_id?: string
230
+ email?: string
231
+ /** Must be enabled for the store; region currency is the default. */
232
+ currency_code?: string
233
+ items?: Array<{ variant_id: string; quantity: number; selling_plan_id?: string }>
234
+ /**
235
+ * B2B v1: with a publishable key, omitting this defaults to the key's
236
+ * channel; a channel OUTSIDE the key's scope → 400 `invalid_sales_channel`.
237
+ */
238
+ sales_channel_id?: string
239
+ promo_codes?: string[]
240
+ shipping_address?: CartAddressInput
241
+ billing_address?: CartAddressInput
242
+ metadata?: Record<string, unknown>
243
+ locale?: string
244
+ /**
245
+ * THE JOIN KEY: this browser's visitor id, which is what connects the
246
+ * browsing that led here to the money. Attached automatically by
247
+ * `createCart` when the page can read the cookie, so a storefront never
248
+ * has to remember it. Stamped at birth or never.
249
+ */
250
+ device_id?: string
251
+ }
252
+
253
+ /**
254
+ * Body of POST /api/store/carts/:id (partial update, `.strict()`).
255
+ * Addresses: object = set/replace in place, null = clear, absent = untouched.
256
+ * A region change re-resolves currency, CLEARS the shipping address (unless
257
+ * a new one is provided in the same call), drops custom-priced items and
258
+ * re-prices the rest.
259
+ */
260
+ export interface UpdateCartInput {
261
+ region_id?: string
262
+ email?: string
263
+ currency_code?: string
264
+ sales_channel_id?: string
265
+ metadata?: Record<string, unknown>
266
+ locale?: string
267
+ /** Write-once: a cart that already carries a visitor keeps it. */
268
+ device_id?: string
269
+ shipping_address?: CartAddressInput | null
270
+ billing_address?: CartAddressInput | null
271
+ }
272
+
273
+ export interface AddLineItemInput {
274
+ variant_id: string
275
+ /**
276
+ * Positive integer. Adding a variant already in the cart UNDER THE SAME
277
+ * PLAN (or both one-time) bumps its quantity; a one-time line and a
278
+ * subscription line of the same variant stay separate.
279
+ */
280
+ quantity: number
281
+ /**
282
+ * Subscription plan for this line (from
283
+ * `products.listSellingPlans`). The server validates the plan is enabled
284
+ * and attached to the variant's product (else 400 `invalid_selling_plan`)
285
+ * and applies the plan price. Omit for a one-time purchase.
286
+ */
287
+ selling_plan_id?: string
288
+ metadata?: Record<string, unknown>
289
+ }
290
+
291
+ export interface UpdateLineItemInput {
292
+ /**
293
+ * Integer ≥ 0 — REQUIRED. 0 deletes the line. NOTE (code wins over
294
+ * store-api.md): `metadata` is NOT accepted on update, only on add.
295
+ */
296
+ quantity: number
297
+ }
298
+
299
+ /**
300
+ * Success shape of POST /api/store/carts/:id/complete.
301
+ *
302
+ * NOTE (code wins over store-api.md): the route NEVER returns the Medusa
303
+ * `{type:"cart", cart, error}` failure union — failures THROW the standard
304
+ * error envelope (StoreApiError) and the cart stays open/retryable.
305
+ */
306
+ export interface CompleteCartResult {
307
+ type: "order"
308
+ order: CompletedOrder
309
+ }
310
+
311
+ /** Order shape returned by complete (complete/_flow.ts loadOrder). */
312
+ export interface CompletedOrder {
313
+ id: string
314
+ display_id?: number
315
+ status: string
316
+ email: string | null
317
+ currency_code: string
318
+ customer_id: string | null
319
+ sales_channel_id?: string | null
320
+ created_at: IsoDateString
321
+ summary: Array<Record<string, unknown>> | Record<string, unknown> | null
322
+ /** Flattened line items (order_line_items + pivot quantity). */
323
+ items: Array<Record<string, unknown> & { quantity: number }>
324
+ [key: string]: unknown
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Functions
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /**
332
+ * Create a cart. → 201 `{cart}`.
333
+ *
334
+ * Auth: anon `x-client-id`; optional Bearer JWT attaches the customer
335
+ * (customer_id + email) and prices initial items with the customer's B2B
336
+ * groups; optional publishable key applies channel scope.
337
+ * Errors: 400 `validation_failed` | `invalid_region` | `region_required` |
338
+ * `invalid_sales_channel` | `price_not_found` (an initial item has no price
339
+ * in the cart currency), 404 `variant_not_found`. An unsupported
340
+ * `currency_code` does NOT error — it silently falls back to the region
341
+ * currency (only `store_currencies` rows are honored).
342
+ * Settings: store default region; enabled store currencies; publishable-key
343
+ * channel scope; automatic promotions re-applied on create.
344
+ */
345
+ export async function createCart(
346
+ client: StorefrontClient,
347
+ input: CreateCartInput = {}
348
+ ): Promise<CartResponse> {
349
+ // The visitor id rides along automatically. It exists only in this
350
+ // browser at this moment, so a cart created without it can never be
351
+ // attributed afterwards — which is why this is here and not left to the
352
+ // caller to remember.
353
+ const device_id = input.device_id ?? readVisitorId()
354
+ return client.post("/api/store/carts", device_id ? { ...input, device_id } : input)
355
+ }
356
+
357
+ /**
358
+ * Retrieve the decorated cart. → 200 `{cart}`.
359
+ * Auth: anon `x-client-id`. Errors: 404 `cart_not_found`.
360
+ * Every read re-runs tax recalc + totals + gift-card tender — amounts are
361
+ * always current (a gift-card balance spent elsewhere shrinks this cart's
362
+ * tender at the next read).
363
+ */
364
+ export async function retrieveCart(
365
+ client: StorefrontClient,
366
+ cartId: string
367
+ ): Promise<CartResponse> {
368
+ return client.get(`/api/store/carts/${cartId}`)
369
+ }
370
+
371
+ /**
372
+ * Partial-update the cart. → 200 `{cart}`.
373
+ * Auth: anon `x-client-id`.
374
+ * Errors: 404 `cart_not_found`, 409 `cart_completed`, 400
375
+ * `validation_failed` | `invalid_region` (unsupported `currency_code` falls
376
+ * back to the region currency instead of erroring).
377
+ * Settings: region change re-prices lines using the cart customer's groups
378
+ * (B2B price lists) and re-applies promotions.
379
+ */
380
+ export async function updateCart(
381
+ client: StorefrontClient,
382
+ cartId: string,
383
+ input: UpdateCartInput
384
+ ): Promise<CartResponse> {
385
+ return client.post(`/api/store/carts/${cartId}`, input)
386
+ }
387
+
388
+ /**
389
+ * Attach the AUTHENTICATED customer to the cart. Body is empty by contract
390
+ * (`z.object({}).strict()`) — the customer comes from the Bearer JWT, never
391
+ * from the body (forgery-vector guard). → 200 `{cart}`.
392
+ * Auth: `x-client-id` + REQUIRED Bearer JWT.
393
+ * Errors: 401 `unauthenticated`, 404 `cart_not_found`, 409 `cart_completed`,
394
+ * 400 `validation_failed` (any body key).
395
+ */
396
+ export async function setCartCustomer(
397
+ client: StorefrontClient,
398
+ cartId: string
399
+ ): Promise<CartResponse> {
400
+ return client.post(`/api/store/carts/${cartId}/customer`, {})
401
+ }
402
+
403
+ /**
404
+ * Add a line item (same-variant add bumps quantity). → 200 `{cart}`.
405
+ * Auth: anon `x-client-id`.
406
+ * Errors: 404 `cart_not_found` | `variant_not_found`, 409 `cart_completed`,
407
+ * 400 `insufficient_inventory` (kit-aware: every linked inventory component
408
+ * is checked; details carry `{variant_id, inventory_item_id, available,
409
+ * requested}`) | `validation_failed` | `price_not_found` (variant has no
410
+ * price in the cart currency).
411
+ * Settings: B2B price lists (via the cart's attached customer), gift-card
412
+ * product flag (`is_giftcard`/`requires_shipping` ride the line), automatic
413
+ * promotions re-applied.
414
+ */
415
+ export async function addLineItem(
416
+ client: StorefrontClient,
417
+ cartId: string,
418
+ input: AddLineItemInput
419
+ ): Promise<CartResponse> {
420
+ return client.post(`/api/store/carts/${cartId}/line-items`, input)
421
+ }
422
+
423
+ /**
424
+ * Set a line's quantity (0 deletes it). → 200 `{cart}`.
425
+ * Auth: anon `x-client-id`.
426
+ * Errors: 404 `cart_not_found` | `line_item_not_found`, 409 `cart_completed`,
427
+ * 400 `insufficient_inventory` | `validation_failed`.
428
+ */
429
+ export async function updateLineItem(
430
+ client: StorefrontClient,
431
+ cartId: string,
432
+ lineItemId: string,
433
+ input: UpdateLineItemInput
434
+ ): Promise<CartResponse> {
435
+ return client.post(`/api/store/carts/${cartId}/line-items/${lineItemId}`, input)
436
+ }
437
+
438
+ /**
439
+ * Remove a line item (idempotent — removing an already-gone line still
440
+ * returns the cart). → 200 `{cart}`.
441
+ * Auth: anon `x-client-id`. Errors: 404 `cart_not_found` (bad cart id).
442
+ */
443
+ export async function deleteLineItem(
444
+ client: StorefrontClient,
445
+ cartId: string,
446
+ lineItemId: string
447
+ ): Promise<CartResponse> {
448
+ return client.delete(`/api/store/carts/${cartId}/line-items/${lineItemId}`)
449
+ }
450
+
451
+ /**
452
+ * Complete the cart → place the order. → 200 `{type:"order", order}`.
453
+ * Idempotent: recalling for a completed cart returns the SAME order
454
+ * (order_cart link); concurrent completes are serialized by a CAS lock
455
+ * (the loser either returns the winner's order or 409 `cart_locked`).
456
+ *
457
+ * Auth: anon `x-client-id` (guest checkout) — but when the store setting
458
+ * `accounts_mode='required'`, a guest cart (no attached customer) → 403
459
+ * `account_required`.
460
+ *
461
+ * Validation errors (400): `cart_email_required`, `cart_empty`,
462
+ * `shipping_address_required`, `shipping_method_required` (only when a line
463
+ * `requires_shipping` — digital-only carts skip it),
464
+ * `payment_collection_required`, `payment_session_required`,
465
+ * `checkout_method_hidden` (checkout-rules completion guard), 400
466
+ * `insufficient_inventory` (reserve step). Payment errors (402):
467
+ * `requires_action` (3DS — details carry `client_secret`),
468
+ * `payment_not_authorized`, `payment_not_initiated`, `payment_incomplete`,
469
+ * `gift_card_insufficient_balance`, `gift_card_not_redeemable`. 409
470
+ * `cart_locked`. Any failure compensates fully (order deleted, inventory
471
+ * released, gift tender reversed, cart unlocked) — the cart stays retryable.
472
+ *
473
+ * Settings: checkout rules (completion guard), accounts_mode, COD fee
474
+ * (carried onto the order via order_summaries.totals), gift-card tender
475
+ * (zero-remainder carts complete on the gift session alone).
476
+ * Emits `order.placed` on the durable bus after commit.
477
+ */
478
+ export async function completeCart(
479
+ client: StorefrontClient,
480
+ cartId: string
481
+ ): Promise<CompleteCartResult> {
482
+ return client.post(`/api/store/carts/${cartId}/complete`, {})
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Promotions — promo codes on the cart (batch-10 merge: the checkout
487
+ // DiscountSection consumed the route raw before this wrapper existed).
488
+ // ---------------------------------------------------------------------------
489
+
490
+ /**
491
+ * Apply promo code(s) to the cart. → 200 `{cart}` (decorated — totals and
492
+ * `promotions` recomputed server-side; adding is idempotent per code).
493
+ * Auth: anon `x-client-id`.
494
+ * Errors: 404 `cart_not_found`, 400 zod (`promo_codes` must be a non-empty
495
+ * string array), 404 `promotion_not_found` (unknown code on ADD), 400
496
+ * `promotion_inactive` (draft/expired code on ADD).
497
+ */
498
+ export async function applyPromotions(
499
+ client: StorefrontClient,
500
+ cartId: string,
501
+ promoCodes: string[]
502
+ ): Promise<CartResponse> {
503
+ return client.post(`/api/store/carts/${cartId}/promotions`, {
504
+ promo_codes: promoCodes,
505
+ })
506
+ }
507
+
508
+ /**
509
+ * Remove promo code(s) from the cart. → 200 `{cart}` (decorated). Unknown
510
+ * codes silently no-op on remove (Medusa parity).
511
+ * Auth: anon `x-client-id`.
512
+ * Errors: 404 `cart_not_found`, 400 zod.
513
+ */
514
+ export async function removePromotions(
515
+ client: StorefrontClient,
516
+ cartId: string,
517
+ promoCodes: string[]
518
+ ): Promise<CartResponse> {
519
+ return client.request(`/api/store/carts/${cartId}/promotions`, {
520
+ method: "DELETE",
521
+ body: { promo_codes: promoCodes },
522
+ })
523
+ }