@cartbase/storefront 0.21.0 → 0.22.1

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 (42) hide show
  1. package/README.md +9 -0
  2. package/package.json +274 -261
  3. package/src/api/checkout.ts +15 -0
  4. package/src/api/http.ts +9 -0
  5. package/src/api/integrations.ts +126 -0
  6. package/src/checkout/card-offer.ts +68 -0
  7. package/src/checkout/carrier-marks.ts +53 -0
  8. package/src/checkout/checkout-client.tsx +71 -13
  9. package/src/checkout/checkout-error-screen.tsx +113 -0
  10. package/src/checkout/discount-section.tsx +88 -56
  11. package/src/checkout/fulfillment-option.ts +30 -0
  12. package/src/checkout/index.ts +41 -6
  13. package/src/checkout/labels.ts +98 -0
  14. package/src/checkout/line-item-card.tsx +35 -83
  15. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  16. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  17. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  18. package/src/checkout/order-summary.tsx +85 -179
  19. package/src/checkout/payment-button.tsx +25 -8
  20. package/src/checkout/payment-method-list.tsx +51 -13
  21. package/src/checkout/payment-wrapper.tsx +57 -13
  22. package/src/checkout/pickup-option.ts +35 -0
  23. package/src/checkout/pickup-point-selector.tsx +328 -0
  24. package/src/checkout/pickup-points.ts +65 -0
  25. package/src/checkout/pigeon-office-selector.tsx +379 -0
  26. package/src/checkout/shipping-method-list.tsx +102 -10
  27. package/src/checkout/summary-math.ts +152 -0
  28. package/src/checkout/use-checkout-funnel.ts +303 -0
  29. package/src/checkout/use-checkout-orchestration.ts +384 -30
  30. package/src/lib/stripe-env.ts +25 -0
  31. package/src/locales/bg.ts +37 -1
  32. package/src/locales/es.ts +35 -1
  33. package/src/tracking/attribution.ts +83 -0
  34. package/src/tracking/consent.ts +53 -0
  35. package/src/tracking/events.ts +113 -0
  36. package/src/tracking/fbq.ts +48 -0
  37. package/src/tracking/gtag.ts +32 -0
  38. package/src/tracking/index.ts +32 -1
  39. package/src/tracking/once.ts +137 -0
  40. package/src/tracking/rybbit-events.ts +42 -0
  41. package/src/tracking/ttq.ts +24 -0
  42. package/src/tracking/types.ts +34 -0
@@ -62,6 +62,13 @@ type DiscountSectionProps = {
62
62
  /** The SDK transport — the promotions route is called through it. */
63
63
  client: StorefrontClient
64
64
  cart: Cart
65
+ /**
66
+ * Show the field itself instead of the door that opens it. The checkout
67
+ * passes this on the phone, where the door would be the second tap
68
+ * inside an already-closed summary bar and a shopper holding a code
69
+ * would never find it.
70
+ */
71
+ alwaysOpen?: boolean
65
72
  /** Receives the decorated cart returned by a successful apply. */
66
73
  onCartChange?: (cart: Cart) => void
67
74
  }
