@cartbase/storefront 0.1.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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- package/tailwind-preset.cjs +72 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { StorefrontClient } from "../api/http"
|
|
4
|
+
import type { StoreShippingOption } from "../api/checkout"
|
|
5
|
+
import type { BoxNowLocker } from "../api/integrations"
|
|
6
|
+
import { DualPrice } from "../lib/dual-price"
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
import { useCheckoutLabels } from "./context"
|
|
9
|
+
import {
|
|
10
|
+
EcontOfficeSelector,
|
|
11
|
+
type EcontOffice,
|
|
12
|
+
} from "./econt-office-selector"
|
|
13
|
+
import { BoxNowLockerSelector } from "./boxnow-locker-selector"
|
|
14
|
+
import { ErrorMessage } from "./error-message"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* CheckoutShippingMethodList — radio-style list of shipping options.
|
|
18
|
+
* When the user selects an Econt-office / BoxNow-locker option, the
|
|
19
|
+
* matching picker expands inline inside that row.
|
|
20
|
+
*
|
|
21
|
+
* Ported from `@1click/ui/src/checkout/shipping-method-list.tsx` (v2.3.1)
|
|
22
|
+
* with the barter data seam: options are `StoreShippingOption` rows from
|
|
23
|
+
* `checkout.listShippingOptions(client, {cart_id})` — rule-filtered and
|
|
24
|
+
* display-ordered SERVER-SIDE (checkout rules + `checkout_method_order`,
|
|
25
|
+
* docs/storefront/checkout.md); this component only renders what arrives.
|
|
26
|
+
* The BoxNow picker needs the SDK transport (its locker directory is a
|
|
27
|
+
* store-API call), so the `boxnow` config gains a `client`.
|
|
28
|
+
*
|
|
29
|
+
* Detection uses the stable fulfillment-option id set by the backend
|
|
30
|
+
* provider (`shipping_option.data.id`) — NOT the display name. Admins
|
|
31
|
+
* rename options freely, and Bulgarian labels overlap ("До точен адрес с
|
|
32
|
+
* ЕКОНТ" contains "еконт" but is address delivery, not office).
|
|
33
|
+
*
|
|
34
|
+
* Presentational — state + handlers come from the orchestration hook.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
type CheckoutShippingMethodListProps = {
|
|
38
|
+
shippingMethods: StoreShippingOption[]
|
|
39
|
+
selectedShippingMethodId: string | null
|
|
40
|
+
calculatedPricesMap: Record<string, number>
|
|
41
|
+
isLoadingPrices: boolean
|
|
42
|
+
shippingLoading: boolean
|
|
43
|
+
shippingError: string | null
|
|
44
|
+
onSelect: (id: string) => void
|
|
45
|
+
addressReady: boolean
|
|
46
|
+
currencyCode: string
|
|
47
|
+
/** Optional: Econt office state + handler for inline-expand rows */
|
|
48
|
+
econt?: {
|
|
49
|
+
detect?: (option: StoreShippingOption) => boolean
|
|
50
|
+
selectedOffice: EcontOffice | null
|
|
51
|
+
onSelectOffice: (office: EcontOffice | null) => void
|
|
52
|
+
userCity: string
|
|
53
|
+
userAddress: string
|
|
54
|
+
}
|
|
55
|
+
/** Optional: BoxNow locker state + handler for inline-expand rows */
|
|
56
|
+
boxnow?: {
|
|
57
|
+
detect?: (option: StoreShippingOption) => boolean
|
|
58
|
+
/** SDK transport for the locker-directory fetch. */
|
|
59
|
+
client: StorefrontClient
|
|
60
|
+
selectedLocker: BoxNowLocker | null
|
|
61
|
+
onSelectLocker: (locker: BoxNowLocker | null) => void
|
|
62
|
+
userCity: string
|
|
63
|
+
userAddress: string
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Optional per-store carrier branding. Keyed by the stable fulfillment
|
|
67
|
+
* option id (shipping_option.data.id) — "econt-office", "boxnow-locker",
|
|
68
|
+
* etc. If a match is found, a small logo renders between the radio and
|
|
69
|
+
* the option name. Stores that don't supply a map get the unbranded
|
|
70
|
+
* (radio + text only) layout.
|
|
71
|
+
*/
|
|
72
|
+
logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
|
|
73
|
+
/** Show the shipping options as a read-only price PREVIEW before the
|
|
74
|
+
* address is entered, instead of the "enter your address" placeholder.
|
|
75
|
+
* Rows render with their names + (flat) prices + carrier logos but are
|
|
76
|
+
* non-selectable — a hint tells the shopper to enter their address to
|
|
77
|
+
* choose. For flat-priced stores this lets people see the cost up front
|
|
78
|
+
* (a common reason carts stall) without letting them pick a method that
|
|
79
|
+
* needs a city (Econt office / BoxNow locker). Default `false` keeps the
|
|
80
|
+
* classic address-gated placeholder for every existing store. */
|
|
81
|
+
previewWhenAddressNotReady?: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Detection uses the stable fulfillment-option id set by the backend
|
|
85
|
+
// provider (shipping_option.data.id) — NOT the display name.
|
|
86
|
+
const getFulfillmentOptionId = (
|
|
87
|
+
option: StoreShippingOption
|
|
88
|
+
): string | null => {
|
|
89
|
+
const data = option.data as { id?: string } | undefined | null
|
|
90
|
+
return typeof data?.id === "string" ? data.id : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const defaultEcontDetect = (option: StoreShippingOption): boolean => {
|
|
94
|
+
return getFulfillmentOptionId(option) === "econt-office"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const defaultBoxnowDetect = (option: StoreShippingOption): boolean => {
|
|
98
|
+
return getFulfillmentOptionId(option) === "boxnow-locker"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function CheckoutShippingMethodList({
|
|
102
|
+
shippingMethods,
|
|
103
|
+
selectedShippingMethodId,
|
|
104
|
+
calculatedPricesMap,
|
|
105
|
+
isLoadingPrices,
|
|
106
|
+
shippingLoading,
|
|
107
|
+
shippingError,
|
|
108
|
+
onSelect,
|
|
109
|
+
addressReady,
|
|
110
|
+
currencyCode,
|
|
111
|
+
econt,
|
|
112
|
+
boxnow,
|
|
113
|
+
logoByFulfillmentOptionId,
|
|
114
|
+
previewWhenAddressNotReady = false,
|
|
115
|
+
}: CheckoutShippingMethodListProps) {
|
|
116
|
+
const labels = useCheckoutLabels()
|
|
117
|
+
const detectEcont = econt?.detect ?? defaultEcontDetect
|
|
118
|
+
const detectBoxnow = boxnow?.detect ?? defaultBoxnowDetect
|
|
119
|
+
|
|
120
|
+
// Preview mode: address not filled yet, but the store wants the options
|
|
121
|
+
// shown as a read-only price preview rather than a placeholder. Rows are
|
|
122
|
+
// rendered but locked (no selection) — see `previewWhenAddressNotReady`.
|
|
123
|
+
const showPreview = !addressReady && previewWhenAddressNotReady
|
|
124
|
+
// Only the classic (non-preview) not-ready state dims + hides the list.
|
|
125
|
+
const gatePlaceholder = !addressReady && !previewWhenAddressNotReady
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className={cn(
|
|
130
|
+
"mt-8 transition-opacity duration-300",
|
|
131
|
+
gatePlaceholder && "opacity-30 pointer-events-none select-none"
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
<h2 className="text-lg font-semibold text-foreground mb-4 tracking-tight">
|
|
135
|
+
{labels.shippingServices}
|
|
136
|
+
</h2>
|
|
137
|
+
|
|
138
|
+
{gatePlaceholder ? (
|
|
139
|
+
<div className="p-4 bg-muted rounded-lg border border-border">
|
|
140
|
+
<p className="text-sm text-muted-foreground">{labels.deliveryDisabled}</p>
|
|
141
|
+
</div>
|
|
142
|
+
) : shippingMethods.length === 0 ? (
|
|
143
|
+
<div className="p-4 bg-muted rounded-lg border border-border">
|
|
144
|
+
<p className="text-sm text-muted-foreground">{labels.noShippingOptions}</p>
|
|
145
|
+
</div>
|
|
146
|
+
) : (
|
|
147
|
+
<div
|
|
148
|
+
className={cn(
|
|
149
|
+
showPreview &&
|
|
150
|
+
"rounded-[2px] border border-dashed border-border/80 bg-muted/40 px-3.5 pt-3 pb-3.5"
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{showPreview && (
|
|
154
|
+
<div className="flex items-center gap-2 mb-3">
|
|
155
|
+
<svg
|
|
156
|
+
viewBox="0 0 24 24"
|
|
157
|
+
fill="none"
|
|
158
|
+
stroke="currentColor"
|
|
159
|
+
strokeWidth={2}
|
|
160
|
+
className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
>
|
|
163
|
+
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
164
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
165
|
+
</svg>
|
|
166
|
+
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
|
167
|
+
{labels.deliveryPreviewHint}
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
<div
|
|
172
|
+
className={cn(
|
|
173
|
+
"space-y-2",
|
|
174
|
+
showPreview &&
|
|
175
|
+
"grayscale opacity-60 pointer-events-none select-none"
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
{shippingMethods.map((option) => {
|
|
179
|
+
// `cantCalc` fades a calculated-price option to opacity-40
|
|
180
|
+
// when its price never resolved. Critically gated on BOTH
|
|
181
|
+
// `!isLoadingPrices` AND `!shippingLoading` so we don't
|
|
182
|
+
// visually punish an option just because the user is
|
|
183
|
+
// mid-courier-switch — the in-flight switch would otherwise
|
|
184
|
+
// leave a brief window where prices are calculated=true but
|
|
185
|
+
// the new option's price hasn't landed in the map yet,
|
|
186
|
+
// making it look broken. (Barter serves flat prices today;
|
|
187
|
+
// the calculated branch is forward-compat —
|
|
188
|
+
// docs/storefront/checkout.md.)
|
|
189
|
+
const cantCalc =
|
|
190
|
+
(option.price_type as string) === "calculated" &&
|
|
191
|
+
!isLoadingPrices &&
|
|
192
|
+
!shippingLoading &&
|
|
193
|
+
typeof calculatedPricesMap[option.id] !== "number"
|
|
194
|
+
const selected = option.id === selectedShippingMethodId
|
|
195
|
+
const price =
|
|
196
|
+
option.price_type === "flat"
|
|
197
|
+
? option.amount
|
|
198
|
+
: calculatedPricesMap[option.id]
|
|
199
|
+
const isFree = price === 0
|
|
200
|
+
const isEcontOffice = econt && detectEcont(option)
|
|
201
|
+
const isBoxnowLocker =
|
|
202
|
+
boxnow && !isEcontOffice && detectBoxnow(option)
|
|
203
|
+
const hasExpanded = selected && (isEcontOffice || isBoxnowLocker)
|
|
204
|
+
const logo = logoByFulfillmentOptionId
|
|
205
|
+
? logoByFulfillmentOptionId[getFulfillmentOptionId(option) ?? ""]
|
|
206
|
+
: undefined
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
key={option.id}
|
|
211
|
+
className={cn(
|
|
212
|
+
"rounded-lg border overflow-hidden transition-colors duration-150",
|
|
213
|
+
selected
|
|
214
|
+
? "border-primary bg-primary/5"
|
|
215
|
+
: "border-border bg-card hover:border-muted-foreground"
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
disabled={cantCalc || shippingLoading || showPreview}
|
|
221
|
+
onClick={showPreview ? undefined : () => onSelect(option.id)}
|
|
222
|
+
className={cn(
|
|
223
|
+
"flex items-center w-full px-4 py-3.5 text-left",
|
|
224
|
+
cantCalc && "opacity-40 cursor-not-allowed",
|
|
225
|
+
showPreview && "cursor-default"
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
<div
|
|
229
|
+
className={cn(
|
|
230
|
+
"w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 mr-3 transition-colors",
|
|
231
|
+
selected ? "border-primary" : "border-border"
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
{selected && (
|
|
235
|
+
<div className="w-2 h-2 rounded-full bg-primary" />
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
{logo && (
|
|
239
|
+
<span
|
|
240
|
+
className="relative flex items-center justify-center w-10 h-7 mr-3 flex-shrink-0"
|
|
241
|
+
aria-hidden="true"
|
|
242
|
+
>
|
|
243
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
244
|
+
<img
|
|
245
|
+
src={logo.src}
|
|
246
|
+
alt={logo.alt}
|
|
247
|
+
className="max-w-full max-h-full object-contain"
|
|
248
|
+
/>
|
|
249
|
+
</span>
|
|
250
|
+
)}
|
|
251
|
+
<div className="flex-1 min-w-0">
|
|
252
|
+
<span className="text-sm font-medium text-foreground">
|
|
253
|
+
{option.name}
|
|
254
|
+
</span>
|
|
255
|
+
{selected && isEcontOffice && econt?.selectedOffice && (
|
|
256
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
257
|
+
{econt.selectedOffice.name}
|
|
258
|
+
</p>
|
|
259
|
+
)}
|
|
260
|
+
{selected && isBoxnowLocker && boxnow?.selectedLocker && (
|
|
261
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
262
|
+
{boxnow.selectedLocker.title}
|
|
263
|
+
</p>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
<span
|
|
267
|
+
className={cn(
|
|
268
|
+
"text-sm font-semibold ml-4 flex-shrink-0",
|
|
269
|
+
isFree ? "text-success" : "text-foreground"
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
{price !== undefined && price !== null ? (
|
|
273
|
+
isFree ? (
|
|
274
|
+
labels.shippingFree
|
|
275
|
+
) : (
|
|
276
|
+
<DualPrice amount={price} currencyCode={currencyCode} />
|
|
277
|
+
)
|
|
278
|
+
) : isLoadingPrices ? (
|
|
279
|
+
<svg
|
|
280
|
+
className="animate-spin w-4 h-4 text-muted-foreground"
|
|
281
|
+
fill="none"
|
|
282
|
+
viewBox="0 0 24 24"
|
|
283
|
+
>
|
|
284
|
+
<circle
|
|
285
|
+
className="opacity-25"
|
|
286
|
+
cx="12"
|
|
287
|
+
cy="12"
|
|
288
|
+
r="10"
|
|
289
|
+
stroke="currentColor"
|
|
290
|
+
strokeWidth="3"
|
|
291
|
+
/>
|
|
292
|
+
<path
|
|
293
|
+
className="opacity-75"
|
|
294
|
+
fill="currentColor"
|
|
295
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
296
|
+
/>
|
|
297
|
+
</svg>
|
|
298
|
+
) : (
|
|
299
|
+
"-"
|
|
300
|
+
)}
|
|
301
|
+
</span>
|
|
302
|
+
</button>
|
|
303
|
+
|
|
304
|
+
{hasExpanded && isEcontOffice && econt && (
|
|
305
|
+
<EcontOfficeSelector
|
|
306
|
+
userCity={econt.userCity}
|
|
307
|
+
userAddress={econt.userAddress}
|
|
308
|
+
selectedOffice={econt.selectedOffice}
|
|
309
|
+
onSelect={econt.onSelectOffice}
|
|
310
|
+
/>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{hasExpanded && isBoxnowLocker && boxnow && (
|
|
314
|
+
<BoxNowLockerSelector
|
|
315
|
+
client={boxnow.client}
|
|
316
|
+
userCity={boxnow.userCity}
|
|
317
|
+
userAddress={boxnow.userAddress}
|
|
318
|
+
selectedLocker={boxnow.selectedLocker}
|
|
319
|
+
onSelect={boxnow.onSelectLocker}
|
|
320
|
+
/>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
324
|
+
})}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
<ErrorMessage
|
|
330
|
+
error={shippingError}
|
|
331
|
+
data-testid="delivery-error-message"
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Elements, useElements } from "@stripe/react-stripe-js"
|
|
4
|
+
import type {
|
|
5
|
+
Appearance,
|
|
6
|
+
Stripe,
|
|
7
|
+
StripeElementsOptions,
|
|
8
|
+
} from "@stripe/stripe-js"
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
useContext,
|
|
12
|
+
useEffect,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from "react"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stripe Elements scope — context + scoped wrapper, **deferred-intent** mode.
|
|
18
|
+
*
|
|
19
|
+
* Ported from `@1click/ui/src/checkout/stripe-wrapper.tsx` (v2.3.1)
|
|
20
|
+
* unchanged — no data seam; the PaymentIntent seam moved server-side into
|
|
21
|
+
* barter's POST /api/store/carts/:id/prepare-checkout.
|
|
22
|
+
*
|
|
23
|
+
* `<Elements>` is mounted with `mode: 'payment'`, `amount`, `currency` — NO
|
|
24
|
+
* PaymentIntent exists on the backend yet. The PI is minted at Buy click by
|
|
25
|
+
* `prepareCheckout` (idempotency key = session id, docs/storefront/
|
|
26
|
+
* checkout.md); the storefront then calls
|
|
27
|
+
* `stripe.confirmPayment({ elements, clientSecret })`.
|
|
28
|
+
*
|
|
29
|
+
* Why deferred-intent (verified against Stripe docs).
|
|
30
|
+
*
|
|
31
|
+
* The previous eager-session model created a PaymentIntent on payment-
|
|
32
|
+
* tab switch. Every cart-amount change rotated the PI, forcing
|
|
33
|
+
* `<Elements>` to remount on a new client_secret and blanking the
|
|
34
|
+
* user's typed-in card form.
|
|
35
|
+
*
|
|
36
|
+
* Stripe's deferred-intent flow
|
|
37
|
+
* (https://docs.stripe.com/payments/accept-a-payment-deferred) lets
|
|
38
|
+
* `<Elements>` mount with just `mode/amount/currency`. When the
|
|
39
|
+
* amount changes (shipping switch, COD-fee toggle), call
|
|
40
|
+
* `elements.update({ amount })` and the iframe updates in place — no
|
|
41
|
+
* remount, no card-form blank-out.
|
|
42
|
+
*
|
|
43
|
+
* Boolean `StripeContext` is preserved for back-compat (consumers like
|
|
44
|
+
* payment-method-list.tsx read it as `stripeReady`). It derives from the
|
|
45
|
+
* scope's `ready` flag (Stripe.js promise + valid key).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
// Boolean context kept for the existing `useContext(StripeContext)`
|
|
49
|
+
// usage in payment-method-list.tsx and any storefront-side consumers.
|
|
50
|
+
export const StripeContext = createContext(false)
|
|
51
|
+
|
|
52
|
+
type StripeScopeValue = {
|
|
53
|
+
/** True when Stripe.js promise + publishable key are available. */
|
|
54
|
+
ready: boolean
|
|
55
|
+
stripePromise: Promise<Stripe | null> | null
|
|
56
|
+
/** Optimistic total in the smallest currency unit (cents/stotinki). */
|
|
57
|
+
amount: number
|
|
58
|
+
currency: string
|
|
59
|
+
appearance?: Appearance
|
|
60
|
+
fonts?: StripeElementsOptions["fonts"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const StripeScopeContext = createContext<StripeScopeValue>({
|
|
64
|
+
ready: false,
|
|
65
|
+
stripePromise: null,
|
|
66
|
+
amount: 0,
|
|
67
|
+
currency: "eur",
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export function StripeScopeProvider({
|
|
71
|
+
value,
|
|
72
|
+
children,
|
|
73
|
+
}: {
|
|
74
|
+
value: StripeScopeValue
|
|
75
|
+
children: ReactNode
|
|
76
|
+
}) {
|
|
77
|
+
return (
|
|
78
|
+
<StripeContext.Provider value={value.ready}>
|
|
79
|
+
<StripeScopeContext.Provider value={value}>
|
|
80
|
+
{children}
|
|
81
|
+
</StripeScopeContext.Provider>
|
|
82
|
+
</StripeContext.Provider>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useStripeScope(): StripeScopeValue {
|
|
87
|
+
return useContext(StripeScopeContext)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* StripeElementsScope — wraps children in Stripe's `<Elements>` provider
|
|
92
|
+
* in deferred-intent mode. Children mount inside `<Elements>` so
|
|
93
|
+
* `useStripe()` / `useElements()` work for the payment widget AND the
|
|
94
|
+
* Buy button.
|
|
95
|
+
*
|
|
96
|
+
* Behaviour when Stripe isn't ready (no stripePromise / no key):
|
|
97
|
+
* - default → renders `fallback` (or null)
|
|
98
|
+
* - `passthrough` mode → renders children directly, without `<Elements>`
|
|
99
|
+
*
|
|
100
|
+
* Use `passthrough` when wrapping a section that contains BOTH Stripe-
|
|
101
|
+
* dependent UI (PaymentElement) AND non-Stripe UI (a manual / COD
|
|
102
|
+
* branch). Without it, COD-only carts (no Stripe key) would render
|
|
103
|
+
* nothing inside the wrapper.
|
|
104
|
+
*
|
|
105
|
+
* Amount syncing: the inner `<AmountSync>` calls `elements.update({ amount })`
|
|
106
|
+
* whenever the optimistic total changes. No remount.
|
|
107
|
+
*/
|
|
108
|
+
export function StripeElementsScope({
|
|
109
|
+
fallback = null,
|
|
110
|
+
passthrough = false,
|
|
111
|
+
children,
|
|
112
|
+
}: {
|
|
113
|
+
fallback?: ReactNode
|
|
114
|
+
passthrough?: boolean
|
|
115
|
+
children: ReactNode
|
|
116
|
+
}) {
|
|
117
|
+
const { ready, stripePromise, amount, currency, appearance, fonts } =
|
|
118
|
+
useStripeScope()
|
|
119
|
+
|
|
120
|
+
if (!ready || !stripePromise) {
|
|
121
|
+
return <>{passthrough ? children : fallback}</>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `loader: "always"` forces Elements to fetch the payment-method config
|
|
125
|
+
// up front so a config error (bad key, network) surfaces via
|
|
126
|
+
// onLoadError instead of silently failing on first interaction.
|
|
127
|
+
const options: StripeElementsOptions = {
|
|
128
|
+
mode: "payment",
|
|
129
|
+
amount,
|
|
130
|
+
currency: currency.toLowerCase(),
|
|
131
|
+
appearance,
|
|
132
|
+
fonts,
|
|
133
|
+
loader: "always",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Elements options={options} stripe={stripePromise}>
|
|
138
|
+
<AmountSync amount={amount} currency={currency} />
|
|
139
|
+
{children}
|
|
140
|
+
</Elements>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Empty component that calls `elements.update({ amount, currency })`
|
|
146
|
+
* whenever the props change. Stripe's `elements.update` is the
|
|
147
|
+
* documented way to push amount/currency changes into a deferred-intent
|
|
148
|
+
* `<Elements>` instance without remounting it.
|
|
149
|
+
*
|
|
150
|
+
* Reference: https://docs.stripe.com/payments/accept-a-payment-deferred
|
|
151
|
+
*/
|
|
152
|
+
function AmountSync({
|
|
153
|
+
amount,
|
|
154
|
+
currency,
|
|
155
|
+
}: {
|
|
156
|
+
amount: number
|
|
157
|
+
currency: string
|
|
158
|
+
}) {
|
|
159
|
+
const elements = useElements()
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!elements) return
|
|
162
|
+
elements.update({ amount, currency: currency.toLowerCase() })
|
|
163
|
+
}, [elements, amount, currency])
|
|
164
|
+
return null
|
|
165
|
+
}
|