@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,225 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { CartDrawer } from "./cart-drawer"
|
|
6
|
+
import { CartDrawerHeader } from "./header"
|
|
7
|
+
import { CartPromoBanner } from "./promo-banner"
|
|
8
|
+
import { CartTieredProgress, type CartTier } from "./tiered-progress"
|
|
9
|
+
import { CartItem } from "./item"
|
|
10
|
+
import { CartFreeGift } from "./free-gift"
|
|
11
|
+
import { CartCrossSellCarousel, type CrossSellCarouselProduct } from "./cross-sell-carousel"
|
|
12
|
+
import { CartCrossSellSidebar, type CrossSellSidebarProduct } from "./cross-sell-sidebar"
|
|
13
|
+
import { CartGiftWrap } from "./gift-wrap"
|
|
14
|
+
import { CartNotes } from "./notes"
|
|
15
|
+
import { CartRewardsPoints } from "./rewards-points"
|
|
16
|
+
import { CartStickyFooter } from "./sticky-footer"
|
|
17
|
+
import { CartEmpty } from "./empty"
|
|
18
|
+
import { useCartDrawer } from "./context"
|
|
19
|
+
import { isProductLine } from "../lib/cart-helpers"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CartDrawerTemplate — optional default assembly that composes every cart
|
|
23
|
+
* drawer primitive into the mindpages-style layout. Every feature is
|
|
24
|
+
* opt-in via a `CartDrawerConfig` object. Pass no config and you get a
|
|
25
|
+
* minimal drawer (items + sticky footer only).
|
|
26
|
+
*
|
|
27
|
+
* Stores that want a different layout should NOT use this template.
|
|
28
|
+
* Instead, import the primitives directly and compose their own drawer
|
|
29
|
+
* body inside a `<CartDrawer>` shell. That way the store owns its
|
|
30
|
+
* composition and still gets library updates to every primitive.
|
|
31
|
+
*
|
|
32
|
+
* Ported from `@1click/ui/src/cart-drawer/template.tsx` (v2.3.1). Barter
|
|
33
|
+
* adaptations:
|
|
34
|
+
* - Amounts are EUR major units — `rewards.pointsPerCurrency` is points
|
|
35
|
+
* per 1 EUR (the source's `/100` minor-unit division is gone), and
|
|
36
|
+
* `freeGift.minCartTotal` is EUR (e.g. 50 = €50).
|
|
37
|
+
* - Cross-sell `onAdd` is wired to the provider's SDK `addItem(variantId)`
|
|
38
|
+
* automatically (the source left it unwired) — build the products via
|
|
39
|
+
* `toCrossSellProduct` so `variantId` is set; cards without it no-op.
|
|
40
|
+
* - `crossSell.loader` example (per-store choice of source):
|
|
41
|
+
* `async () => (await listProducts(client, { collection_id: [id],
|
|
42
|
+
* currency_code: "eur", limit: 6 })).products
|
|
43
|
+
* .map(toCrossSellProduct).filter(Boolean)`
|
|
44
|
+
* or `listRelatedProducts(client, anchorHandle, { currency_code: "eur" })`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export type CartDrawerConfig = {
|
|
48
|
+
promoBanner?: {
|
|
49
|
+
message: string
|
|
50
|
+
variant?: "info" | "success" | "warning"
|
|
51
|
+
}
|
|
52
|
+
shippingTiers?: CartTier[]
|
|
53
|
+
freeGift?: {
|
|
54
|
+
title: string
|
|
55
|
+
description?: string
|
|
56
|
+
/** EUR major units. */
|
|
57
|
+
originalPrice: number
|
|
58
|
+
thumbnail?: string | null
|
|
59
|
+
/** Only render when the cart subtotal exceeds this amount (EUR). */
|
|
60
|
+
minCartTotal?: number
|
|
61
|
+
}
|
|
62
|
+
giftWrap?: {
|
|
63
|
+
/** EUR major units. */
|
|
64
|
+
price: number
|
|
65
|
+
}
|
|
66
|
+
notes?: {
|
|
67
|
+
types: ("gift" | "order")[]
|
|
68
|
+
}
|
|
69
|
+
rewards?: {
|
|
70
|
+
/** Points earned per 1 EUR of subtotal (major units). */
|
|
71
|
+
pointsPerCurrency: number
|
|
72
|
+
label?: string
|
|
73
|
+
details?: string
|
|
74
|
+
}
|
|
75
|
+
crossSell?: {
|
|
76
|
+
/** Provide your own products; pass empty array or omit to disable */
|
|
77
|
+
products?: (CrossSellSidebarProduct | CrossSellCarouselProduct)[]
|
|
78
|
+
/** Async loader called once the drawer has items. Return an empty array on failure. */
|
|
79
|
+
loader?: () => Promise<CrossSellSidebarProduct[]>
|
|
80
|
+
label?: string
|
|
81
|
+
}
|
|
82
|
+
showContinueShopping?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type CartDrawerTemplateProps = {
|
|
86
|
+
config?: CartDrawerConfig
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function CartDrawerTemplate({
|
|
90
|
+
config = {},
|
|
91
|
+
}: CartDrawerTemplateProps) {
|
|
92
|
+
const { cart, addItem } = useCartDrawer()
|
|
93
|
+
// Cart drawer is pre-checkout / shopping context — render PRODUCT
|
|
94
|
+
// lines only. Backend-injected fee lines (COD fee, future surcharges)
|
|
95
|
+
// are excluded by `isProductLine`. The same predicate gates `hasItems`
|
|
96
|
+
// so a cart containing only a fee (edge case) renders empty rather
|
|
97
|
+
// than showing a single fake-product row.
|
|
98
|
+
const items = (cart?.items || []).filter(isProductLine)
|
|
99
|
+
const hasItems = items.length > 0
|
|
100
|
+
const currencyCode = cart?.currency_code || "eur"
|
|
101
|
+
|
|
102
|
+
// Subtotal is EUR major units (server truth) — points per 1 EUR.
|
|
103
|
+
const rewardsPoints = config.rewards
|
|
104
|
+
? Math.floor((cart?.subtotal ?? 0) * config.rewards.pointsPerCurrency)
|
|
105
|
+
: 0
|
|
106
|
+
|
|
107
|
+
const showFreeGift =
|
|
108
|
+
config.freeGift &&
|
|
109
|
+
(cart?.subtotal ?? 0) >= (config.freeGift.minCartTotal ?? 0)
|
|
110
|
+
|
|
111
|
+
// Cross-sell loader — async, only fires once the drawer has items.
|
|
112
|
+
// Empty/failure state shows nothing (no fake fallback products).
|
|
113
|
+
const [crossSell, setCrossSell] = useState<CrossSellSidebarProduct[]>(
|
|
114
|
+
(config.crossSell?.products as CrossSellSidebarProduct[]) || []
|
|
115
|
+
)
|
|
116
|
+
const [crossSellLoaded, setCrossSellLoaded] = useState(
|
|
117
|
+
Boolean(config.crossSell?.products)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!hasItems || crossSellLoaded) return
|
|
122
|
+
const loader = config.crossSell?.loader
|
|
123
|
+
if (!loader) {
|
|
124
|
+
setCrossSellLoaded(true)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
loader()
|
|
128
|
+
.then((products) => setCrossSell(products ?? []))
|
|
129
|
+
.catch(() => setCrossSell([]))
|
|
130
|
+
.finally(() => setCrossSellLoaded(true))
|
|
131
|
+
}, [hasItems, crossSellLoaded, config.crossSell?.loader])
|
|
132
|
+
|
|
133
|
+
// Cross-sell add → the provider's SDK addItem (barter adds by variant
|
|
134
|
+
// id). Cards built without a variantId no-op.
|
|
135
|
+
const handleCrossSellAdd = useCallback(
|
|
136
|
+
(_productId: string, variantId?: string) => {
|
|
137
|
+
if (variantId) void addItem(variantId, 1)
|
|
138
|
+
},
|
|
139
|
+
[addItem]
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<CartDrawer
|
|
144
|
+
sidebar={
|
|
145
|
+
hasItems && crossSell.length > 0 ? (
|
|
146
|
+
<CartCrossSellSidebar
|
|
147
|
+
products={crossSell}
|
|
148
|
+
label={config.crossSell?.label}
|
|
149
|
+
onAdd={handleCrossSellAdd}
|
|
150
|
+
/>
|
|
151
|
+
) : undefined
|
|
152
|
+
}
|
|
153
|
+
>
|
|
154
|
+
<CartDrawerHeader />
|
|
155
|
+
|
|
156
|
+
{hasItems ? (
|
|
157
|
+
<>
|
|
158
|
+
{config.promoBanner && (
|
|
159
|
+
<CartPromoBanner
|
|
160
|
+
message={config.promoBanner.message}
|
|
161
|
+
variant={config.promoBanner.variant}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{config.shippingTiers && (
|
|
166
|
+
<CartTieredProgress
|
|
167
|
+
tiers={config.shippingTiers}
|
|
168
|
+
currencyCode={currencyCode}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
<div className="flex-1 overflow-y-auto no-scrollbar relative z-0 bg-card">
|
|
173
|
+
{items
|
|
174
|
+
.slice()
|
|
175
|
+
.sort((a, b) =>
|
|
176
|
+
(a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
|
|
177
|
+
)
|
|
178
|
+
.map((item) => (
|
|
179
|
+
<CartItem key={item.id} item={item} currencyCode={currencyCode} />
|
|
180
|
+
))}
|
|
181
|
+
|
|
182
|
+
{showFreeGift && config.freeGift && (
|
|
183
|
+
<CartFreeGift
|
|
184
|
+
title={config.freeGift.title}
|
|
185
|
+
description={config.freeGift.description}
|
|
186
|
+
originalPrice={config.freeGift.originalPrice}
|
|
187
|
+
currencyCode={currencyCode}
|
|
188
|
+
thumbnail={config.freeGift.thumbnail}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{config.rewards && rewardsPoints > 0 && (
|
|
193
|
+
<CartRewardsPoints
|
|
194
|
+
points={rewardsPoints}
|
|
195
|
+
label={config.rewards.label}
|
|
196
|
+
details={config.rewards.details}
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{config.giftWrap && (
|
|
201
|
+
<CartGiftWrap
|
|
202
|
+
price={config.giftWrap.price}
|
|
203
|
+
currencyCode={currencyCode}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{config.notes && <CartNotes types={config.notes.types} />}
|
|
208
|
+
|
|
209
|
+
{config.crossSell && crossSell.length > 0 && (
|
|
210
|
+
<CartCrossSellCarousel
|
|
211
|
+
label={config.crossSell.label}
|
|
212
|
+
products={crossSell as CrossSellCarouselProduct[]}
|
|
213
|
+
onAdd={handleCrossSellAdd}
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<CartStickyFooter />
|
|
219
|
+
</>
|
|
220
|
+
) : (
|
|
221
|
+
<CartEmpty />
|
|
222
|
+
)}
|
|
223
|
+
</CartDrawer>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { convertToLocale } from "../lib/money"
|
|
4
|
+
import { useCartDrawer } from "./context"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tiered progress bar — shows how far the cart total is from the next
|
|
8
|
+
* shipping/discount tier, with checkpoint markers along the bar.
|
|
9
|
+
*
|
|
10
|
+
* Ported from `@1click/ui/src/cart-drawer/tiered-progress.tsx` (v2.3.1);
|
|
11
|
+
* originally extracted from mindpages-storefront
|
|
12
|
+
* src/modules/cart-drawer/cart-tiered-progress.tsx.
|
|
13
|
+
*
|
|
14
|
+
* Barter money note: `cart.total` and tier `threshold`s are EUR decimal
|
|
15
|
+
* major units (e.g. `{ threshold: 50 }` = €50) — configure tiers in major
|
|
16
|
+
* units, not cents.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type CartTier = {
|
|
20
|
+
/** EUR major units. */
|
|
21
|
+
threshold: number
|
|
22
|
+
label: string
|
|
23
|
+
icon?: "shipping" | "discount" | "gift"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TierProgress = {
|
|
27
|
+
/** 0–100, capped. */
|
|
28
|
+
progressPercent: number
|
|
29
|
+
/** First tier the amount hasn't reached yet; null when all unlocked. */
|
|
30
|
+
nextTier: CartTier | null
|
|
31
|
+
/** Amount missing to `nextTier.threshold` (0 when all unlocked). */
|
|
32
|
+
remaining: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure tier math — exported for unit tests. `tiers` must be sorted
|
|
37
|
+
* ascending by threshold (the caller's config order, same contract as the
|
|
38
|
+
* source component).
|
|
39
|
+
*/
|
|
40
|
+
export function computeTierProgress(
|
|
41
|
+
amount: number,
|
|
42
|
+
tiers: readonly CartTier[]
|
|
43
|
+
): TierProgress {
|
|
44
|
+
if (!tiers.length) {
|
|
45
|
+
return { progressPercent: 0, nextTier: null, remaining: 0 }
|
|
46
|
+
}
|
|
47
|
+
const maxThreshold = tiers[tiers.length - 1]!.threshold
|
|
48
|
+
const progressPercent =
|
|
49
|
+
maxThreshold > 0 ? Math.min((amount / maxThreshold) * 100, 100) : 100
|
|
50
|
+
const nextTier = tiers.find((t) => amount < t.threshold) ?? null
|
|
51
|
+
return {
|
|
52
|
+
progressPercent,
|
|
53
|
+
nextTier,
|
|
54
|
+
remaining: nextTier ? nextTier.threshold - amount : 0,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type CartTieredProgressProps = {
|
|
59
|
+
tiers: CartTier[]
|
|
60
|
+
currencyCode: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function CartTieredProgress({
|
|
64
|
+
tiers,
|
|
65
|
+
currencyCode,
|
|
66
|
+
}: CartTieredProgressProps) {
|
|
67
|
+
const { cart, labels } = useCartDrawer()
|
|
68
|
+
// Tax-inclusive total (server truth — EUR major units)
|
|
69
|
+
const subtotal = cart?.total ?? 0
|
|
70
|
+
|
|
71
|
+
if (tiers.length === 0) return null
|
|
72
|
+
|
|
73
|
+
const maxThreshold = tiers[tiers.length - 1]!.threshold
|
|
74
|
+
const { progressPercent, nextTier } = computeTierProgress(subtotal, tiers)
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
className="px-6 pt-4 pb-5 flex-shrink-0 relative z-10 bg-card border-b border-border"
|
|
79
|
+
style={{ boxShadow: "0 4px 12px rgba(0,0,0,0.04)" }}
|
|
80
|
+
>
|
|
81
|
+
{nextTier ? (
|
|
82
|
+
<p className="text-[13px] text-muted-foreground mb-4">
|
|
83
|
+
<span className="font-bold text-foreground">
|
|
84
|
+
{convertToLocale({
|
|
85
|
+
amount: nextTier.threshold - subtotal,
|
|
86
|
+
currency_code: currencyCode,
|
|
87
|
+
})}
|
|
88
|
+
</span>{" "}
|
|
89
|
+
{labels.awayFromFreeShipping}
|
|
90
|
+
</p>
|
|
91
|
+
) : (
|
|
92
|
+
<p className="text-[13px] text-success font-medium mb-4">
|
|
93
|
+
{labels.allRewardsUnlocked}
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
<div className="relative px-3">
|
|
98
|
+
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
|
99
|
+
<div
|
|
100
|
+
className="h-full rounded-full transition-all duration-700 ease-out"
|
|
101
|
+
style={{
|
|
102
|
+
width: `${progressPercent}%`,
|
|
103
|
+
background:
|
|
104
|
+
progressPercent >= 100
|
|
105
|
+
? "linear-gradient(90deg, oklch(var(--success)), oklch(var(--success) / 0.7))"
|
|
106
|
+
: "linear-gradient(90deg, oklch(var(--foreground)), oklch(var(--foreground) / 0.75))",
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{tiers.map((tier, i) => {
|
|
112
|
+
const position = (tier.threshold / maxThreshold) * 100
|
|
113
|
+
const achieved = subtotal >= tier.threshold
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
key={i}
|
|
118
|
+
className="absolute top-0"
|
|
119
|
+
style={{ left: `${position}%` }}
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
className={`w-5 h-5 rounded-full flex items-center justify-center -translate-x-1/2 -translate-y-[6px] border-[2.5px] border-surface ${
|
|
123
|
+
achieved ? "bg-success" : "bg-muted"
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
{achieved && (
|
|
127
|
+
<svg
|
|
128
|
+
width="9"
|
|
129
|
+
height="9"
|
|
130
|
+
viewBox="0 0 10 10"
|
|
131
|
+
fill="none"
|
|
132
|
+
stroke="white"
|
|
133
|
+
strokeWidth="2"
|
|
134
|
+
strokeLinecap="round"
|
|
135
|
+
strokeLinejoin="round"
|
|
136
|
+
>
|
|
137
|
+
<path d="M2 5l2.5 2.5L8 3" />
|
|
138
|
+
</svg>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
<div className="-translate-x-1/2 mt-1.5 whitespace-nowrap text-center">
|
|
142
|
+
<span
|
|
143
|
+
className={`text-[10px] block leading-tight font-medium ${
|
|
144
|
+
achieved ? "text-success" : "text-muted-foreground"
|
|
145
|
+
}`}
|
|
146
|
+
>
|
|
147
|
+
{tier.label}
|
|
148
|
+
</span>
|
|
149
|
+
<span
|
|
150
|
+
className={`text-[10px] block leading-tight tabular-nums ${
|
|
151
|
+
achieved ? "text-success" : "text-muted-foreground"
|
|
152
|
+
}`}
|
|
153
|
+
>
|
|
154
|
+
{convertToLocale({
|
|
155
|
+
amount: tier.threshold,
|
|
156
|
+
currency_code: currencyCode,
|
|
157
|
+
})}
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="h-8" />
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates address-save errors into user-friendly Bulgarian copy for the
|
|
3
|
+
* checkout address surface.
|
|
4
|
+
*
|
|
5
|
+
* Ported from `@1click/ui/src/checkout/address-error-copy.ts` (v2.3.1) and
|
|
6
|
+
* adapted to the barter error envelope: `StoreApiError` carries a structured
|
|
7
|
+
* `code` (POST /api/store/carts/:id — docs/storefront/carts.md), so
|
|
8
|
+
* translation is code-first; the substring patterns stay as the fallback for
|
|
9
|
+
* network failures and any legacy wire.
|
|
10
|
+
*
|
|
11
|
+
* The mapping is deliberately narrow: only translate errors we recognize;
|
|
12
|
+
* everything else falls back to a clean generic message rather than
|
|
13
|
+
* half-translated text.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const GENERIC_FALLBACK =
|
|
17
|
+
"Възникна проблем при запазване на адреса. Моля, проверете данните и опитайте отново."
|
|
18
|
+
|
|
19
|
+
const NETWORK_FALLBACK =
|
|
20
|
+
"Няма връзка със сървъра. Моля, проверете интернет връзката и опитайте отново."
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Barter cart-update error codes → BG copy (POST /api/store/carts/:id,
|
|
24
|
+
* docs/storefront/carts.md). Code-first, matched before any pattern.
|
|
25
|
+
*/
|
|
26
|
+
export const ADDRESS_ERROR_CODE_COPY: Record<string, string> = {
|
|
27
|
+
validation_failed:
|
|
28
|
+
"Някое от полетата не е валидно. Моля, проверете данните и опитайте отново.",
|
|
29
|
+
invalid_region: "Невалидна държава. Моля, презаредете страницата.",
|
|
30
|
+
region_required: "Регионът на доставка не е валиден. Моля, презаредете страницата.",
|
|
31
|
+
cart_completed:
|
|
32
|
+
"Поръчката вече е финализирана. Моля, проверете имейла си за потвърждение.",
|
|
33
|
+
cart_not_found: "Сесията на количката изтече. Моля, презаредете страницата.",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Patterns matched in order — first match wins. List most specific
|
|
38
|
+
* first, generic last. Match is case-insensitive (regexes carry /i).
|
|
39
|
+
* Kept verbatim from the production port.
|
|
40
|
+
*/
|
|
41
|
+
const PATTERNS: Array<{ match: RegExp; copy: string }> = [
|
|
42
|
+
// Email validation — confirmed (in @1click production) as the most common
|
|
43
|
+
// address-save failure shape.
|
|
44
|
+
{
|
|
45
|
+
match: /invalid[\s_]?(?:request:?\s*)?invalid[\s_]?email|invalid[\s_]?email/i,
|
|
46
|
+
copy: "Невалиден имейл адрес. Моля, проверете изписването.",
|
|
47
|
+
},
|
|
48
|
+
// Postal / postcode validation
|
|
49
|
+
{
|
|
50
|
+
match: /invalid[\s_]?postal|invalid[\s_]?post(?:code|al[\s_]?code)|postal[\s_]?code[\s_]?(?:is[\s_]?)?invalid/i,
|
|
51
|
+
copy: "Невалиден пощенски код. Моля, въведете 4-цифрен код.",
|
|
52
|
+
},
|
|
53
|
+
// Phone validation
|
|
54
|
+
{
|
|
55
|
+
match: /invalid[\s_]?phone|phone[\s_]?(?:number[\s_]?)?(?:is[\s_]?)?invalid/i,
|
|
56
|
+
copy: "Невалиден телефонен номер. Моля, проверете цифрите.",
|
|
57
|
+
},
|
|
58
|
+
// Country / region mismatch
|
|
59
|
+
{
|
|
60
|
+
match: /invalid[\s_]?country|country[\s_]?(?:code[\s_]?)?(?:is[\s_]?)?invalid|no[\s_]?region|invalid[\s_]?region/i,
|
|
61
|
+
copy: "Невалидна държава. Моля, презаредете страницата.",
|
|
62
|
+
},
|
|
63
|
+
// Cart already completed — race with order placement
|
|
64
|
+
{
|
|
65
|
+
match: /cart[\s_]?(?:is[\s_]?)?already[\s_]?completed|already[\s_]?an[\s_]?order|completed[\s_]?cart/i,
|
|
66
|
+
copy: "Поръчката вече е финализирана. Моля, проверете имейла си за потвърждение.",
|
|
67
|
+
},
|
|
68
|
+
// Cart not found / expired
|
|
69
|
+
{
|
|
70
|
+
match: /cart[\s_]?(?:not[\s_]?found|does[\s_]?not[\s_]?exist|expired)/i,
|
|
71
|
+
copy: "Сесията на количката изтече. Моля, презаредете страницата.",
|
|
72
|
+
},
|
|
73
|
+
// Network / fetch failure
|
|
74
|
+
{
|
|
75
|
+
match: /failed[\s_]?to[\s_]?fetch|network[\s_]?error|networkerror|econnreset|etimedout/i,
|
|
76
|
+
copy: NETWORK_FALLBACK,
|
|
77
|
+
},
|
|
78
|
+
// The Next.js canned Server Components production message — surface a
|
|
79
|
+
// clean fallback instead of the English placeholder. (The barter SDK
|
|
80
|
+
// throws from the browser, so this only fires for stores that proxy the
|
|
81
|
+
// save through a server action.)
|
|
82
|
+
{
|
|
83
|
+
match: /server[\s_]?components[\s_]?render|digest property is included/i,
|
|
84
|
+
copy: GENERIC_FALLBACK,
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Translate any thrown / returned address-save error into
|
|
90
|
+
* customer-facing Bulgarian copy. Returns the generic fallback when
|
|
91
|
+
* the error doesn't match a known code or pattern.
|
|
92
|
+
*/
|
|
93
|
+
export function translateAddressError(err: unknown): string {
|
|
94
|
+
if (err && typeof err === "object") {
|
|
95
|
+
const code = (err as { code?: unknown }).code
|
|
96
|
+
if (typeof code === "string" && ADDRESS_ERROR_CODE_COPY[code]) {
|
|
97
|
+
return ADDRESS_ERROR_CODE_COPY[code]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const raw = extractMessage(err)
|
|
102
|
+
if (!raw) return GENERIC_FALLBACK
|
|
103
|
+
|
|
104
|
+
for (const { match, copy } of PATTERNS) {
|
|
105
|
+
if (match.test(raw)) return copy
|
|
106
|
+
}
|
|
107
|
+
return GENERIC_FALLBACK
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractMessage(err: unknown): string {
|
|
111
|
+
if (!err) return ""
|
|
112
|
+
if (typeof err === "string") return err
|
|
113
|
+
if (err instanceof Error) return err.message
|
|
114
|
+
if (typeof err === "object" && err !== null) {
|
|
115
|
+
const e = err as { message?: string; code?: string; type?: string }
|
|
116
|
+
return [e.message, e.code, e.type].filter(Boolean).join(" ")
|
|
117
|
+
}
|
|
118
|
+
return String(err)
|
|
119
|
+
}
|