@@ -69,6 +76,7 @@ type DiscountSectionProps = {
69
76
  export function DiscountSection({
70
77
  client,
71
78
  cart,
79
+ alwaysOpen = false,
72
80
  onCartChange,
73
81
  }: DiscountSectionProps) {
74
82
  const labels = useCheckoutLabels()
@@ -98,6 +106,45 @@ export function DiscountSection({
98
106
  }
99
107
  }
100
108
 
109
+ const field = (
110
+ <div className={cn("flex gap-2", !alwaysOpen && "mt-3")}>
111
+ <input
112
+ type="text"
113
+ value={code}
114
+ onChange={(e) => setCode(e.target.value.toUpperCase())}
115
+ onKeyDown={(e) => {
116
+ if (e.key === "Enter") {
117
+ e.preventDefault()
118
+ void handleApply()
119
+ }
120
+ }}
121
+ placeholder="PROMO2024"
122
+ className="flex-1 h-11 px-4 text-sm bg-card border border-border rounded-xl focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
123
+ />
124
+ <button
125
+ type="button"
126
+ onClick={handleApply}
127
+ disabled={loading || !code.trim()}
128
+ className="px-5 h-11 text-sm font-semibold bg-foreground text-card rounded-xl hover:bg-foreground/90 transition-all disabled:opacity-40"
129
+ >
130
+ {labels.addCode}
131
+ </button>
132
+ </div>
133
+ )
134
+
135
+ if (alwaysOpen) {
136
+ return (
137
+ <div>
138
+ <p className="text-xs font-medium text-muted-foreground mb-1.5">
139
+ {labels.discountCode}
140
+ </p>
141
+ {field}
142
+ {error && <p className="text-xs text-destructive mt-2">{error}</p>}
143
+ {promotions.length > 0 && <AppliedList promotions={promotions} />}
144
+ </div>
145
+ )
146
+ }
147
+
101
148
  return (
102
149
  <div>
103
150
  <button
@@ -152,67 +199,52 @@ export function DiscountSection({
152
199
  </span>
153
200
  </button>
154
201
 
155
- {open && (
156
- <div className="mt-3 flex gap-2">
157
- <input
158
- type="text"
159
- value={code}
160
- onChange={(e) => setCode(e.target.value.toUpperCase())}
161
- placeholder="PROMO2024"
162
- className="flex-1 h-11 px-4 text-sm bg-card border border-border rounded-xl focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
163
- />
164
- <button
165
- type="button"
166
- onClick={handleApply}
167
- disabled={loading || !code.trim()}
168
- className="px-5 h-11 text-sm font-semibold bg-foreground text-card rounded-xl hover:bg-foreground/90 transition-all disabled:opacity-40"
169
- >
170
- {labels.addCode}
171
- </button>
172
- </div>
173
- )}
202
+ {open && field}
174
203
 
175
204
  {error && <p className="text-xs text-destructive mt-2">{error}</p>}
176
205
 
177
- {promotions.length > 0 && (
178
- <div className="mt-3 space-y-1.5">
179
- {promotions.map((p) => (
180
- <div
181
- key={p.id}
182
- className="flex items-center justify-between px-4 py-2.5 bg-success/10 border border-success/20 rounded-lg"
206
+ {promotions.length > 0 && <AppliedList promotions={promotions} />}
207
+ </div>
208
+ )
209
+ }
210
+
211
+ /** The codes already on the cart, with what each one takes off. */
212
+ function AppliedList({ promotions }: { promotions: AppliedPromotion[] }) {
213
+ return (
214
+ <div className="mt-3 space-y-1.5">
215
+ {promotions.map((p) => (
216
+ <div
217
+ key={p.id}
218
+ className="flex items-center justify-between px-4 py-2.5 bg-success/10 border border-success/20 rounded-lg"
219
+ >
220
+ <div className="flex items-center gap-2">
221
+ <svg
222
+ className="w-4 h-4 text-success"
223
+ fill="none"
224
+ viewBox="0 0 24 24"
225
+ stroke="currentColor"
226
+ strokeWidth={2}
183
227
  >
184
- <div className="flex items-center gap-2">
185
- <svg
186
- className="w-4 h-4 text-success"
187
- fill="none"
188
- viewBox="0 0 24 24"
189
- stroke="currentColor"
190
- strokeWidth={2}
191
- >
192
- <path
193
- strokeLinecap="round"
194
- strokeLinejoin="round"
195
- d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
196
- />
197
- </svg>
198
- <span className="text-sm font-semibold text-success">
199
- {p.code}
200
- </span>
201
- </div>
202
- <span className="text-sm font-medium text-success">
203
- {p.application_method?.type === "percentage"
204
- ? `-${p.application_method.value}%`
205
- : p.application_method?.value && p.application_method?.currency_code
206
- ? `-${convertToLocale({
207
- amount: +p.application_method.value,
208
- currency_code: p.application_method.currency_code,
209
- })}`
210
- : ""}
211
- </span>
212
- </div>
213
- ))}
228
+ <path
229
+ strokeLinecap="round"
230
+ strokeLinejoin="round"
231
+ d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
232
+ />
233
+ </svg>
234
+ <span className="text-sm font-semibold text-success">{p.code}</span>
235
+ </div>
236
+ <span className="text-sm font-medium text-success">
237
+ {p.application_method?.type === "percentage"
238
+ ? `-${p.application_method.value}%`
239
+ : p.application_method?.value && p.application_method?.currency_code
240
+ ? `-${convertToLocale({
241
+ amount: +p.application_method.value,
242
+ currency_code: p.application_method.currency_code,
243
+ })}`
244
+ : ""}
245
+ </span>
214
246
  </div>
215
- )}
247
+ ))}
216
248
  </div>
217
249
  )
218
250
  }
@@ -0,0 +1,30 @@
1
+ import type { StoreShippingOption } from "../api/checkout"
2
+
3
+ /**
4
+ * WHICH DESTINATION a shipping option books, read from the option itself.
5
+ *
6
+ * Cartbase writes the merchant's choice as `data.fulfillment_option` (the
7
+ * Shipping option drawer's "To an Econt office", "To a BoxNow locker" and
8
+ * the rest, from the carrier registry). The kit was ported from a platform
9
+ * that put the same value at `data.id`, and nothing on this side ever wrote
10
+ * that key, so on a Cartbase store every reader of it saw null: no office
11
+ * or locker picker ever opened, no carrier mark ever keyed, and a cart
12
+ * could reach Buy with an office option and no office chosen (found live on
13
+ * the Demo Store, 2026-09-18).
14
+ *
15
+ * So the Cartbase key is read first and the ported one stays as a fallback,
16
+ * because a cart carried over from that platform still speaks it.
17
+ */
18
+ export function fulfillmentOptionId(
19
+ option: Pick<StoreShippingOption, "data"> | null | undefined
20
+ ): string | null {
21
+ const data = option?.data as
22
+ | { fulfillment_option?: unknown; id?: unknown }
23
+ | null
24
+ | undefined
25
+ if (typeof data?.fulfillment_option === "string" && data.fulfillment_option) {
26
+ return data.fulfillment_option
27
+ }
28
+ if (typeof data?.id === "string" && data.id) return data.id
29
+ return null
30
+ }
@@ -1,11 +1,11 @@
1
1
  // @cartbase/storefront/checkout — barrel export.
2
2
  //
3
- // Ported from `@1click/ui/src/checkout/index.ts` (v2.3.1). Not ported in
4
- // this batch: CheckoutLineItem's standalone file (its flat row lives inside
5
- // order-summary.tsx and is re-exported below) and the Mobile* bars (out of
6
- // the pre-declared file list; stores compose their own from the hook).
7
- // Extra modules beyond the pre-declared export list (geocode,
8
- // compare-addresses) are exported from here only.
3
+ // Ported from `@1click/ui/src/checkout/index.ts` (v2.3.1). CheckoutLineItem
4
+ // has no standalone file here: its flat row lives inside order-summary.tsx
5
+ // and is re-exported below. The mobile bars are exported too, so a store
6
+ // with its own checkout layout composes the same phone summary the default
7
+ // layout mounts. Extra modules beyond the pre-declared export list
8
+ // (geocode, compare-addresses) are exported from here only.
9
9
 
10
10
  export {
11
11
  CheckoutProvider,
@@ -21,6 +21,11 @@ export {
21
21
  useStripeScope,
22
22
  } from "./stripe-wrapper"
23
23
  export { PaymentWrapper } from "./payment-wrapper"
24
+ export {
25
+ resolveCardOffer,
26
+ type CardEntryLike,
27
+ type CardOffer,
28
+ } from "./card-offer"
24
29
  export { PaymentButton } from "./payment-button"
25
30
  export {
26
31
  EcontOfficeSelector,
@@ -30,16 +35,41 @@ export {
30
35
  BoxNowLockerSelector,
31
36
  type BoxNowLocker,
32
37
  } from "./boxnow-locker-selector"
38
+ export {
39
+ PigeonOfficeSelector,
40
+ type PigeonOffice,
41
+ } from "./pigeon-office-selector"
42
+ export {
43
+ PickupPointSelector,
44
+ type PickupPoint,
45
+ } from "./pickup-point-selector"
46
+ export { pickupOptionOf, type PickupOption } from "./pickup-option"
47
+ export { carrierMarks } from "./carrier-marks"
33
48
  export { AddressSelect } from "./address-select"
34
49
  export { CompanyDetails } from "./company-details"
35
50
  export { DiscountSection } from "./discount-section"
36
51
  export { GiftCardSection } from "./gift-card-section"
37
52
  export { LineItemCard } from "./line-item-card"
38
53
  export { OrderSummary, CheckoutLineItem } from "./order-summary"
54
+ export { MobileCheckoutTopBar } from "./mobile-checkout-top-bar"
55
+ export { MobileCheckoutBottomBar } from "./mobile-checkout-bottom-bar"
56
+ export { MobileOrderSummaryBody } from "./mobile-order-summary-body"
57
+ export {
58
+ checkoutTotals,
59
+ amountDueAfterTender,
60
+ isNothingToPay,
61
+ isCompanyOrder,
62
+ type CheckoutTotals,
63
+ type CheckoutTotalsInput,
64
+ } from "./summary-math"
39
65
  export { CheckoutAddressForm } from "./address-form"
40
66
  export { CheckoutShippingMethodList } from "./shipping-method-list"
41
67
  export { CheckoutPaymentMethodList } from "./payment-method-list"
42
68
  export { CheckoutClient } from "./checkout-client"
69
+ export {
70
+ CheckoutErrorScreen,
71
+ type CheckoutErrorScreenProps,
72
+ } from "./checkout-error-screen"
43
73
  export {
44
74
  useCheckoutOrchestration,
45
75
  resolveOrderConfirmedPath,
@@ -47,6 +77,11 @@ export {
47
77
  type CheckoutLogError,
48
78
  type PaymentProviderLike,
49
79
  } from "./use-checkout-orchestration"
80
+ export {
81
+ useCheckoutFunnel,
82
+ type UseCheckoutFunnelOptions,
83
+ } from "./use-checkout-funnel"
84
+ export { fulfillmentOptionId } from "./fulfillment-option"
50
85
  export {
51
86
  translatePaymentError,
52
87
  translateGiftCardError,
@@ -46,6 +46,15 @@ export type CheckoutLabels = {
46
46
  addCode: string
47
47
 
48
48
  // States / empty / disabled
49
+ /**
50
+ * The checkout with nothing to check out. A shopper who lands here with
51
+ * an empty cart, or after their order was placed in another tab, used to
52
+ * be bounced to the home page without a word, which reads as the store
53
+ * losing their order. The page says it instead, and offers the way back.
54
+ */
55
+ cartEmptyTitle: string
56
+ cartEmptyText: string
57
+ cartEmptyBack: string
49
58
  deliveryDisabled: string
50
59
  noShippingOptions: string
51
60
  paymentDisabled: string
@@ -80,6 +89,18 @@ export type CheckoutLabels = {
80
89
  shipping: string
81
90
  shippingCalc: string
82
91
  shippingFree: string
92
+ /**
93
+ * What a line that costs nothing says in place of its price: a gift a
94
+ * promotion added, or a line a discount took to zero.
95
+ *
96
+ * A SEPARATE label from `shippingFree` on purpose, and not a saving of
97
+ * one string: in Bulgarian the shipping row reads "Безплатна", agreeing
98
+ * with доставка, and a product line reads "Безплатно". One label would
99
+ * print the wrong gender on one of the two rows for every Bulgarian
100
+ * store, which is exactly the kind of detail a shopper reads as
101
+ * carelessness at the moment of paying.
102
+ */
103
+ lineFree: string
83
104
  tax: string
84
105
  /**
85
106
  * What the tax is, in the store's words. OPTIONAL and undefined by
@@ -124,7 +145,19 @@ export type CheckoutLabels = {
124
145
 
125
146
  // Place order button
126
147
  placeOrder: string
148
+ /** The button on an order with nothing left to pay. */
149
+ completeOrder: string
127
150
  selectPaymentMethod: string
151
+ /** What stands where the tender list would be on a covered order. */
152
+ nothingToPay: string
153
+
154
+ /**
155
+ * The checkout's own error screen (`CheckoutErrorScreen`), shown when
156
+ * the page itself fails rather than a call inside it.
157
+ */
158
+ errorTitle: string
159
+ errorBody: string
160
+ errorRetry: string
128
161
 
129
162
  /**
130
163
  * Processing-state messages shown beneath the spinning Buy button
@@ -141,6 +174,27 @@ export type CheckoutLabels = {
141
174
  processingCard: string[]
142
175
  processingCod: string[]
143
176
 
177
+ /**
178
+ * The pickup picker, for every carrier (2026-09-18). One picker means one
179
+ * set of words: a shopper choosing a Speedy office and a shopper choosing
180
+ * an Econt office are doing the same thing, and the carrier is already
181
+ * named on the row above by its own mark. The three per-carrier sets below
182
+ * stay for stores that still mount the old components; new work reads
183
+ * these.
184
+ */
185
+ pickupLoading: string
186
+ pickupNearestOffices: string
187
+ pickupNearestLockers: string
188
+ pickupSearchOffice: string
189
+ pickupSearchLocker: string
190
+ pickupSearchOfficePlaceholder: string
191
+ pickupSearchLockerPlaceholder: string
192
+ pickupNoResults: string
193
+ pickupChange: string
194
+ pickupUnavailable: string
195
+ /** Why Buy is still disabled: an office option with no office named. */
196
+ pickupRequired: string
197
+
144
198
  // Econt office selector
145
199
  econtLoadingOffices: string
146
200
  econtNearestOffices: string
@@ -158,6 +212,16 @@ export type CheckoutLabels = {
158
212
  boxnowChange: string
159
213
  boxnowUnavailable: string
160
214
  boxnowNoLockersInCity: string
215
+
216
+ // Pigeon Express office selector
217
+ pigeonLoadingOffices: string
218
+ pigeonNearestOffices: string
219
+ pigeonSearchAnother: string
220
+ pigeonSearchPlaceholder: string
221
+ pigeonNoResults: string
222
+ pigeonChange: string
223
+ pigeonUnavailable: string
224
+ pigeonNoOfficesInCity: string
161
225
  /**
162
226
  * What a shopper reads when saving the address fails. It used to be
163
227
  * hardcoded BULGARIAN inside `address-error-copy.ts` with no way to
@@ -287,6 +351,9 @@ export const defaultCheckoutLabels: CheckoutLabels = {
287
351
  addToOrder: "+ Add to order",
288
352
  addCode: "Apply",
289
353
 
354
+ cartEmptyTitle: "Your cart is empty",
355
+ cartEmptyText: "Add something to it and your order starts here.",
356
+ cartEmptyBack: "Back to the shop",
290
357
  deliveryDisabled:
291
358
  "Enter your delivery address to see available shipping options.",
292
359
  noShippingOptions: "No shipping options available for your address.",
@@ -312,6 +379,7 @@ export const defaultCheckoutLabels: CheckoutLabels = {
312
379
  shipping: "Shipping",
313
380
  shippingCalc: "Calculated at checkout",
314
381
  shippingFree: "Free",
382
+ lineFree: "Free",
315
383
  // The row NAME, neutral on purpose: it sits beside a number, so it must
316
384
  // say something, but "VAT incl." names one tax regime and asserts that
317
385
  // prices include it. That is Bulgaria's answer, not Germany's, and not any
@@ -347,7 +415,14 @@ export const defaultCheckoutLabels: CheckoutLabels = {
347
415
  companyAddress: "Company address",
348
416
 
349
417
  placeOrder: "Place order",
418
+ completeOrder: "Complete order",
350
419
  selectPaymentMethod: "Select a payment method",
420
+ nothingToPay: "Your order is fully covered. There is nothing left to pay.",
421
+
422
+ errorTitle: "Something went wrong at checkout",
423
+ errorBody:
424
+ "Your cart is safe. Try again, and if it keeps happening, get in touch.",
425
+ errorRetry: "Try again",
351
426
 
352
427
  processingCard: [
353
428
  "Connecting to the bank...",
@@ -361,6 +436,18 @@ export const defaultCheckoutLabels: CheckoutLabels = {
361
436
  "Attending to every detail...",
362
437
  ],
363
438
 
439
+ pickupLoading: "Loading pickup points...",
440
+ pickupNearestOffices: "Nearest offices",
441
+ pickupNearestLockers: "Nearest lockers",
442
+ pickupSearchOffice: "Search for another office",
443
+ pickupSearchLocker: "Search for another locker",
444
+ pickupSearchOfficePlaceholder: "Search by name, city, or address...",
445
+ pickupSearchLockerPlaceholder: "Search by name, city, or address...",
446
+ pickupNoResults: "Nothing found",
447
+ pickupChange: "Change",
448
+ pickupUnavailable: "Pickup points are unavailable right now",
449
+ pickupRequired: "Choose a pickup point",
450
+
364
451
  econtLoadingOffices: "Loading offices...",
365
452
  econtNearestOffices: "Nearest offices",
366
453
  econtSearchAnother: "Search for another office",
@@ -378,6 +465,17 @@ export const defaultCheckoutLabels: CheckoutLabels = {
378
465
  "BoxNow is temporarily unavailable, please choose a different shipping method.",
379
466
  boxnowNoLockersInCity:
380
467
  "No BoxNow lockers found in your city. Please choose a different shipping method.",
468
+
469
+ pigeonLoadingOffices: "Loading offices...",
470
+ pigeonNearestOffices: "Nearest offices",
471
+ pigeonSearchAnother: "Search for another office",
472
+ pigeonSearchPlaceholder: "Search by name, city, or address...",
473
+ pigeonNoResults: "No offices found for",
474
+ pigeonChange: "Change",
475
+ pigeonUnavailable:
476
+ "Pigeon Express is temporarily unavailable, please choose a different shipping method.",
477
+ pigeonNoOfficesInCity:
478
+ "No Pigeon Express offices found in your city. Please choose a different shipping method.",
381
479
  addressErrors: {
382
480
  generic:
383
481
  "We could not save your address. Please check the details and try again.",
@@ -1,76 +1,44 @@
1
1
  "use client"
2
2
 
3
- import { useState } from "react"
4
-
5
3
  import type { StorefrontClient } from "../api/http"
6
- import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
4
+ import type { Cart, CartLineItem } from "../api/carts"
7
5
  import { Price } from "../lib/price"
8
- import { cn } from "../lib/utils"
9
6
  import { variantCaption } from "../lib/variant-caption"
10
7
  import { useCheckoutLabels } from "./context"
11
8
 
12
9
  /**
13
10
  * LineItemCard — a single cart line shown inside the checkout order
14
- * summary card. Includes thumbnail, title, variant, quantity select,
15
- * and price.
11
+ * summary card: thumbnail, title, variant, how many, price.
16
12
  *
17
- * Ported from `@1click/ui/src/checkout/line-item-card.tsx` (v2.3.1) with
18
- * the Cartbase data seam: the cookie-bound `updateLineItem` server action
19
- * `carts.updateLineItem(client, cartId, lineId, {quantity})`
20
- * (POST /api/store/carts/:id/line-items/:lineId docs/storefront/
21
- * carts.md). The route returns the freshly decorated cart; pass
22
- * `onCartChange` so the host can update its cart state (or
23
- * `router.refresh()` in an RSC app). `variant.title` → the flat
24
- * `variant_title` column of the Cartbase line DTO.
13
+ * **A CHECKOUT IS A CHECKOUT** (Alexander, 2026-09-18). This card carried a
14
+ * quantity select, ported from the platform this kit came from, and it is
15
+ * gone with the summary row's stepper: the cart is changed in the cart, and
16
+ * the checkout states the order. Nothing in the checkout family writes a
17
+ * line any more.
25
18
  *
26
- * Checkout is master-level: customers cannot remove items here.
27
- * Removal lives in the cart drawer only.
19
+ * Ported from `@1click/ui/src/checkout/line-item-card.tsx` (v2.3.1);
20
+ * `variant.title` the flat `variant_title` column of the Cartbase line
21
+ * DTO.
28
22
  */
29
23
 
30
24
  type LineItemCardProps = {
31
- /** The SDK transport — quantity changes go through it. */
32
- client: StorefrontClient
33
- cartId: string
34
25
  item: CartLineItem
35
26
  currencyCode: string
36
- /** Receives the decorated cart returned by the quantity update. */
27
+ /** @deprecated Unused: the checkout summary no longer writes. Accepted so
28
+ * a store passing the old props keeps compiling; remove them at leisure. */
29
+ client?: StorefrontClient
30
+ /** @deprecated Unused — see `client`. */
31
+ cartId?: string
32
+ /** @deprecated Unused — see `client`. */
37
33
  onCartChange?: (cart: Cart) => void
38
34
  }
39
35
 
40
- export function LineItemCard({
41
- client,
42
- cartId,
43
- item,
44
- currencyCode,
45
- onCartChange,
46
- }: LineItemCardProps) {
36
+ export function LineItemCard({ item, currencyCode }: LineItemCardProps) {
47
37
  const labels = useCheckoutLabels()
48
- const [updating, setUpdating] = useState(false)
49
-
50
- const handleQty = async (qty: number) => {
51
- setUpdating(true)
52
- try {
53
- const { cart } = await updateLineItem(client, cartId, item.id, {
54
- quantity: qty,
55
- })
56
- onCartChange?.(cart)
57
- } catch {
58
- // Quantity update failed (e.g. insufficient_inventory) — the select
59
- // snaps back to the server value on the next cart render.
60
- } finally {
61
- setUpdating(false)
62
- }
63
- }
64
-
65
38
  const total = item.total ?? 0
66
39
 
67
40
  return (
68
- <div
69
- className={cn(
70
- "relative flex gap-4 p-4 rounded-xl border border-border bg-muted/50 transition-opacity",
71
- updating && "opacity-40 pointer-events-none"
72
- )}
73
- >
41
+ <div className="relative flex gap-4 p-4 rounded-xl border border-border bg-muted/50">
74
42
  <div className="relative w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-card border border-border">
75
43
  {item.thumbnail ? (
76
44
  // eslint-disable-next-line @next/next/no-img-element
@@ -113,42 +81,26 @@ export function LineItemCard({
113
81
  </p>
114
82
  )}
115
83
 
116
- <div className="flex items-center gap-2 mt-2.5">
117
- <div className="relative">
118
- <select
119
- value={item.quantity}
120
- onChange={(e) => handleQty(parseInt(e.target.value, 10))}
121
- className="h-8 pl-2.5 pr-7 text-xs font-medium border border-border rounded-lg bg-card text-foreground appearance-none cursor-pointer hover:border-muted-foreground transition-colors focus:outline-none focus:ring-1 focus:ring-primary/20"
122
- >
123
- {Array.from({ length: 10 }, (_, i) => (
124
- <option key={i + 1} value={i + 1}>
125
- {labels.qty} {i + 1}
126
- </option>
127
- ))}
128
- </select>
129
- <svg
130
- className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none"
131
- fill="none"
132
- viewBox="0 0 24 24"
133
- stroke="currentColor"
134
- strokeWidth={2}
135
- >
136
- <path
137
- strokeLinecap="round"
138
- strokeLinejoin="round"
139
- d="M19.5 8.25l-7.5 7.5-7.5-7.5"
140
- />
141
- </svg>
142
- </div>
143
- </div>
84
+ {/* How many, said and not offered: the quantity select that stood
85
+ here could change the order from inside the checkout. */}
86
+ <p className="text-xs font-medium text-muted-foreground mt-2.5 tabular-nums">
87
+ {labels.qty} {item.quantity}
88
+ </p>
144
89
  </div>
145
90
 
146
91
  <div className="flex flex-col items-end justify-center flex-shrink-0">
147
- <Price
148
- amount={total}
149
- currencyCode={currencyCode}
150
- className="text-sm font-bold text-foreground"
151
- />
92
+ {/* A line that costs nothing says so; €0.00 reads as a fault. */}
93
+ {total <= 0 ? (
94
+ <span className="text-xs font-bold text-success uppercase">
95
+ {labels.lineFree}
96
+ </span>
97
+ ) : (
98
+ <Price
99
+ amount={total}
100
+ currencyCode={currencyCode}
101
+ className="text-sm font-bold text-foreground"
102
+ />
103
+ )}
152
104
  </div>
153
105
  </div>
154
106
  )