@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.
- package/README.md +9 -0
- package/package.json +274 -261
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +126 -0
- package/src/checkout/card-offer.ts +68 -0
- package/src/checkout/carrier-marks.ts +53 -0
- package/src/checkout/checkout-client.tsx +71 -13
- package/src/checkout/checkout-error-screen.tsx +113 -0
- package/src/checkout/discount-section.tsx +88 -56
- package/src/checkout/fulfillment-option.ts +30 -0
- package/src/checkout/index.ts +41 -6
- package/src/checkout/labels.ts +98 -0
- package/src/checkout/line-item-card.tsx +35 -83
- package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
- package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
- package/src/checkout/mobile-order-summary-body.tsx +172 -0
- package/src/checkout/order-summary.tsx +85 -179
- package/src/checkout/payment-button.tsx +25 -8
- package/src/checkout/payment-method-list.tsx +51 -13
- package/src/checkout/payment-wrapper.tsx +57 -13
- package/src/checkout/pickup-option.ts +35 -0
- package/src/checkout/pickup-point-selector.tsx +328 -0
- package/src/checkout/pickup-points.ts +65 -0
- package/src/checkout/pigeon-office-selector.tsx +379 -0
- package/src/checkout/shipping-method-list.tsx +102 -10
- package/src/checkout/summary-math.ts +152 -0
- package/src/checkout/use-checkout-funnel.ts +303 -0
- package/src/checkout/use-checkout-orchestration.ts +384 -30
- package/src/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +37 -1
- package/src/locales/es.ts +35 -1
- package/src/tracking/attribution.ts +83 -0
- package/src/tracking/consent.ts +53 -0
- package/src/tracking/events.ts +113 -0
- package/src/tracking/fbq.ts +48 -0
- package/src/tracking/gtag.ts +32 -0
- package/src/tracking/index.ts +32 -1
- package/src/tracking/once.ts +137 -0
- package/src/tracking/rybbit-events.ts +42 -0
- package/src/tracking/ttq.ts +24 -0
- 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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
}
|
package/src/checkout/index.ts
CHANGED
|
@@ -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).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// Extra modules beyond the pre-declared export list
|
|
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,
|
package/src/checkout/labels.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
15
|
-
* and price.
|
|
11
|
+
* summary card: thumbnail, title, variant, how many, price.
|
|
16
12
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
)
|