@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,245 @@
1
+ import { DualPrice } from "../lib/dual-price"
2
+ import { findFeeLine, type LineLike } from "../lib/cart-helpers"
3
+ import { defaultOrderLabels, type OrderLabels } from "./labels"
4
+
5
+ /**
6
+ * Order totals breakdown. Ported from `@1click/ui/src/order/order-totals.tsx`
7
+ * (v2.3.1) with the barter money seam:
8
+ *
9
+ * Totals are SERVER truth and the barter store order read
10
+ * (`StoreOrderDetail`) carries NONE of them — they live where the server
11
+ * computed them: the decorated cart the app holds on the confirmation page
12
+ * (`api/carts` `Cart`: `item_subtotal`, `shipping_subtotal`,
13
+ * `discount_total`, `tax_total`, `total`, `cod_fee_total`, `cod_fee_label`)
14
+ * or the order's summary snapshot (`order_summaries.totals` — reachable via
15
+ * `completeCart().order.summary`). The component takes a structural
16
+ * `OrderTotalsSource` both of those satisfy (`orderTotalsFromSummary()`
17
+ * adapts the summary shape) and only SELECTS + FORMATS rows — it never
18
+ * computes money beyond the two production subtractions documented below.
19
+ *
20
+ * COD fee display, in preference order (production fix preserved):
21
+ * 1. barter-native fee: `cod_fee_total` > 0 (never a line item;
22
+ * already folded into `total` server-side), labeled by the
23
+ * admin-editable `cod_fee_label`.
24
+ * 2. legacy Medusa-era fee LINE ITEM (`metadata.is_cod_fee`) found in
25
+ * `items` — its net is subtracted from the visible subtotal so the
26
+ * Subtotal row stays product-only while Tax keeps the fee's tax
27
+ * portion (v2.3.1 fix).
28
+ * 3. label fallbacks: `codFeeLabel` prop → source `cod_fee_label` →
29
+ * fee line's own title → translated `labels.codFee`.
30
+ */
31
+
32
+ /** Structural totals source — `api/carts` `Cart` satisfies it directly. */
33
+ export interface OrderTotalsSource {
34
+ /** Product-only net subtotal (cart path). Preferred. */
35
+ item_subtotal?: number | null
36
+ /** Combined items+shipping net (summary path fallback). */
37
+ subtotal?: number | null
38
+ /** Shipping net (cart path). Preferred for the Shipping row. */
39
+ shipping_subtotal?: number | null
40
+ /** Shipping gross (summary path fallback). */
41
+ shipping_total?: number | null
42
+ discount_total?: number | null
43
+ tax_total?: number | null
44
+ total?: number | null
45
+ /** barter-native COD fee (already folded into `total`). */
46
+ cod_fee_total?: number | null
47
+ /** Admin-editable COD fee label from the server. */
48
+ cod_fee_label?: string | null
49
+ [key: string]: unknown
50
+ }
51
+
52
+ /**
53
+ * Adapt `order_summaries.totals` (shape:
54
+ * `{subtotal, total, tax_total, discount_total, shipping_total,
55
+ * cod_fee_total}` — src/app/api/store/carts/[id]/complete/_copy.ts) to an
56
+ * `OrderTotalsSource`. Accepts the raw `CompletedOrder["summary"]` value
57
+ * (embed row, array of rows, or the totals object itself); null when no
58
+ * totals can be found.
59
+ */
60
+ export function orderTotalsFromSummary(
61
+ summary: unknown
62
+ ): OrderTotalsSource | null {
63
+ if (!summary || typeof summary !== "object") return null
64
+ const first = Array.isArray(summary) ? summary[0] : summary
65
+ if (!first || typeof first !== "object") return null
66
+ const rec = first as Record<string, unknown>
67
+ const totals =
68
+ rec.totals && typeof rec.totals === "object"
69
+ ? (rec.totals as Record<string, unknown>)
70
+ : rec
71
+ if (typeof totals.total !== "number") return null
72
+ return totals as OrderTotalsSource
73
+ }
74
+
75
+ export type OrderTotalsRow = {
76
+ key: "subtotal" | "shipping" | "codFee" | "discount" | "tax"
77
+ /** Resolved display label (codFee row only — others use the label pack). */
78
+ label?: string
79
+ amount: number
80
+ /** Shipping row at 0 renders the FREE badge instead of a price. */
81
+ free?: boolean
82
+ /** Discount row renders negated. */
83
+ negative?: boolean
84
+ }
85
+
86
+ /**
87
+ * Pure row selection — the entire display policy of the breakdown, kept
88
+ * out of JSX so it is unit-testable (tests/unit/storefront-order-reviews).
89
+ */
90
+ export function selectOrderTotalsRows(
91
+ source: OrderTotalsSource,
92
+ opts: {
93
+ items?: readonly LineLike[] | null
94
+ codFeeLabel?: string
95
+ labels?: Pick<OrderLabels, "codFee">
96
+ } = {}
97
+ ): { rows: OrderTotalsRow[]; total: number } {
98
+ const l = { ...defaultOrderLabels, ...opts.labels }
99
+ const rows: OrderTotalsRow[] = []
100
+
101
+ // Legacy fee LINE (Medusa-era injection) — barter-native carts never
102
+ // have one, but ported data / hybrid stores might.
103
+ const codFeeItem = findFeeLine(opts.items || [])
104
+ const nativeFee = Number(source.cod_fee_total ?? 0)
105
+ const lineFee = Number(codFeeItem?.total ?? 0)
106
+ const codFeeAmount = nativeFee > 0 ? nativeFee : lineFee
107
+
108
+ // Subtotal: prefer the product-only net; fall back to combined-minus-
109
+ // shipping, then combined. Subtract a legacy fee line's net so the
110
+ // visible Subtotal stays product-only (v2.3.1 production fix).
111
+ const baseSubtotal =
112
+ source.item_subtotal ??
113
+ (source.subtotal !== null &&
114
+ source.subtotal !== undefined &&
115
+ source.shipping_subtotal !== null &&
116
+ source.shipping_subtotal !== undefined
117
+ ? Number(source.subtotal) - Number(source.shipping_subtotal)
118
+ : source.subtotal ?? 0)
119
+ const feeNet = codFeeItem
120
+ ? Number(codFeeItem.subtotal ?? codFeeItem.total ?? 0)
121
+ : 0
122
+ rows.push({
123
+ key: "subtotal",
124
+ amount: Math.max(0, Number(baseSubtotal ?? 0) - feeNet),
125
+ })
126
+
127
+ const shippingAmount = Number(
128
+ source.shipping_subtotal ?? source.shipping_total ?? 0
129
+ )
130
+ rows.push({
131
+ key: "shipping",
132
+ amount: shippingAmount,
133
+ free: shippingAmount === 0,
134
+ })
135
+
136
+ if (codFeeAmount > 0) {
137
+ rows.push({
138
+ key: "codFee",
139
+ amount: codFeeAmount,
140
+ label:
141
+ opts.codFeeLabel ??
142
+ source.cod_fee_label ??
143
+ ((codFeeItem as { title?: string } | null)?.title || undefined) ??
144
+ l.codFee,
145
+ })
146
+ }
147
+
148
+ const discount = Number(source.discount_total ?? 0)
149
+ if (discount > 0) {
150
+ rows.push({ key: "discount", amount: discount, negative: true })
151
+ }
152
+
153
+ rows.push({ key: "tax", amount: Number(source.tax_total ?? 0) })
154
+
155
+ return { rows, total: Number(source.total ?? 0) }
156
+ }
157
+
158
+ type OrderTotalsProps = {
159
+ totals: OrderTotalsSource
160
+ currencyCode: string
161
+ /** Lines from the same source — used only to detect a legacy fee line. */
162
+ items?: readonly LineLike[] | null
163
+ labels?: Pick<
164
+ OrderLabels,
165
+ "subtotal" | "shipping" | "discount" | "tax" | "total" | "free" | "codFee"
166
+ >
167
+ /**
168
+ * Per-store override for the cash-on-delivery fee row label. Optional —
169
+ * falls back to the server's `cod_fee_label`, then the fee line's title,
170
+ * then the translated `labels.codFee` default.
171
+ */
172
+ codFeeLabel?: string
173
+ }
174
+
175
+ export function OrderTotals({
176
+ totals,
177
+ currencyCode,
178
+ items,
179
+ labels,
180
+ codFeeLabel,
181
+ }: OrderTotalsProps) {
182
+ const l = { ...defaultOrderLabels, ...labels }
183
+ const { rows, total } = selectOrderTotalsRows(totals, {
184
+ items,
185
+ codFeeLabel,
186
+ labels: l,
187
+ })
188
+
189
+ const labelFor = (row: OrderTotalsRow): string => {
190
+ switch (row.key) {
191
+ case "subtotal":
192
+ return l.subtotal
193
+ case "shipping":
194
+ return l.shipping
195
+ case "codFee":
196
+ return row.label ?? l.codFee
197
+ case "discount":
198
+ return l.discount
199
+ case "tax":
200
+ return l.tax
201
+ }
202
+ }
203
+
204
+ return (
205
+ <div className="pt-4 border-t border-border">
206
+ <div className="flex flex-col gap-2 text-sm">
207
+ {rows.map((row) => (
208
+ <div key={row.key} className="flex justify-between">
209
+ <span className="text-muted-foreground">{labelFor(row)}</span>
210
+ {row.free ? (
211
+ <span className="text-sm font-medium text-success">{l.free}</span>
212
+ ) : row.negative ? (
213
+ <span className="text-sm text-success">
214
+ -{" "}
215
+ <DualPrice
216
+ amount={row.amount}
217
+ currencyCode={currencyCode}
218
+ className="text-sm text-success"
219
+ />
220
+ </span>
221
+ ) : (
222
+ <DualPrice
223
+ amount={row.amount}
224
+ currencyCode={currencyCode}
225
+ className="text-sm text-foreground"
226
+ />
227
+ )}
228
+ </div>
229
+ ))}
230
+ </div>
231
+
232
+ <div className="h-px bg-border my-3" />
233
+ <div className="flex justify-between items-baseline">
234
+ <span className="text-[15px] font-bold text-foreground">{l.total}</span>
235
+ <DualPrice
236
+ amount={total}
237
+ currencyCode={currencyCode}
238
+ className="text-xl font-bold text-foreground tracking-tight"
239
+ />
240
+ </div>
241
+ </div>
242
+ )
243
+ }
244
+
245
+ export { type OrderTotalsProps }
@@ -0,0 +1,125 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+
7
+ /**
8
+ * Field — floating-label input primitive.
9
+ *
10
+ * Built on a native `<input>` with a visually-overlaid label that scales up
11
+ * when the field is empty and shrinks into the top-left corner when the
12
+ * field is focused or has a value. This is the canonical input treatment
13
+ * used throughout the library's checkout and any form UI.
14
+ *
15
+ * Why not build on shadcn's `Input`? The floating-label animation requires
16
+ * controlling input height, padding, and label positioning together as a
17
+ * single unit — wrapping shadcn's Input would mean overriding nearly all of
18
+ * its styles anyway. Instead, Field is its own small primitive. Stores that
19
+ * want plain inputs use shadcn's `Input` directly; stores that want the
20
+ * floating-label treatment use `Field`.
21
+ *
22
+ * Extracted and adapted from mindpages-storefront
23
+ * src/modules/checkout/templates/checkout-client/index.tsx lines 143-205.
24
+ *
25
+ * @example
26
+ * <Field label="Email" name="email" type="email" value={email} onChange={...} />
27
+ */
28
+
29
+ export type FieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
30
+ label: string
31
+ className?: string
32
+ /**
33
+ * Apply the "needs filling" attention cue (3s-idle pulse). Soft blue
34
+ * (sky-500) ring + 2-cycle pulse animation on mount, then a steady
35
+ * highlight until the field gets a value (parent removes the prop).
36
+ *
37
+ * Distinct from focus (orange/primary) and error (red/destructive)
38
+ * so the customer reads it as "this needs your attention" without
39
+ * confusing it for an error.
40
+ */
41
+ pulse?: boolean
42
+ }
43
+
44
+ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
45
+ function Field(
46
+ {
47
+ label,
48
+ required,
49
+ disabled,
50
+ className,
51
+ value,
52
+ onFocus,
53
+ onBlur,
54
+ pulse,
55
+ ...props
56
+ },
57
+ ref
58
+ ) {
59
+ const [focused, setFocused] = React.useState(false)
60
+ const hasValue = typeof value === "string" ? value.length > 0 : !!value
61
+ const isActive = focused || hasValue
62
+
63
+ // 2-cycle pulse on first activation. Clears after ~1.2s so the
64
+ // animation calls attention briefly, then leaves a steady ring
65
+ // until the customer fills the field (which removes the prop).
66
+ const [pulsing, setPulsing] = React.useState(false)
67
+ React.useEffect(() => {
68
+ if (!pulse) {
69
+ setPulsing(false)
70
+ return
71
+ }
72
+ setPulsing(true)
73
+ const t = setTimeout(() => setPulsing(false), 1200)
74
+ return () => clearTimeout(t)
75
+ }, [pulse])
76
+
77
+ // Pulse only applies to empty fields without focus — once the
78
+ // customer focuses or fills the field, the standard treatment wins.
79
+ const showPulse = pulse && !hasValue && !focused
80
+
81
+ return (
82
+ <div className={cn("relative", className)}>
83
+ <input
84
+ ref={ref}
85
+ required={required}
86
+ disabled={disabled}
87
+ value={value}
88
+ onFocus={(e) => {
89
+ setFocused(true)
90
+ onFocus?.(e)
91
+ }}
92
+ onBlur={(e) => {
93
+ setFocused(false)
94
+ onBlur?.(e)
95
+ }}
96
+ className={cn(
97
+ "w-full h-[44px] px-3 pt-[14px] pb-[2px] text-sm rounded-lg border",
98
+ "transition-colors duration-300 outline-none",
99
+ "focus:border-primary focus:bg-primary/5",
100
+ disabled
101
+ ? "bg-muted border-border text-muted-foreground cursor-not-allowed"
102
+ : "bg-card border-border text-foreground hover:border-muted-foreground",
103
+ // Attention cue: distinct soft blue. Steady ring once the
104
+ // initial pulse settles. Wins over default border but loses
105
+ // to focus (which is orange/primary).
106
+ showPulse &&
107
+ "!border-sky-500 !bg-sky-50/40 ring-2 ring-sky-400/30",
108
+ showPulse && pulsing && "animate-pulse"
109
+ )}
110
+ {...props}
111
+ />
112
+ <span
113
+ className={cn(
114
+ "absolute pointer-events-none left-3 top-[14px] text-sm leading-4 origin-top-left",
115
+ "transition-transform transition-colors duration-300 ease-out",
116
+ showPulse ? "text-sky-600" : "text-muted-foreground",
117
+ isActive ? "-translate-y-2 scale-[0.77]" : "translate-y-0 scale-100"
118
+ )}
119
+ >
120
+ {label}
121
+ </span>
122
+ </div>
123
+ )
124
+ }
125
+ )
@@ -0,0 +1,77 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+
7
+ /**
8
+ * SelectField — floating-label native select primitive.
9
+ *
10
+ * Matches `Field`'s visual treatment — the label floats into the top-left
11
+ * when a value is selected. Uses a plain native `<select>` for simplicity
12
+ * and native mobile keyboard support. If a fancier dropdown is needed
13
+ * (searchable, multi-select, rich options), use the shadcn `Select`
14
+ * primitives directly from `@cartbase/storefront/primitives/ui/select`.
15
+ *
16
+ * Extracted from mindpages-storefront
17
+ * src/modules/checkout/templates/checkout-client/index.tsx lines 207-270.
18
+ *
19
+ * @example
20
+ * <SelectField label="Country" name="country" value={country} onChange={...}>
21
+ * <option value="bg">Bulgaria</option>
22
+ * <option value="de">Germany</option>
23
+ * </SelectField>
24
+ */
25
+
26
+ export type SelectFieldProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
27
+ label: string
28
+ children: React.ReactNode
29
+ className?: string
30
+ }
31
+
32
+ export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>(
33
+ function SelectField({ label, required, disabled, children, className, value, ...props }, ref) {
34
+ const hasValue = value !== undefined && value !== null && value !== ""
35
+
36
+ return (
37
+ <div className={cn("relative", className)}>
38
+ <select
39
+ ref={ref}
40
+ required={required}
41
+ disabled={disabled}
42
+ value={value}
43
+ className={cn(
44
+ "w-full h-[44px] px-3 pt-[14px] pb-[2px] pr-9 text-sm rounded-lg border transition-colors duration-150 appearance-none outline-none",
45
+ "focus:border-primary focus:bg-primary/5",
46
+ disabled
47
+ ? "bg-muted border-border text-muted-foreground cursor-not-allowed"
48
+ : "bg-card border-border text-foreground hover:border-muted-foreground"
49
+ )}
50
+ {...props}
51
+ >
52
+ {children}
53
+ </select>
54
+ <span
55
+ className={cn(
56
+ "absolute pointer-events-none left-3 top-[14px] text-sm leading-4 origin-top-left",
57
+ "transition-transform transition-colors duration-150 ease-out",
58
+ hasValue ? "text-muted-foreground" : "text-muted-foreground",
59
+ hasValue ? "-translate-y-2 scale-[0.77]" : "translate-y-0 scale-100"
60
+ )}
61
+ >
62
+ {label}
63
+ </span>
64
+ <svg
65
+ className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none"
66
+ fill="none"
67
+ viewBox="0 0 24 24"
68
+ stroke="currentColor"
69
+ strokeWidth={1.5}
70
+ aria-hidden="true"
71
+ >
72
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
73
+ </svg>
74
+ </div>
75
+ )
76
+ }
77
+ )
@@ -0,0 +1,61 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
5
+ import { ChevronDown } from "lucide-react"
6
+
7
+ import { cn } from "../../lib/utils"
8
+
9
+ /**
10
+ * Accordion — shadcn/ui accordion primitive (wraps Radix Accordion).
11
+ */
12
+
13
+ const Accordion = AccordionPrimitive.Root
14
+
15
+ const AccordionItem = React.forwardRef<
16
+ React.ElementRef<typeof AccordionPrimitive.Item>,
17
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
18
+ >(({ className, ...props }, ref) => (
19
+ <AccordionPrimitive.Item
20
+ ref={ref}
21
+ className={cn("border-b border-border", className)}
22
+ {...props}
23
+ />
24
+ ))
25
+ AccordionItem.displayName = "AccordionItem"
26
+
27
+ const AccordionTrigger = React.forwardRef<
28
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
29
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
30
+ >(({ className, children, ...props }, ref) => (
31
+ <AccordionPrimitive.Header className="flex">
32
+ <AccordionPrimitive.Trigger
33
+ ref={ref}
34
+ className={cn(
35
+ "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
36
+ className
37
+ )}
38
+ {...props}
39
+ >
40
+ {children}
41
+ <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
42
+ </AccordionPrimitive.Trigger>
43
+ </AccordionPrimitive.Header>
44
+ ))
45
+ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
46
+
47
+ const AccordionContent = React.forwardRef<
48
+ React.ElementRef<typeof AccordionPrimitive.Content>,
49
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
50
+ >(({ className, children, ...props }, ref) => (
51
+ <AccordionPrimitive.Content
52
+ ref={ref}
53
+ className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
54
+ {...props}
55
+ >
56
+ <div className={cn("pb-4 pt-0", className)}>{children}</div>
57
+ </AccordionPrimitive.Content>
58
+ ))
59
+ AccordionContent.displayName = AccordionPrimitive.Content.displayName
60
+
61
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Slot } from "@radix-ui/react-slot"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "../../lib/utils"
8
+
9
+ /**
10
+ * Button — matches the current shadcn/ui radix-maia style (2026).
11
+ *
12
+ * Uses rounded-4xl for pill shape, matching shadcn's rounded-4xl.
13
+ * Colors driven by semantic tokens — stores override via CSS variables.
14
+ */
15
+
16
+ const buttonVariants = cva(
17
+ "inline-flex shrink-0 items-center justify-center rounded-[2px] border border-transparent text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
18
+ {
19
+ variants: {
20
+ variant: {
21
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
22
+ outline:
23
+ "border-border bg-input/30 hover:bg-input/50 hover:text-foreground",
24
+ secondary:
25
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
26
+ ghost: "hover:bg-muted hover:text-foreground",
27
+ destructive:
28
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
29
+ link: "text-primary underline-offset-4 hover:underline",
30
+ },
31
+ size: {
32
+ default: "h-9 gap-1.5 px-3",
33
+ xs: "h-6 gap-1 px-2.5 text-xs [&_svg:not([class*='size-'])]:size-3",
34
+ sm: "h-8 gap-1 px-3",
35
+ lg: "h-10 gap-1.5 px-4",
36
+ icon: "size-9",
37
+ "icon-sm": "size-8",
38
+ "icon-lg": "size-10",
39
+ },
40
+ },
41
+ defaultVariants: {
42
+ variant: "default",
43
+ size: "default",
44
+ },
45
+ }
46
+ )
47
+
48
+ export interface ButtonProps
49
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
50
+ VariantProps<typeof buttonVariants> {
51
+ asChild?: boolean
52
+ }
53
+
54
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
55
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
56
+ const Comp = asChild ? Slot : "button"
57
+ return (
58
+ <Comp
59
+ className={cn(buttonVariants({ variant, size, className }))}
60
+ ref={ref}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+ )
66
+ Button.displayName = "Button"
67
+
68
+ export { Button, buttonVariants }
@@ -0,0 +1,16 @@
1
+ "use client"
2
+
3
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
4
+
5
+ /**
6
+ * Collapsible — shadcn/ui collapsible primitive (wraps Radix Collapsible).
7
+ *
8
+ * Used for the checkout's "Company invoice" dropdown and any other
9
+ * expand/collapse UX.
10
+ */
11
+
12
+ const Collapsible = CollapsiblePrimitive.Root
13
+ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
14
+ const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
15
+
16
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent }