@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,94 @@
1
+ "use client"
2
+
3
+ import { useState, type ChangeEvent } from "react"
4
+
5
+ import { Field } from "../primitives/field"
6
+ import { cn } from "../lib/utils"
7
+ import { useCheckoutLabels } from "./context"
8
+
9
+ /**
10
+ * CompanyDetails — collapsible "Company invoice" section shown under the
11
+ * address form. Four fields: name, VAT, MOL (responsible person), address.
12
+ *
13
+ * Ported from `@1click/ui/src/checkout/company-details.tsx` (v2.3.1)
14
+ * unchanged — pure form UI; the values ride `formData` and the
15
+ * orchestration hook persists them into `cart.metadata`
16
+ * (company_name/company_vat/company_mol/company_address). Barter upgrade
17
+ * over the @1click KNOWN_ISSUES state: the hook ALSO syncs
18
+ * `company_name`/`company_eik` onto the signed-in customer profile
19
+ * (PATCH /api/store/customers/me — first-class columns,
20
+ * docs/storefront/customers.md), so invoice data is no longer
21
+ * cart-metadata-only.
22
+ */
23
+
24
+ type CompanyDetailsProps = {
25
+ formData: Record<string, string>
26
+ onChange: (e: ChangeEvent<HTMLInputElement>) => void
27
+ onBlur: () => void
28
+ }
29
+
30
+ export function CompanyDetails({ formData, onChange, onBlur }: CompanyDetailsProps) {
31
+ const labels = useCheckoutLabels()
32
+ const [open, setOpen] = useState(!!formData.company_name?.trim())
33
+
34
+ return (
35
+ <div className="mt-3">
36
+ <button
37
+ type="button"
38
+ onClick={() => setOpen(!open)}
39
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
40
+ >
41
+ <svg
42
+ className={cn(
43
+ "w-4 h-4 transition-transform duration-200",
44
+ open && "rotate-90"
45
+ )}
46
+ fill="none"
47
+ viewBox="0 0 24 24"
48
+ stroke="currentColor"
49
+ strokeWidth={2}
50
+ >
51
+ <path
52
+ strokeLinecap="round"
53
+ strokeLinejoin="round"
54
+ d="M8.25 4.5l7.5 7.5-7.5 7.5"
55
+ />
56
+ </svg>
57
+ {labels.companyInvoice}
58
+ </button>
59
+
60
+ {open && (
61
+ <div className="mt-3 space-y-2.5">
62
+ <Field
63
+ label={labels.companyName}
64
+ name="company_name"
65
+ value={formData.company_name ?? ""}
66
+ onChange={onChange}
67
+ onBlur={onBlur}
68
+ />
69
+ <Field
70
+ label={labels.companyVat}
71
+ name="company_vat"
72
+ value={formData.company_vat ?? ""}
73
+ onChange={onChange}
74
+ onBlur={onBlur}
75
+ />
76
+ <Field
77
+ label={labels.companyMol}
78
+ name="company_mol"
79
+ value={formData.company_mol ?? ""}
80
+ onChange={onChange}
81
+ onBlur={onBlur}
82
+ />
83
+ <Field
84
+ label={labels.companyAddress}
85
+ name="company_address"
86
+ value={formData.company_address ?? ""}
87
+ onChange={onChange}
88
+ onBlur={onBlur}
89
+ />
90
+ </div>
91
+ )}
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Compare two address objects for shallow equality across the canonical
3
+ * shipping-address fields. Returns true if every field matches.
4
+ *
5
+ * Ported from `@1click/ui/src/data/util/compare-addresses.ts` (v2.3.1)
6
+ * unchanged. Lives in the checkout family because the barter SDK has no
7
+ * data/util layer (the @1click data layer became `@cartbase/storefront/api`).
8
+ * Extra file beyond the pre-declared export list → exported from the
9
+ * family barrel only.
10
+ */
11
+
12
+ const FIELDS = [
13
+ "first_name",
14
+ "last_name",
15
+ "address_1",
16
+ "company",
17
+ "postal_code",
18
+ "city",
19
+ "country_code",
20
+ "province",
21
+ "phone",
22
+ ] as const
23
+
24
+ type AddressLike = Record<string, unknown> | null | undefined
25
+
26
+ export default function compareAddresses(
27
+ address1: AddressLike,
28
+ address2: AddressLike
29
+ ): boolean {
30
+ if (address1 === address2) return true
31
+ if (!address1 || !address2) return false
32
+
33
+ for (const key of FIELDS) {
34
+ const a = (address1 as Record<string, unknown>)[key] ?? null
35
+ const b = (address2 as Record<string, unknown>)[key] ?? null
36
+ if (a !== b) return false
37
+ }
38
+
39
+ return true
40
+ }
@@ -0,0 +1,76 @@
1
+ "use client"
2
+
3
+ import { createContext, useContext, type ReactNode } from "react"
4
+
5
+ import { defaultCheckoutLabels, type CheckoutLabels } from "./labels"
6
+
7
+ /**
8
+ * CheckoutContext — supplies labels to every checkout primitive without
9
+ * prop drilling. Stores wrap the checkout page or their own composition
10
+ * with `<CheckoutProvider labels={...}>` and every primitive inside reads
11
+ * from context.
12
+ *
13
+ * Ported from `@1click/ui/src/checkout/context.tsx` (v2.3.1) unchanged —
14
+ * this module has no data seam.
15
+ *
16
+ * Unlike the cart drawer, the checkout context is labels-only. Form
17
+ * state, cart data, shipping methods, and payment sessions are passed
18
+ * through as props to `CheckoutClient` because they are request-specific.
19
+ */
20
+
21
+ type CheckoutContextValue = {
22
+ labels: CheckoutLabels
23
+ /**
24
+ * Template for the post-success redirect URL after placeOrder.
25
+ * Substitutions:
26
+ * {id} → order.id
27
+ * {country} → lowercase ISO country code from shipping_address
28
+ *
29
+ * Default: "/{country}/order/{id}/confirmed" — for stores whose
30
+ * URL structure includes a country segment (e.g. MindPages).
31
+ *
32
+ * Stores with flattened single-country URLs (e.g. Alenika) should
33
+ * pass "/order/{id}/confirmed" via CheckoutProvider.
34
+ */
35
+ orderConfirmedPath: string
36
+ }
37
+
38
+ const DEFAULT_CONFIRMED_PATH = "/{country}/order/{id}/confirmed"
39
+
40
+ const CheckoutContext = createContext<CheckoutContextValue>({
41
+ labels: defaultCheckoutLabels,
42
+ orderConfirmedPath: DEFAULT_CONFIRMED_PATH,
43
+ })
44
+
45
+ export function useCheckoutLabels(): CheckoutLabels {
46
+ return useContext(CheckoutContext).labels
47
+ }
48
+
49
+ export function useOrderConfirmedPath(): string {
50
+ return useContext(CheckoutContext).orderConfirmedPath
51
+ }
52
+
53
+ export function CheckoutProvider({
54
+ labels: labelOverrides,
55
+ orderConfirmedPath,
56
+ children,
57
+ }: {
58
+ labels?: Partial<CheckoutLabels>
59
+ orderConfirmedPath?: string
60
+ children: ReactNode
61
+ }) {
62
+ const labels: CheckoutLabels = {
63
+ ...defaultCheckoutLabels,
64
+ ...labelOverrides,
65
+ }
66
+ return (
67
+ <CheckoutContext.Provider
68
+ value={{
69
+ labels,
70
+ orderConfirmedPath: orderConfirmedPath ?? DEFAULT_CONFIRMED_PATH,
71
+ }}
72
+ >
73
+ {children}
74
+ </CheckoutContext.Provider>
75
+ )
76
+ }
@@ -0,0 +1,218 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+
5
+ import type { StorefrontClient } from "../api/http"
6
+ import { applyPromotions, type Cart } from "../api/carts"
7
+ import { convertToLocale } from "../lib/money"
8
+ import { cn } from "../lib/utils"
9
+ import { useCheckoutLabels } from "./context"
10
+ import { translatePromotionError } from "./promotion-error-copy"
11
+
12
+ /**
13
+ * DiscountSection — collapsible promo-code input shown inside the order
14
+ * summary card. Lists currently applied promotions with their discount
15
+ * amount.
16
+ *
17
+ * Ported from `@1click/ui/src/checkout/discount-section.tsx` (v2.3.1) with
18
+ * the barter data seam:
19
+ *
20
+ * - `applyPromotions(codes)` (Medusa js-sdk server action, full-list
21
+ * replace) → `POST /api/store/carts/:id/promotions {promo_codes}` — the
22
+ * barter route is ADDITIVE (upserts each code's link, then re-applies),
23
+ * so only the NEW code is sent; already-applied codes stay put. The SDK
24
+ * carts module ships no wrapper for this route yet, so the component
25
+ * calls it through the client transport with the documented DTO
26
+ * (verified against src/app/api/store/carts/[id]/promotions/route.ts).
27
+ * - `cart.promotions` on the barter wire is the raw pivot embed
28
+ * (`[{promotion: {..., application_method}}]`) — unwrapped here, with a
29
+ * flat-row fallback should the decoration ever flatten it.
30
+ * - Errors are code-first (`promotion_not_found` / `promotion_inactive` /
31
+ * `promotion_misconfigured` / `promotion_unsupported_type` + cart-level
32
+ * codes) via `translatePromotionError`.
33
+ *
34
+ * The route returns the freshly decorated cart; `onCartChange` hands it to
35
+ * the host (or the host calls `router.refresh()` in an RSC app).
36
+ */
37
+
38
+ /** Structural shape of one applied promotion (promotions row). */
39
+ type AppliedPromotion = {
40
+ id: string
41
+ code: string | null
42
+ application_method?: {
43
+ type?: string | null
44
+ value?: number | string | null
45
+ currency_code?: string | null
46
+ } | null
47
+ }
48
+
49
+ /** Unwrap the barter pivot embed; tolerate an already-flat row. */
50
+ function extractPromotions(cart: Cart): AppliedPromotion[] {
51
+ const raw = (cart.promotions ?? []) as Array<Record<string, unknown>>
52
+ return raw
53
+ .map((row) =>
54
+ row && typeof row === "object" && "promotion" in row
55
+ ? (row.promotion as AppliedPromotion | null)
56
+ : (row as unknown as AppliedPromotion)
57
+ )
58
+ .filter((p): p is AppliedPromotion => !!p && typeof p.id === "string")
59
+ }
60
+
61
+ type DiscountSectionProps = {
62
+ /** The SDK transport — the promotions route is called through it. */
63
+ client: StorefrontClient
64
+ cart: Cart
65
+ /** Receives the decorated cart returned by a successful apply. */
66
+ onCartChange?: (cart: Cart) => void
67
+ }
68
+
69
+ export function DiscountSection({
70
+ client,
71
+ cart,
72
+ onCartChange,
73
+ }: DiscountSectionProps) {
74
+ const labels = useCheckoutLabels()
75
+ const [code, setCode] = useState("")
76
+ const [loading, setLoading] = useState(false)
77
+ const [error, setError] = useState("")
78
+ const [open, setOpen] = useState(false)
79
+
80
+ const promotions = extractPromotions(cart)
81
+
82
+ const handleApply = async () => {
83
+ if (!code.trim()) return
84
+ setLoading(true)
85
+ setError("")
86
+ try {
87
+ // Additive apply — the barter route upserts the new code's link and
88
+ // re-applies every cart promotion; already-applied codes stay.
89
+ const { cart: updated } = await applyPromotions(client, cart.id, [code.trim()])
90
+ onCartChange?.(updated)
91
+ setCode("")
92
+ } catch (e: unknown) {
93
+ // Never surface the raw API error string to the shopper — map to a
94
+ // proper localized message (code-first, pattern fallback).
95
+ setError(translatePromotionError(e, { hasEmail: !!cart.email }))
96
+ } finally {
97
+ setLoading(false)
98
+ }
99
+ }
100
+
101
+ return (
102
+ <div>
103
+ <button
104
+ type="button"
105
+ onClick={() => setOpen(!open)}
106
+ className="flex items-center gap-3 w-full p-4 rounded-xl border border-border bg-muted/50 hover:bg-muted transition-colors"
107
+ >
108
+ <div className="w-10 h-10 rounded-full bg-gradient-to-br from-warning to-warning/70 flex items-center justify-center flex-shrink-0">
109
+ <svg
110
+ className="w-5 h-5 text-card"
111
+ fill="none"
112
+ viewBox="0 0 24 24"
113
+ stroke="currentColor"
114
+ strokeWidth={2}
115
+ >
116
+ <path
117
+ strokeLinecap="round"
118
+ strokeLinejoin="round"
119
+ d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
120
+ />
121
+ <path
122
+ strokeLinecap="round"
123
+ strokeLinejoin="round"
124
+ d="M6 6h.008v.008H6V6z"
125
+ />
126
+ </svg>
127
+ </div>
128
+ <div className="flex-1 text-left">
129
+ <p className="text-sm font-semibold text-foreground">
130
+ {labels.discountCode}
131
+ </p>
132
+ <p className="text-xs text-muted-foreground">{labels.discountSub}</p>
133
+ </div>
134
+ <span className="text-sm font-medium text-foreground flex items-center gap-1.5">
135
+ {labels.addCode}
136
+ <svg
137
+ className={cn(
138
+ "w-4 h-4 text-muted-foreground transition-transform duration-200",
139
+ open && "rotate-180"
140
+ )}
141
+ fill="none"
142
+ viewBox="0 0 24 24"
143
+ stroke="currentColor"
144
+ strokeWidth={2}
145
+ >
146
+ <path
147
+ strokeLinecap="round"
148
+ strokeLinejoin="round"
149
+ d="M19.5 8.25l-7.5 7.5-7.5-7.5"
150
+ />
151
+ </svg>
152
+ </span>
153
+ </button>
154
+
155
+ {open && (
156
+ <div className="mt-3 flex gap-2">
157
+ <input
158
+ type="text"
159
+ value={code}
160
+ onChange={(e) => setCode(e.target.value.toUpperCase())}
161
+ placeholder="PROMO2024"
162
+ className="flex-1 h-11 px-4 text-sm bg-card border border-border rounded-xl focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
163
+ />
164
+ <button
165
+ type="button"
166
+ onClick={handleApply}
167
+ disabled={loading || !code.trim()}
168
+ className="px-5 h-11 text-sm font-semibold bg-foreground text-card rounded-xl hover:bg-foreground/90 transition-all disabled:opacity-40"
169
+ >
170
+ {labels.addCode}
171
+ </button>
172
+ </div>
173
+ )}
174
+
175
+ {error && <p className="text-xs text-destructive mt-2">{error}</p>}
176
+
177
+ {promotions.length > 0 && (
178
+ <div className="mt-3 space-y-1.5">
179
+ {promotions.map((p) => (
180
+ <div
181
+ key={p.id}
182
+ className="flex items-center justify-between px-4 py-2.5 bg-success/10 border border-success/20 rounded-lg"
183
+ >
184
+ <div className="flex items-center gap-2">
185
+ <svg
186
+ className="w-4 h-4 text-success"
187
+ fill="none"
188
+ viewBox="0 0 24 24"
189
+ stroke="currentColor"
190
+ strokeWidth={2}
191
+ >
192
+ <path
193
+ strokeLinecap="round"
194
+ strokeLinejoin="round"
195
+ d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
196
+ />
197
+ </svg>
198
+ <span className="text-sm font-semibold text-success">
199
+ {p.code}
200
+ </span>
201
+ </div>
202
+ <span className="text-sm font-medium text-success">
203
+ {p.application_method?.type === "percentage"
204
+ ? `-${p.application_method.value}%`
205
+ : p.application_method?.value && p.application_method?.currency_code
206
+ ? `-${convertToLocale({
207
+ amount: +p.application_method.value,
208
+ currency_code: p.application_method.currency_code,
209
+ })}`
210
+ : ""}
211
+ </span>
212
+ </div>
213
+ ))}
214
+ </div>
215
+ )}
216
+ </div>
217
+ )
218
+ }