@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.
Files changed (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. 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
+ }