@thorprovider/blocks 0.1.1 → 0.1.3
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/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +42 -5
- package/AGENTS.md +1 -1
- package/README.md +94 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1266 -97
- package/dist/contracts.d.ts +1266 -97
- package/dist/contracts.js +225 -9
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +207 -10
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +756 -96
- package/dist/index.d.ts +756 -96
- package/dist/index.js +5114 -3702
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4939 -3551
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
- package/src/index.ts +56 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +234 -6
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import type { PaymentMethod } from '@thorprovider/types'
|
|
4
|
+
import { CheckoutForm1PaymentStep } from './checkout-form-1-payment-step'
|
|
5
|
+
import { CheckoutForm1ShippingStep } from './checkout-form-1-shipping-step'
|
|
6
|
+
import { CheckoutForm1OrderSummary } from './checkout-form-1-order-summary'
|
|
7
|
+
import type { CheckoutForm1FormData } from './checkout-form-1'
|
|
8
|
+
|
|
9
|
+
const formData: CheckoutForm1FormData = {
|
|
10
|
+
email: '',
|
|
11
|
+
firstName: '',
|
|
12
|
+
lastName: '',
|
|
13
|
+
phone: '',
|
|
14
|
+
address: '',
|
|
15
|
+
city: '',
|
|
16
|
+
state: '',
|
|
17
|
+
zipCode: '',
|
|
18
|
+
country: 'US',
|
|
19
|
+
paymentMethod: 'pp_card',
|
|
20
|
+
cardNumber: '',
|
|
21
|
+
expiryDate: '',
|
|
22
|
+
cvv: '',
|
|
23
|
+
cardName: '',
|
|
24
|
+
saveInfo: false,
|
|
25
|
+
sameAsBilling: true,
|
|
26
|
+
newsletter: false,
|
|
27
|
+
promoCode: '',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cardMethod: PaymentMethod = {
|
|
31
|
+
id: 'pp_card',
|
|
32
|
+
name: 'Tarjeta',
|
|
33
|
+
type: 'card',
|
|
34
|
+
isEnabled: true,
|
|
35
|
+
requiresSetup: true,
|
|
36
|
+
description: 'Paga con tarjeta',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const manualMethod: PaymentMethod = {
|
|
40
|
+
id: 'pp_system_default',
|
|
41
|
+
name: 'Pago manual TEST',
|
|
42
|
+
type: 'cash_on_delivery',
|
|
43
|
+
isEnabled: true,
|
|
44
|
+
requiresSetup: false,
|
|
45
|
+
description: 'Pago de prueba, sin tarjeta',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const noop = () => {}
|
|
49
|
+
|
|
50
|
+
const formatMoney = (money: { amount: string; currencyCode: string }) =>
|
|
51
|
+
`${money.currencyCode} ${money.amount}`
|
|
52
|
+
|
|
53
|
+
describe('CheckoutForm1 payment step — launch flow collects no raw card data', () => {
|
|
54
|
+
it('renders no card fields by default even for a card-type method', () => {
|
|
55
|
+
const html = renderToStaticMarkup(
|
|
56
|
+
<CheckoutForm1PaymentStep
|
|
57
|
+
formData={formData}
|
|
58
|
+
onChange={noop}
|
|
59
|
+
onCardNumberChange={noop}
|
|
60
|
+
onExpiryChange={noop}
|
|
61
|
+
paymentMethods={[cardMethod]}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
expect(html).not.toContain('checkout-card-number')
|
|
65
|
+
expect(html).not.toContain('checkout-cvv')
|
|
66
|
+
expect(html).toContain('Paga con tarjeta')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders the approved manual TEST provider label only', () => {
|
|
70
|
+
const html = renderToStaticMarkup(
|
|
71
|
+
<CheckoutForm1PaymentStep
|
|
72
|
+
formData={{ ...formData, paymentMethod: 'pp_system_default' }}
|
|
73
|
+
onChange={noop}
|
|
74
|
+
onCardNumberChange={noop}
|
|
75
|
+
onExpiryChange={noop}
|
|
76
|
+
paymentMethods={[manualMethod]}
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
expect(html).toContain('Pago manual TEST')
|
|
80
|
+
expect(html).toContain('Pago de prueba, sin tarjeta')
|
|
81
|
+
expect(html).not.toContain('checkout-card-number')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders card fields only in the explicit legacy mode', () => {
|
|
85
|
+
const html = renderToStaticMarkup(
|
|
86
|
+
<CheckoutForm1PaymentStep
|
|
87
|
+
formData={formData}
|
|
88
|
+
onChange={noop}
|
|
89
|
+
onCardNumberChange={noop}
|
|
90
|
+
onExpiryChange={noop}
|
|
91
|
+
paymentMethods={[cardMethod]}
|
|
92
|
+
collectCardFields
|
|
93
|
+
/>,
|
|
94
|
+
)
|
|
95
|
+
expect(html).toContain('checkout-card-number')
|
|
96
|
+
expect(html).toContain('checkout-cvv')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('CheckoutForm1 shipping step — controlled eligible options', () => {
|
|
101
|
+
it('renders only the supplied eligible shipping options with their prices', () => {
|
|
102
|
+
const html = renderToStaticMarkup(
|
|
103
|
+
<CheckoutForm1ShippingStep
|
|
104
|
+
formData={formData}
|
|
105
|
+
onChange={noop}
|
|
106
|
+
countries={[{ value: 'US', label: 'Estados Unidos' }]}
|
|
107
|
+
shippingOptions={[
|
|
108
|
+
{ id: 'sm_std', name: 'Estándar', price: { amount: '5.00', currencyCode: 'USD' } },
|
|
109
|
+
{ id: 'sm_exp', name: 'Exprés', price: { amount: '15.00', currencyCode: 'USD' } },
|
|
110
|
+
]}
|
|
111
|
+
selectedShippingOptionId='sm_exp'
|
|
112
|
+
onSelectShippingOption={noop}
|
|
113
|
+
formatMoney={formatMoney}
|
|
114
|
+
/>,
|
|
115
|
+
)
|
|
116
|
+
expect(html).toContain('Estándar')
|
|
117
|
+
expect(html).toContain('Exprés')
|
|
118
|
+
expect(html).toContain('USD 5.00')
|
|
119
|
+
expect(html).toContain('USD 15.00')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('renders no shipping section when no options are supplied', () => {
|
|
123
|
+
const html = renderToStaticMarkup(
|
|
124
|
+
<CheckoutForm1ShippingStep formData={formData} onChange={noop} />,
|
|
125
|
+
)
|
|
126
|
+
expect(html).not.toContain('Shipping method')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('CheckoutForm1 order summary — authoritative totals and states', () => {
|
|
131
|
+
const items = [
|
|
132
|
+
{
|
|
133
|
+
id: 'line_1',
|
|
134
|
+
name: 'Taladro',
|
|
135
|
+
variant: 'Rojo',
|
|
136
|
+
quantity: 2,
|
|
137
|
+
lineTotal: { amount: '90.00', currencyCode: 'USD' },
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
it('renders backend-authoritative totals verbatim', () => {
|
|
142
|
+
const html = renderToStaticMarkup(
|
|
143
|
+
<CheckoutForm1OrderSummary
|
|
144
|
+
orderSummary={{
|
|
145
|
+
items,
|
|
146
|
+
totals: {
|
|
147
|
+
subtotal: { amount: '90.00', currencyCode: 'USD' },
|
|
148
|
+
tax: { amount: '7.20', currencyCode: 'USD' },
|
|
149
|
+
shipping: { amount: '5.00', currencyCode: 'USD' },
|
|
150
|
+
total: { amount: '102.20', currencyCode: 'USD' },
|
|
151
|
+
},
|
|
152
|
+
}}
|
|
153
|
+
formatMoney={formatMoney}
|
|
154
|
+
/>,
|
|
155
|
+
)
|
|
156
|
+
expect(html).toContain('USD 90.00')
|
|
157
|
+
expect(html).toContain('USD 7.20')
|
|
158
|
+
expect(html).toContain('USD 5.00')
|
|
159
|
+
expect(html).toContain('USD 102.20')
|
|
160
|
+
expect(html).not.toContain('Free shipping on orders over')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('renders a controlled pending state without inventing amounts', () => {
|
|
164
|
+
const html = renderToStaticMarkup(
|
|
165
|
+
<CheckoutForm1OrderSummary orderSummary={{ items }} totalsState='pending' />,
|
|
166
|
+
)
|
|
167
|
+
expect(html).toContain('Actualizando totales')
|
|
168
|
+
expect(html).not.toContain('Total')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('never renders a fake zero for absent or malformed required totals', () => {
|
|
172
|
+
const html = renderToStaticMarkup(
|
|
173
|
+
<CheckoutForm1OrderSummary
|
|
174
|
+
orderSummary={{
|
|
175
|
+
items,
|
|
176
|
+
totals: {
|
|
177
|
+
subtotal: { amount: '', currencyCode: 'USD' },
|
|
178
|
+
total: { amount: 'abc', currencyCode: 'USD' },
|
|
179
|
+
},
|
|
180
|
+
}}
|
|
181
|
+
formatMoney={formatMoney}
|
|
182
|
+
/>,
|
|
183
|
+
)
|
|
184
|
+
expect(html).not.toContain('USD 0')
|
|
185
|
+
expect(html).toContain('Totales no disponibles')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('preserves a legitimate zero total', () => {
|
|
189
|
+
const html = renderToStaticMarkup(
|
|
190
|
+
<CheckoutForm1OrderSummary
|
|
191
|
+
orderSummary={{
|
|
192
|
+
items,
|
|
193
|
+
totals: {
|
|
194
|
+
subtotal: { amount: '0', currencyCode: 'USD' },
|
|
195
|
+
total: { amount: '0', currencyCode: 'USD' },
|
|
196
|
+
},
|
|
197
|
+
}}
|
|
198
|
+
formatMoney={formatMoney}
|
|
199
|
+
/>,
|
|
200
|
+
)
|
|
201
|
+
expect(html).toContain('USD 0')
|
|
202
|
+
expect(html).not.toContain('Totales no disponibles')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('renders a controlled error state with a recovery action', () => {
|
|
206
|
+
const html = renderToStaticMarkup(
|
|
207
|
+
<CheckoutForm1OrderSummary
|
|
208
|
+
orderSummary={{ items }}
|
|
209
|
+
totalsState='error'
|
|
210
|
+
onRetryTotals={noop}
|
|
211
|
+
/>,
|
|
212
|
+
)
|
|
213
|
+
expect(html).toContain('No se pudieron actualizar los totales')
|
|
214
|
+
expect(html).toContain('Reintentar')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -11,7 +11,7 @@ import { CheckoutForm1PaymentStep } from './checkout-form-1-payment-step'
|
|
|
11
11
|
import { CheckoutForm1OrderSummary } from './checkout-form-1-order-summary'
|
|
12
12
|
import { CheckoutForm1Progress } from './checkout-form-1-progress'
|
|
13
13
|
import { formatCardNumber, formatExpiryDate } from './card-formatting'
|
|
14
|
-
import type { PaymentMethod } from '@thorprovider/types'
|
|
14
|
+
import type { Money, PaymentMethod } from '@thorprovider/types'
|
|
15
15
|
import type { CheckoutFieldErrors } from './checkout-form-types'
|
|
16
16
|
|
|
17
17
|
export interface CheckoutForm1FormData {
|
|
@@ -44,17 +44,37 @@ export type CheckoutForm1FieldDescriptions = Partial<
|
|
|
44
44
|
export interface CheckoutForm1OrderSummaryItem {
|
|
45
45
|
id: string | number
|
|
46
46
|
name: string
|
|
47
|
-
variant
|
|
48
|
-
price: number
|
|
47
|
+
variant?: string
|
|
49
48
|
quantity: number
|
|
50
|
-
|
|
49
|
+
/** Canonical backend line total; preferred over `price * quantity`. */
|
|
50
|
+
lineTotal?: Money
|
|
51
|
+
/** @deprecated legacy numeric price, used only when `lineTotal` is absent. */
|
|
52
|
+
price?: number
|
|
53
|
+
/** Optional; an absent image is omitted rather than serialized as `""`. */
|
|
54
|
+
image?: string
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
export interface CheckoutForm1Totals {
|
|
58
|
+
subtotal: Money
|
|
59
|
+
discount?: Money
|
|
60
|
+
tax?: Money
|
|
61
|
+
shipping?: Money
|
|
62
|
+
total: Money
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type CheckoutForm1TotalsState = 'idle' | 'pending' | 'error'
|
|
66
|
+
|
|
53
67
|
export interface CheckoutForm1OrderSummary {
|
|
54
68
|
items: CheckoutForm1OrderSummaryItem[]
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
/** Backend-authoritative totals; rendered verbatim when present. */
|
|
70
|
+
totals?: CheckoutForm1Totals
|
|
71
|
+
/** @deprecated legacy numeric amounts, used only when `totals` is absent. */
|
|
72
|
+
shipping?: number
|
|
73
|
+
/** @deprecated */
|
|
74
|
+
tax?: number
|
|
75
|
+
/** @deprecated */
|
|
57
76
|
discount?: number
|
|
77
|
+
/** @deprecated */
|
|
58
78
|
promoDiscount?: number
|
|
59
79
|
}
|
|
60
80
|
|
|
@@ -63,6 +83,15 @@ export interface CheckoutForm1CountryOption {
|
|
|
63
83
|
label: string
|
|
64
84
|
}
|
|
65
85
|
|
|
86
|
+
export interface CheckoutForm1ShippingOption {
|
|
87
|
+
/** Backend shipping method id — the controlled selection identity. */
|
|
88
|
+
id: string
|
|
89
|
+
name: string
|
|
90
|
+
price?: Money
|
|
91
|
+
description?: string
|
|
92
|
+
estimatedDays?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
66
95
|
export type CheckoutForm1ChangeHandler = (
|
|
67
96
|
field: keyof CheckoutForm1FormData,
|
|
68
97
|
value: string | boolean
|
|
@@ -78,11 +107,35 @@ export interface CheckoutForm1Props {
|
|
|
78
107
|
title?: string
|
|
79
108
|
description?: string
|
|
80
109
|
orderSummary?: CheckoutForm1OrderSummary
|
|
110
|
+
/** Controlled state of the totals fetch/mutation. */
|
|
111
|
+
totalsState?: CheckoutForm1TotalsState
|
|
112
|
+
totalsErrorLabel?: string
|
|
113
|
+
retryLabel?: string
|
|
114
|
+
onRetryTotals?: () => void
|
|
81
115
|
countries?: CheckoutForm1CountryOption[]
|
|
116
|
+
/** Eligible shipping options for the cart's region/address. */
|
|
117
|
+
shippingOptions?: CheckoutForm1ShippingOption[]
|
|
118
|
+
/** Controlled selection of a shipping option id. */
|
|
119
|
+
selectedShippingOptionId?: string
|
|
120
|
+
onSelectShippingOption?: (id: string) => void
|
|
82
121
|
paymentMethods?: PaymentMethod[]
|
|
83
122
|
noPaymentMethodsLabel?: string
|
|
123
|
+
/** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
|
|
124
|
+
formatMoney?: (money: Money) => string
|
|
125
|
+
/**
|
|
126
|
+
* Raw card fields are legacy-only and never collected in the launch flow.
|
|
127
|
+
* Defaults to `false`: the curated surface renders no card inputs.
|
|
128
|
+
*/
|
|
129
|
+
collectCardFields?: boolean
|
|
130
|
+
onApplyPromo?: (code: string) => void
|
|
131
|
+
promoPending?: boolean
|
|
132
|
+
promoApplyLabel?: string
|
|
133
|
+
promoPlaceholder?: string
|
|
134
|
+
/** Optional trust copy; rendered only when supplied (never fabricated). */
|
|
135
|
+
trustNotes?: string[]
|
|
84
136
|
backLabel?: string
|
|
85
137
|
completeOrderLabel?: string
|
|
138
|
+
processingLabel?: string
|
|
86
139
|
continueLabel?: string
|
|
87
140
|
values: CheckoutForm1FormData
|
|
88
141
|
onChange: CheckoutForm1ChangeHandler
|
|
@@ -94,17 +147,35 @@ export interface CheckoutForm1Props {
|
|
|
94
147
|
submitting?: boolean
|
|
95
148
|
/** Order-level error surfaced above the footer actions. */
|
|
96
149
|
submitError?: string
|
|
150
|
+
/** Optional recovery action for a failed submission. */
|
|
151
|
+
onRetrySubmit?: () => void
|
|
152
|
+
submitErrorActionLabel?: string
|
|
97
153
|
}
|
|
98
154
|
|
|
99
155
|
export default function CheckoutForm1({
|
|
100
156
|
title,
|
|
101
157
|
description,
|
|
102
158
|
orderSummary,
|
|
159
|
+
totalsState = 'idle',
|
|
160
|
+
totalsErrorLabel,
|
|
161
|
+
retryLabel,
|
|
162
|
+
onRetryTotals,
|
|
103
163
|
countries,
|
|
164
|
+
shippingOptions,
|
|
165
|
+
selectedShippingOptionId,
|
|
166
|
+
onSelectShippingOption,
|
|
104
167
|
paymentMethods,
|
|
105
168
|
noPaymentMethodsLabel,
|
|
169
|
+
formatMoney,
|
|
170
|
+
collectCardFields = false,
|
|
171
|
+
onApplyPromo,
|
|
172
|
+
promoPending = false,
|
|
173
|
+
promoApplyLabel,
|
|
174
|
+
promoPlaceholder,
|
|
175
|
+
trustNotes,
|
|
106
176
|
backLabel = 'Atrás',
|
|
107
177
|
completeOrderLabel = 'Completar pedido',
|
|
178
|
+
processingLabel = 'Procesando pedido…',
|
|
108
179
|
continueLabel = 'Continuar',
|
|
109
180
|
values,
|
|
110
181
|
onChange,
|
|
@@ -114,6 +185,8 @@ export default function CheckoutForm1({
|
|
|
114
185
|
onSubmit,
|
|
115
186
|
submitting = false,
|
|
116
187
|
submitError,
|
|
188
|
+
onRetrySubmit,
|
|
189
|
+
submitErrorActionLabel = 'Reintentar',
|
|
117
190
|
}: CheckoutForm1Props) {
|
|
118
191
|
const [step, setStep] = useState(1)
|
|
119
192
|
|
|
@@ -175,6 +248,10 @@ export default function CheckoutForm1({
|
|
|
175
248
|
formData={values}
|
|
176
249
|
onChange={onChange}
|
|
177
250
|
countries={countries}
|
|
251
|
+
shippingOptions={shippingOptions}
|
|
252
|
+
selectedShippingOptionId={selectedShippingOptionId}
|
|
253
|
+
onSelectShippingOption={onSelectShippingOption}
|
|
254
|
+
formatMoney={formatMoney}
|
|
178
255
|
fieldErrors={fieldErrors}
|
|
179
256
|
fieldDescriptions={fieldDescriptions}
|
|
180
257
|
/>
|
|
@@ -187,6 +264,7 @@ export default function CheckoutForm1({
|
|
|
187
264
|
onExpiryChange={handleExpiryChange}
|
|
188
265
|
paymentMethods={paymentMethods}
|
|
189
266
|
noPaymentMethodsLabel={noPaymentMethodsLabel}
|
|
267
|
+
collectCardFields={collectCardFields}
|
|
190
268
|
fieldErrors={fieldErrors}
|
|
191
269
|
fieldDescriptions={fieldDescriptions}
|
|
192
270
|
/>
|
|
@@ -195,9 +273,20 @@ export default function CheckoutForm1({
|
|
|
195
273
|
{submitError && (
|
|
196
274
|
<div
|
|
197
275
|
role="alert"
|
|
198
|
-
className="mt-6 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
|
276
|
+
className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
|
199
277
|
>
|
|
200
|
-
{submitError}
|
|
278
|
+
<span>{submitError}</span>
|
|
279
|
+
{onRetrySubmit ? (
|
|
280
|
+
<Button
|
|
281
|
+
type="button"
|
|
282
|
+
variant="outline"
|
|
283
|
+
size="sm"
|
|
284
|
+
onClick={onRetrySubmit}
|
|
285
|
+
disabled={submitting}
|
|
286
|
+
>
|
|
287
|
+
{submitErrorActionLabel}
|
|
288
|
+
</Button>
|
|
289
|
+
) : null}
|
|
201
290
|
</div>
|
|
202
291
|
)}
|
|
203
292
|
|
|
@@ -213,7 +302,7 @@ export default function CheckoutForm1({
|
|
|
213
302
|
{isLastStep ? (
|
|
214
303
|
<Button type="button" onClick={handleSubmit} disabled={submitting}>
|
|
215
304
|
<Lock className="size-4" />
|
|
216
|
-
{submitting ?
|
|
305
|
+
{submitting ? processingLabel : completeOrderLabel}
|
|
217
306
|
</Button>
|
|
218
307
|
) : (
|
|
219
308
|
<Button type="button" onClick={handleContinue} disabled={submitting}>
|
|
@@ -226,10 +315,24 @@ export default function CheckoutForm1({
|
|
|
226
315
|
|
|
227
316
|
{orderSummary && (
|
|
228
317
|
<div className="lg:col-span-1">
|
|
229
|
-
<CheckoutForm1OrderSummary
|
|
318
|
+
<CheckoutForm1OrderSummary
|
|
319
|
+
orderSummary={orderSummary}
|
|
320
|
+
totalsState={totalsState}
|
|
321
|
+
totalsErrorLabel={totalsErrorLabel}
|
|
322
|
+
retryLabel={retryLabel}
|
|
323
|
+
onRetryTotals={onRetryTotals}
|
|
324
|
+
promoCode={values.promoCode}
|
|
325
|
+
onPromoCodeChange={(value) => onChange('promoCode', value)}
|
|
326
|
+
onApplyPromo={onApplyPromo}
|
|
327
|
+
promoPending={promoPending}
|
|
328
|
+
promoApplyLabel={promoApplyLabel}
|
|
329
|
+
promoPlaceholder={promoPlaceholder}
|
|
330
|
+
trustNotes={trustNotes}
|
|
331
|
+
formatMoney={formatMoney}
|
|
332
|
+
/>
|
|
230
333
|
</div>
|
|
231
334
|
)}
|
|
232
335
|
</div>
|
|
233
336
|
</div>
|
|
234
337
|
)
|
|
235
|
-
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { BackToTop, backToTopPropsSchema, resolveScrollBehavior } from './back-to-top'
|
|
5
|
+
|
|
6
|
+
describe('BackToTop', () => {
|
|
7
|
+
it('parses a valid props object', () => {
|
|
8
|
+
const result = backToTopPropsSchema.parse({ threshold: 300 })
|
|
9
|
+
expect(result.threshold).toBe(300)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('accepts an empty props object', () => {
|
|
13
|
+
expect(() => backToTopPropsSchema.parse({})).not.toThrow()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('uses an instant jump under reduced motion', () => {
|
|
17
|
+
expect(resolveScrollBehavior(true)).toBe('auto')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('uses a smooth scroll when motion is allowed', () => {
|
|
21
|
+
expect(resolveScrollBehavior(false)).toBe('smooth')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders nothing before it becomes visible', () => {
|
|
25
|
+
expect(renderToStaticMarkup(<BackToTop />)).toBe('')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ArrowUp } from 'lucide-react'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../../lib/utils'
|
|
8
|
+
|
|
9
|
+
export const backToTopPropsSchema = z.object({
|
|
10
|
+
threshold: z.number().optional(),
|
|
11
|
+
label: z.string().optional(),
|
|
12
|
+
className: z.string().optional(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type BackToTopProps = z.infer<typeof backToTopPropsSchema>
|
|
16
|
+
|
|
17
|
+
const DEFAULT_THRESHOLD = 400
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the scroll behavior for a back-to-top activation. Pure so it can be
|
|
21
|
+
* unit-tested without a DOM: reduced motion yields an instant jump.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveScrollBehavior(
|
|
24
|
+
prefersReducedMotion: boolean,
|
|
25
|
+
): 'auto' | 'smooth' {
|
|
26
|
+
return prefersReducedMotion ? 'auto' : 'smooth'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Back-to-top control that appears once the page is scrolled past `threshold`
|
|
31
|
+
* and returns the viewport to the top. Honors `prefers-reduced-motion` (instant
|
|
32
|
+
* jump instead of a smooth scroll). Framework-agnostic; icon-only with an
|
|
33
|
+
* accessible label.
|
|
34
|
+
*/
|
|
35
|
+
export function BackToTop({
|
|
36
|
+
threshold = DEFAULT_THRESHOLD,
|
|
37
|
+
label = 'Back to top',
|
|
38
|
+
className,
|
|
39
|
+
}: BackToTopProps) {
|
|
40
|
+
const [visible, setVisible] = useState(false)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const onScroll = () => setVisible(window.scrollY > threshold)
|
|
44
|
+
onScroll()
|
|
45
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
46
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
47
|
+
}, [threshold])
|
|
48
|
+
|
|
49
|
+
if (!visible) return null
|
|
50
|
+
|
|
51
|
+
const handleClick = () => {
|
|
52
|
+
const reduce =
|
|
53
|
+
typeof window !== 'undefined' &&
|
|
54
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
55
|
+
window.scrollTo({ top: 0, behavior: resolveScrollBehavior(reduce) })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={handleClick}
|
|
62
|
+
aria-label={label}
|
|
63
|
+
className={cn(
|
|
64
|
+
'fixed bottom-4 right-4 z-40 inline-flex size-11 items-center justify-center rounded-full border bg-card text-foreground shadow-lg transition-colors hover:bg-secondary',
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<ArrowUp className="size-5" aria-hidden="true" />
|
|
69
|
+
</button>
|
|
70
|
+
)
|
|
71
|
+
}
|