@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import type { StorefrontClient } from "../api/http"
|
|
6
|
+
import type { Cart } from "../api/carts"
|
|
7
|
+
import { Price } from "../lib/price"
|
|
8
|
+
import { cn } from "../lib/utils"
|
|
9
|
+
import { useCheckoutLabels } from "./context"
|
|
10
|
+
import { MobileOrderSummaryBody } from "./mobile-order-summary-body"
|
|
11
|
+
import { checkoutTotals } from "./summary-math"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* MobileCheckoutBottomBar — the card that sits directly above Place order
|
|
15
|
+
* on a phone: the first item's picture with the number of units on it, the
|
|
16
|
+
* word Total over the count, the figure, and a chevron onto the same body
|
|
17
|
+
* the top bar opens. It is the last thing a shopper reads before paying,
|
|
18
|
+
* so what they are paying for is one tap away at the moment it matters.
|
|
19
|
+
*
|
|
20
|
+
* It opens upwards, the body above its own row, so the row and the Buy
|
|
21
|
+
* button under it stay where the thumb already is. Hidden from the small
|
|
22
|
+
* breakpoint up, where the summary column is always open.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type MobileCheckoutBottomBarProps = {
|
|
26
|
+
/** The SDK transport, for the doors inside the opened body. */
|
|
27
|
+
client: StorefrontClient
|
|
28
|
+
cart: Cart
|
|
29
|
+
optimisticShippingCost: number | null
|
|
30
|
+
optimisticMethodFee?: number | null
|
|
31
|
+
methodFeeLabel?: string
|
|
32
|
+
showGiftCards?: boolean
|
|
33
|
+
/** Hide the promo door inside the body; see MobileOrderSummaryBody. */
|
|
34
|
+
hideDiscount?: boolean
|
|
35
|
+
onCartChange?: (cart: Cart) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function MobileCheckoutBottomBar({
|
|
39
|
+
client,
|
|
40
|
+
cart,
|
|
41
|
+
optimisticShippingCost,
|
|
42
|
+
optimisticMethodFee,
|
|
43
|
+
methodFeeLabel,
|
|
44
|
+
showGiftCards,
|
|
45
|
+
hideDiscount,
|
|
46
|
+
onCartChange,
|
|
47
|
+
}: MobileCheckoutBottomBarProps) {
|
|
48
|
+
const labels = useCheckoutLabels()
|
|
49
|
+
const [open, setOpen] = useState(false)
|
|
50
|
+
const { displayTotal, itemCount, productItems } = checkoutTotals({
|
|
51
|
+
cart,
|
|
52
|
+
optimisticShippingCost,
|
|
53
|
+
optimisticMethodFee,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const firstItem = productItems[0]
|
|
57
|
+
if (!firstItem) return null
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="sm:hidden rounded-[2px] border border-border bg-card overflow-hidden">
|
|
61
|
+
{open && (
|
|
62
|
+
<div className="px-5 pt-5 pb-2">
|
|
63
|
+
<MobileOrderSummaryBody
|
|
64
|
+
client={client}
|
|
65
|
+
cart={cart}
|
|
66
|
+
optimisticShippingCost={optimisticShippingCost}
|
|
67
|
+
optimisticMethodFee={optimisticMethodFee}
|
|
68
|
+
methodFeeLabel={methodFeeLabel}
|
|
69
|
+
showGiftCards={showGiftCards}
|
|
70
|
+
hideDiscount={hideDiscount}
|
|
71
|
+
onCartChange={onCartChange}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => setOpen((v) => !v)}
|
|
79
|
+
className={cn(
|
|
80
|
+
"w-full flex items-center gap-3 px-4 py-3 text-left",
|
|
81
|
+
open && "border-t border-border"
|
|
82
|
+
)}
|
|
83
|
+
aria-expanded={open}
|
|
84
|
+
>
|
|
85
|
+
<div className="relative flex-shrink-0">
|
|
86
|
+
<div className="w-12 h-12 rounded-lg overflow-hidden bg-muted border border-border">
|
|
87
|
+
{firstItem.thumbnail ? (
|
|
88
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
89
|
+
<img
|
|
90
|
+
src={firstItem.thumbnail}
|
|
91
|
+
alt={firstItem.product_title ?? ""}
|
|
92
|
+
className="w-full h-full object-cover"
|
|
93
|
+
/>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
{itemCount > 1 && (
|
|
97
|
+
<span className="absolute -top-1.5 -right-1.5 min-w-[20px] h-[20px] px-1 text-[10px] font-bold rounded-full bg-foreground text-card flex items-center justify-center shadow-sm ring-2 ring-card">
|
|
98
|
+
{itemCount}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="flex-1 min-w-0">
|
|
104
|
+
<p className="text-base font-bold text-foreground leading-tight">{labels.total}</p>
|
|
105
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
106
|
+
{itemCount} {labels.items}
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className="flex items-center gap-1.5">
|
|
111
|
+
<Price
|
|
112
|
+
amount={displayTotal}
|
|
113
|
+
currencyCode={cart.currency_code}
|
|
114
|
+
className="text-base font-bold text-foreground tracking-tight"
|
|
115
|
+
/>
|
|
116
|
+
<svg
|
|
117
|
+
className={cn(
|
|
118
|
+
"w-4 h-4 text-muted-foreground transition-transform duration-200",
|
|
119
|
+
open && "rotate-180"
|
|
120
|
+
)}
|
|
121
|
+
fill="none"
|
|
122
|
+
viewBox="0 0 24 24"
|
|
123
|
+
stroke="currentColor"
|
|
124
|
+
strokeWidth={2.25}
|
|
125
|
+
>
|
|
126
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
127
|
+
</svg>
|
|
128
|
+
</div>
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import type { StorefrontClient } from "../api/http"
|
|
6
|
+
import type { Cart } from "../api/carts"
|
|
7
|
+
import { Price } from "../lib/price"
|
|
8
|
+
import { cn } from "../lib/utils"
|
|
9
|
+
import { useCheckoutLabels } from "./context"
|
|
10
|
+
import { MobileOrderSummaryBody } from "./mobile-order-summary-body"
|
|
11
|
+
import { checkoutTotals } from "./summary-math"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* MobileCheckoutTopBar — the strip a phone shopper meets first, above the
|
|
15
|
+
* contact form: the words "Order summary", a chevron, and the total. It
|
|
16
|
+
* opens onto the items and the breakdown.
|
|
17
|
+
*
|
|
18
|
+
* Closed by default, because the first thing a shopper checks is the
|
|
19
|
+
* figure, and only then, if they want, what makes it up. The desktop
|
|
20
|
+
* column is always open, so this bar is hidden from the small breakpoint
|
|
21
|
+
* up: the two never show at once.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type MobileCheckoutTopBarProps = {
|
|
25
|
+
/** The SDK transport, for the doors inside the opened body. */
|
|
26
|
+
client: StorefrontClient
|
|
27
|
+
cart: Cart
|
|
28
|
+
optimisticShippingCost: number | null
|
|
29
|
+
optimisticMethodFee?: number | null
|
|
30
|
+
methodFeeLabel?: string
|
|
31
|
+
showGiftCards?: boolean
|
|
32
|
+
onCartChange?: (cart: Cart) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function MobileCheckoutTopBar({
|
|
36
|
+
client,
|
|
37
|
+
cart,
|
|
38
|
+
optimisticShippingCost,
|
|
39
|
+
optimisticMethodFee,
|
|
40
|
+
methodFeeLabel,
|
|
41
|
+
showGiftCards,
|
|
42
|
+
onCartChange,
|
|
43
|
+
}: MobileCheckoutTopBarProps) {
|
|
44
|
+
const labels = useCheckoutLabels()
|
|
45
|
+
const [open, setOpen] = useState(false)
|
|
46
|
+
const { displayTotal } = checkoutTotals({
|
|
47
|
+
cart,
|
|
48
|
+
optimisticShippingCost,
|
|
49
|
+
optimisticMethodFee,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="sm:hidden bg-muted border-y border-border">
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => setOpen((v) => !v)}
|
|
57
|
+
className="w-full flex items-center justify-between px-5 py-3.5 text-left"
|
|
58
|
+
aria-expanded={open}
|
|
59
|
+
>
|
|
60
|
+
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-primary">
|
|
61
|
+
{labels.orderSummary}
|
|
62
|
+
<svg
|
|
63
|
+
className={cn("w-4 h-4 transition-transform duration-200", open && "rotate-180")}
|
|
64
|
+
fill="none"
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth={2.25}
|
|
68
|
+
>
|
|
69
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
70
|
+
</svg>
|
|
71
|
+
</span>
|
|
72
|
+
<Price
|
|
73
|
+
amount={displayTotal}
|
|
74
|
+
currencyCode={cart.currency_code}
|
|
75
|
+
className="text-base font-bold text-foreground tracking-tight"
|
|
76
|
+
/>
|
|
77
|
+
</button>
|
|
78
|
+
|
|
79
|
+
{open && (
|
|
80
|
+
<div className="px-5 pb-5">
|
|
81
|
+
<MobileOrderSummaryBody
|
|
82
|
+
client={client}
|
|
83
|
+
cart={cart}
|
|
84
|
+
optimisticShippingCost={optimisticShippingCost}
|
|
85
|
+
optimisticMethodFee={optimisticMethodFee}
|
|
86
|
+
methodFeeLabel={methodFeeLabel}
|
|
87
|
+
showGiftCards={showGiftCards}
|
|
88
|
+
onCartChange={onCartChange}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { StorefrontClient } from "../api/http"
|
|
4
|
+
import type { Cart } from "../api/carts"
|
|
5
|
+
import { Price } from "../lib/price"
|
|
6
|
+
import { useCheckoutLabels } from "./context"
|
|
7
|
+
import { DiscountSection } from "./discount-section"
|
|
8
|
+
import { GiftCardSection } from "./gift-card-section"
|
|
9
|
+
import { CheckoutLineItem } from "./order-summary"
|
|
10
|
+
import { checkoutTotals, isCompanyOrder } from "./summary-math"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* MobileOrderSummaryBody — what the two mobile summary bars reveal when a
|
|
14
|
+
* shopper opens them: the items with their counts, the discount and
|
|
15
|
+
* gift-card doors, and the same breakdown the desktop column prints.
|
|
16
|
+
*
|
|
17
|
+
* It carries no card of its own: the top bar is a utility strip and the
|
|
18
|
+
* bottom bar is a product card, so each parent decides its own ground. The
|
|
19
|
+
* grand total is not repeated here either, because the bar's own row shows
|
|
20
|
+
* it whether the body is open or closed.
|
|
21
|
+
*
|
|
22
|
+
* The numbers come from `checkoutTotals`, the same function the desktop
|
|
23
|
+
* summary calls, so the three surfaces cannot drift apart.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
type MobileOrderSummaryBodyProps = {
|
|
27
|
+
/** The SDK transport — the promo and gift-card doors run through it. */
|
|
28
|
+
client: StorefrontClient
|
|
29
|
+
cart: Cart
|
|
30
|
+
optimisticShippingCost: number | null
|
|
31
|
+
optimisticMethodFee?: number | null
|
|
32
|
+
/** Override for the fee row's name; the cart's own label otherwise. */
|
|
33
|
+
methodFeeLabel?: string
|
|
34
|
+
/** Show the gift-card door. Default true, as on the desktop summary. */
|
|
35
|
+
showGiftCards?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Hide the promo door inside this body. A layout that surfaces an
|
|
38
|
+
* always-open discount field of its own (the checkout does this on the
|
|
39
|
+
* phone, right above the bottom bar) passes true so the shopper is not
|
|
40
|
+
* offered the same thing twice.
|
|
41
|
+
*/
|
|
42
|
+
hideDiscount?: boolean
|
|
43
|
+
onCartChange?: (cart: Cart) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function MobileOrderSummaryBody({
|
|
47
|
+
client,
|
|
48
|
+
cart,
|
|
49
|
+
optimisticShippingCost,
|
|
50
|
+
optimisticMethodFee,
|
|
51
|
+
methodFeeLabel,
|
|
52
|
+
showGiftCards = true,
|
|
53
|
+
hideDiscount = false,
|
|
54
|
+
onCartChange,
|
|
55
|
+
}: MobileOrderSummaryBodyProps) {
|
|
56
|
+
const labels = useCheckoutLabels()
|
|
57
|
+
const t = checkoutTotals({ cart, optimisticShippingCost, optimisticMethodFee })
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="space-y-4 pt-4">
|
|
61
|
+
<div className="divide-y divide-border">
|
|
62
|
+
{t.productItems.map((item) => (
|
|
63
|
+
<div key={item.id} className="py-5 first:pt-0">
|
|
64
|
+
<CheckoutLineItem
|
|
65
|
+
item={item}
|
|
66
|
+
currencyCode={cart.currency_code}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{(!hideDiscount || showGiftCards) && (
|
|
73
|
+
<div className="space-y-3">
|
|
74
|
+
{!hideDiscount && (
|
|
75
|
+
<DiscountSection client={client} cart={cart} onCartChange={onCartChange} />
|
|
76
|
+
)}
|
|
77
|
+
{showGiftCards && (
|
|
78
|
+
<GiftCardSection client={client} cart={cart} onCartChange={onCartChange} />
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
<div className="space-y-2 pt-2">
|
|
84
|
+
<div className="flex justify-between text-sm">
|
|
85
|
+
<span className="text-muted-foreground">
|
|
86
|
+
{labels.subtotal} · {t.itemCount} {labels.items}
|
|
87
|
+
</span>
|
|
88
|
+
<Price
|
|
89
|
+
amount={t.productSubtotal}
|
|
90
|
+
currencyCode={cart.currency_code}
|
|
91
|
+
className="font-medium text-foreground"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="flex justify-between text-sm">
|
|
96
|
+
<span className="text-muted-foreground">{labels.shipping}</span>
|
|
97
|
+
{t.shippingKnown ? (
|
|
98
|
+
t.shippingCost === 0 ? (
|
|
99
|
+
<span className="font-medium text-success">{labels.shippingFree}</span>
|
|
100
|
+
) : (
|
|
101
|
+
<Price
|
|
102
|
+
amount={t.shippingCost ?? 0}
|
|
103
|
+
currencyCode={cart.currency_code}
|
|
104
|
+
className="font-medium text-foreground"
|
|
105
|
+
/>
|
|
106
|
+
)
|
|
107
|
+
) : (
|
|
108
|
+
<span className="font-medium text-foreground">{labels.shippingCalc}</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{t.methodFeeAmount > 0 && (
|
|
113
|
+
<div className="flex justify-between text-sm">
|
|
114
|
+
<span className="text-muted-foreground">
|
|
115
|
+
{methodFeeLabel ?? cart.payment_method_fee_label ?? labels.paymentMethodFee}
|
|
116
|
+
</span>
|
|
117
|
+
<Price
|
|
118
|
+
amount={t.methodFeeAmount}
|
|
119
|
+
currencyCode={cart.currency_code}
|
|
120
|
+
className="font-medium text-foreground"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{!!cart.discount_total && (
|
|
126
|
+
<div className="flex justify-between text-sm text-success">
|
|
127
|
+
<span>{labels.discount}</span>
|
|
128
|
+
<span className="font-medium">
|
|
129
|
+
-
|
|
130
|
+
<Price amount={cart.discount_total} currencyCode={cart.currency_code} />
|
|
131
|
+
</span>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{/* For a company, and only when the figure is known: a consumer
|
|
136
|
+
sees the price they pay, VAT included (see OrderSummary). */}
|
|
137
|
+
{isCompanyOrder(cart) && (cart.tax_total ?? 0) > 0 && (
|
|
138
|
+
<div className="flex justify-between text-sm">
|
|
139
|
+
<span className="text-muted-foreground">{labels.tax}</span>
|
|
140
|
+
<Price
|
|
141
|
+
amount={cart.tax_total ?? 0}
|
|
142
|
+
currencyCode={cart.currency_code}
|
|
143
|
+
className="font-medium text-foreground"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{/* Tender, not a reduction: the gift card covers part of the total
|
|
149
|
+
the bar shows, it does not lower it (docs/storefront/gift-cards.md). */}
|
|
150
|
+
{t.giftCardTotal > 0 && (
|
|
151
|
+
<>
|
|
152
|
+
<div className="flex justify-between text-sm text-success">
|
|
153
|
+
<span>{labels.giftCardApplied}</span>
|
|
154
|
+
<span className="font-medium">
|
|
155
|
+
-
|
|
156
|
+
<Price amount={t.giftCardTotal} currencyCode={cart.currency_code} />
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="flex justify-between text-sm">
|
|
160
|
+
<span className="text-muted-foreground">{labels.giftCardRemaining}</span>
|
|
161
|
+
<Price
|
|
162
|
+
amount={t.giftCardRemainder}
|
|
163
|
+
currencyCode={cart.currency_code}
|
|
164
|
+
className="font-semibold text-foreground"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
</>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|