@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
@@ -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
+ }