@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,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 }
|