@thorprovider/blocks 1.0.0 → 2.0.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/CHANGELOG.md +48 -0
- package/README.md +156 -40
- package/package.json +1 -1
- package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
- package/src/blocks/application/login-forms/login-page.tsx +240 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
- package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
- package/src/blocks/application/verification/verify-1.tsx +2 -2
- package/src/blocks/application/viewport-ownership.test.tsx +41 -0
- package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
- package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
- package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
- package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
- package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
- package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
- package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
- package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
- package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
- package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
- package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
- package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
- package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
- package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
- package/src/blocks/marketing/footers/footer-section.tsx +532 -0
- package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
- package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
- package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
- package/src/blocks/marketing/navbars/navbar.tsx +735 -0
- package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
- package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
- package/src/index.ts +80 -81
- package/src/lib/contracts-curated.test.ts +5 -5
- package/src/lib/contracts.test.ts +55 -0
- package/src/lib/contracts.ts +64 -2
- package/tsup.config.ts +1 -1
- package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
- package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
- package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
- package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
- package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
- package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
- package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
- package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
|
@@ -6,32 +6,35 @@ import { RadioGroup, RadioGroupItem } from '../../../ui/radio-group'
|
|
|
6
6
|
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
7
7
|
import type { Money } from '@thorprovider/types'
|
|
8
8
|
import { CheckoutFieldFeedback, checkoutFieldAria } from './checkout-field-feedback'
|
|
9
|
+
import { CheckoutCountryField } from './checkout-country-field'
|
|
9
10
|
import type {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
CheckoutChangeHandler,
|
|
12
|
+
CheckoutCountryOption,
|
|
13
|
+
CheckoutFieldDescriptions,
|
|
14
|
+
CheckoutValuesFieldErrors,
|
|
15
|
+
CheckoutLabels,
|
|
16
|
+
CheckoutShippingOption,
|
|
17
|
+
CheckoutValues,
|
|
18
|
+
} from './checkout'
|
|
18
19
|
|
|
19
|
-
export interface
|
|
20
|
-
|
|
21
|
-
onChange:
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
export interface CheckoutShippingStepProps {
|
|
21
|
+
values: CheckoutValues
|
|
22
|
+
onChange: CheckoutChangeHandler
|
|
23
|
+
labels: CheckoutLabels
|
|
24
|
+
countries?: CheckoutCountryOption[]
|
|
25
|
+
shippingOptions?: CheckoutShippingOption[]
|
|
24
26
|
selectedShippingOptionId?: string
|
|
25
27
|
onSelectShippingOption?: (id: string) => void
|
|
26
28
|
/** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
|
|
27
29
|
formatMoney?: (money: Money) => string
|
|
28
|
-
fieldErrors?:
|
|
29
|
-
fieldDescriptions?:
|
|
30
|
+
fieldErrors?: CheckoutValuesFieldErrors
|
|
31
|
+
fieldDescriptions?: CheckoutFieldDescriptions
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
export function
|
|
33
|
-
|
|
34
|
+
export function CheckoutShippingStep({
|
|
35
|
+
values,
|
|
34
36
|
onChange,
|
|
37
|
+
labels,
|
|
35
38
|
countries,
|
|
36
39
|
shippingOptions,
|
|
37
40
|
selectedShippingOptionId,
|
|
@@ -39,15 +42,15 @@ export function CheckoutForm1ShippingStep({
|
|
|
39
42
|
formatMoney: formatMoneyProp,
|
|
40
43
|
fieldErrors,
|
|
41
44
|
fieldDescriptions,
|
|
42
|
-
}:
|
|
45
|
+
}: CheckoutShippingStepProps) {
|
|
43
46
|
return (
|
|
44
|
-
<FieldGroup className="grid gap-4">
|
|
47
|
+
<FieldGroup className="grid grid-cols-1 gap-4">
|
|
45
48
|
<Field data-invalid={!!fieldErrors?.address?.length}>
|
|
46
|
-
<FieldLabel htmlFor="checkout-address">
|
|
49
|
+
<FieldLabel htmlFor="checkout-address">{labels.addressLabel}</FieldLabel>
|
|
47
50
|
<Input
|
|
48
51
|
id="checkout-address"
|
|
49
|
-
|
|
50
|
-
value={
|
|
52
|
+
autoComplete="street-address"
|
|
53
|
+
value={values.address}
|
|
51
54
|
onChange={(e) => onChange('address', e.target.value)}
|
|
52
55
|
{...checkoutFieldAria('checkout-address', fieldErrors?.address)}
|
|
53
56
|
/>
|
|
@@ -57,13 +60,14 @@ export function CheckoutForm1ShippingStep({
|
|
|
57
60
|
description={fieldDescriptions?.address}
|
|
58
61
|
/>
|
|
59
62
|
</Field>
|
|
60
|
-
|
|
63
|
+
|
|
64
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
61
65
|
<Field data-invalid={!!fieldErrors?.city?.length}>
|
|
62
|
-
<FieldLabel htmlFor="checkout-city">
|
|
66
|
+
<FieldLabel htmlFor="checkout-city">{labels.cityLabel}</FieldLabel>
|
|
63
67
|
<Input
|
|
64
68
|
id="checkout-city"
|
|
65
|
-
|
|
66
|
-
value={
|
|
69
|
+
autoComplete="address-level2"
|
|
70
|
+
value={values.city}
|
|
67
71
|
onChange={(e) => onChange('city', e.target.value)}
|
|
68
72
|
{...checkoutFieldAria('checkout-city', fieldErrors?.city)}
|
|
69
73
|
/>
|
|
@@ -74,11 +78,11 @@ export function CheckoutForm1ShippingStep({
|
|
|
74
78
|
/>
|
|
75
79
|
</Field>
|
|
76
80
|
<Field data-invalid={!!fieldErrors?.state?.length}>
|
|
77
|
-
<FieldLabel htmlFor="checkout-state">
|
|
81
|
+
<FieldLabel htmlFor="checkout-state">{labels.stateLabel}</FieldLabel>
|
|
78
82
|
<Input
|
|
79
83
|
id="checkout-state"
|
|
80
|
-
|
|
81
|
-
value={
|
|
84
|
+
autoComplete="address-level1"
|
|
85
|
+
value={values.state}
|
|
82
86
|
onChange={(e) => onChange('state', e.target.value)}
|
|
83
87
|
{...checkoutFieldAria('checkout-state', fieldErrors?.state)}
|
|
84
88
|
/>
|
|
@@ -89,13 +93,14 @@ export function CheckoutForm1ShippingStep({
|
|
|
89
93
|
/>
|
|
90
94
|
</Field>
|
|
91
95
|
</div>
|
|
92
|
-
|
|
96
|
+
|
|
97
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
93
98
|
<Field data-invalid={!!fieldErrors?.zipCode?.length}>
|
|
94
|
-
<FieldLabel htmlFor="checkout-zip">
|
|
99
|
+
<FieldLabel htmlFor="checkout-zip">{labels.zipCodeLabel}</FieldLabel>
|
|
95
100
|
<Input
|
|
96
101
|
id="checkout-zip"
|
|
97
|
-
|
|
98
|
-
value={
|
|
102
|
+
autoComplete="postal-code"
|
|
103
|
+
value={values.zipCode}
|
|
99
104
|
onChange={(e) => onChange('zipCode', e.target.value)}
|
|
100
105
|
{...checkoutFieldAria('checkout-zip', fieldErrors?.zipCode)}
|
|
101
106
|
/>
|
|
@@ -105,32 +110,49 @@ export function CheckoutForm1ShippingStep({
|
|
|
105
110
|
description={fieldDescriptions?.zipCode}
|
|
106
111
|
/>
|
|
107
112
|
</Field>
|
|
108
|
-
{countries && countries.length > 0
|
|
109
|
-
<
|
|
113
|
+
{countries && countries.length > 0 ? (
|
|
114
|
+
<CheckoutCountryField
|
|
115
|
+
labels={labels}
|
|
110
116
|
countries={countries}
|
|
111
|
-
value={
|
|
117
|
+
value={values.country}
|
|
112
118
|
onValueChange={(value) => onChange('country', value)}
|
|
113
119
|
errors={fieldErrors?.country}
|
|
114
120
|
description={fieldDescriptions?.country}
|
|
115
121
|
/>
|
|
122
|
+
) : (
|
|
123
|
+
<Field data-invalid={!!fieldErrors?.country?.length}>
|
|
124
|
+
<FieldLabel htmlFor="checkout-country-input">{labels.countryLabel}</FieldLabel>
|
|
125
|
+
<Input
|
|
126
|
+
id="checkout-country-input"
|
|
127
|
+
autoComplete="country-name"
|
|
128
|
+
value={values.country}
|
|
129
|
+
onChange={(e) => onChange('country', e.target.value)}
|
|
130
|
+
{...checkoutFieldAria('checkout-country-input', fieldErrors?.country)}
|
|
131
|
+
/>
|
|
132
|
+
<CheckoutFieldFeedback
|
|
133
|
+
id="checkout-country-input"
|
|
134
|
+
errors={fieldErrors?.country}
|
|
135
|
+
description={fieldDescriptions?.country}
|
|
136
|
+
/>
|
|
137
|
+
</Field>
|
|
116
138
|
)}
|
|
117
139
|
</div>
|
|
118
140
|
|
|
119
141
|
{shippingOptions && shippingOptions.length > 0 ? (
|
|
120
142
|
<FieldSet>
|
|
121
|
-
<FieldLegend>
|
|
143
|
+
<FieldLegend>{labels.shippingMethodLegend}</FieldLegend>
|
|
122
144
|
<RadioGroup
|
|
123
145
|
value={selectedShippingOptionId ?? ''}
|
|
124
146
|
onValueChange={(value) => value && onSelectShippingOption?.(String(value))}
|
|
125
|
-
className="grid gap-3"
|
|
147
|
+
className="grid grid-cols-1 gap-3"
|
|
126
148
|
>
|
|
127
149
|
{shippingOptions.map((option) => (
|
|
128
150
|
<Field
|
|
129
151
|
key={option.id}
|
|
130
152
|
orientation="horizontal"
|
|
131
|
-
className="justify-between rounded-lg border p-4"
|
|
153
|
+
className="flex-wrap items-start justify-between gap-3 rounded-lg border p-4"
|
|
132
154
|
>
|
|
133
|
-
<FieldContent>
|
|
155
|
+
<FieldContent className="min-w-0">
|
|
134
156
|
<FieldLabel htmlFor={`shipping-${option.id}`} className="flex flex-col gap-0.5">
|
|
135
157
|
<span>{option.name}</span>
|
|
136
158
|
{option.description ? (
|
|
@@ -142,7 +164,9 @@ export function CheckoutForm1ShippingStep({
|
|
|
142
164
|
</FieldContent>
|
|
143
165
|
<div className="flex items-center gap-3">
|
|
144
166
|
{isAvailableMoney(option.price) ? (
|
|
145
|
-
<span className="text-sm tabular-nums">
|
|
167
|
+
<span className="text-sm tabular-nums">
|
|
168
|
+
{formatMoney(option.price, formatMoneyProp)}
|
|
169
|
+
</span>
|
|
146
170
|
) : null}
|
|
147
171
|
<RadioGroupItem value={option.id} id={`shipping-${option.id}`} />
|
|
148
172
|
</div>
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
import userEvent from '@testing-library/user-event'
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { Checkout, type CheckoutProps, type CheckoutValues } from './checkout'
|
|
8
|
+
|
|
9
|
+
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
|
10
|
+
|
|
11
|
+
// jsdom lacks browser APIs some shadcn/ui primitives probe on mount.
|
|
12
|
+
if (!window.matchMedia) {
|
|
13
|
+
window.matchMedia = ((query: string) => ({
|
|
14
|
+
matches: false,
|
|
15
|
+
media: query,
|
|
16
|
+
onchange: null,
|
|
17
|
+
addEventListener: () => {},
|
|
18
|
+
removeEventListener: () => {},
|
|
19
|
+
addListener: () => {},
|
|
20
|
+
removeListener: () => {},
|
|
21
|
+
dispatchEvent: () => false,
|
|
22
|
+
})) as unknown as typeof window.matchMedia
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const usd = (amount: string) => ({ amount, currencyCode: 'USD' })
|
|
26
|
+
|
|
27
|
+
const formatMoney = (money: { amount: string; currencyCode: string }) =>
|
|
28
|
+
`${money.currencyCode} ${money.amount}`
|
|
29
|
+
|
|
30
|
+
const emptyValues = (): CheckoutValues => ({
|
|
31
|
+
email: '',
|
|
32
|
+
firstName: '',
|
|
33
|
+
lastName: '',
|
|
34
|
+
phone: '',
|
|
35
|
+
address: '',
|
|
36
|
+
city: '',
|
|
37
|
+
state: '',
|
|
38
|
+
zipCode: '',
|
|
39
|
+
country: '',
|
|
40
|
+
paymentMethod: '',
|
|
41
|
+
saveInfo: false,
|
|
42
|
+
newsletter: false,
|
|
43
|
+
promoCode: '',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const values: CheckoutValues = {
|
|
47
|
+
...emptyValues(),
|
|
48
|
+
email: 'shopper@example.com',
|
|
49
|
+
firstName: 'Ada',
|
|
50
|
+
lastName: 'Lovelace',
|
|
51
|
+
address: '1 Analytical Way',
|
|
52
|
+
city: 'London',
|
|
53
|
+
state: 'LDN',
|
|
54
|
+
zipCode: 'EC1A',
|
|
55
|
+
country: 'GB',
|
|
56
|
+
paymentMethod: 'pp_system_default',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const summary = {
|
|
60
|
+
items: [
|
|
61
|
+
{ id: 'line_1', name: 'Drill', quantity: 2, lineTotal: usd('90.00'), unitPrice: usd('45.00') },
|
|
62
|
+
{ id: 'line_2', name: 'Battery', quantity: 1, lineTotal: usd('20.00') },
|
|
63
|
+
],
|
|
64
|
+
totals: {
|
|
65
|
+
subtotal: usd('110.00'),
|
|
66
|
+
discount: usd('10.00'),
|
|
67
|
+
tax: usd('8.00'),
|
|
68
|
+
shipping: usd('5.00'),
|
|
69
|
+
total: usd('113.00'),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const baseProps = (): CheckoutProps => ({
|
|
74
|
+
presentation: 'stepper',
|
|
75
|
+
values: emptyValues(),
|
|
76
|
+
onChange: () => {},
|
|
77
|
+
formatMoney,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const renderMarkup = (props: Partial<CheckoutProps> = {}) =>
|
|
81
|
+
renderToStaticMarkup(<Checkout {...baseProps()} {...props} />)
|
|
82
|
+
|
|
83
|
+
describe('Checkout — authoritative totals', () => {
|
|
84
|
+
it('renders supplied backend totals verbatim, even when they look inconsistent', () => {
|
|
85
|
+
const html = renderMarkup({ presentation: 'single-page', orderSummary: summary })
|
|
86
|
+
expect(html).toContain('USD 110.00')
|
|
87
|
+
expect(html).toContain('USD 10.00')
|
|
88
|
+
expect(html).toContain('USD 8.00')
|
|
89
|
+
expect(html).toContain('USD 5.00')
|
|
90
|
+
expect(html).toContain('USD 113.00')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('never fabricates a zero for an absent or malformed required total', () => {
|
|
94
|
+
const html = renderMarkup({
|
|
95
|
+
presentation: 'single-page',
|
|
96
|
+
orderSummary: {
|
|
97
|
+
items: summary.items,
|
|
98
|
+
totals: { subtotal: usd(''), total: usd('abc') },
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
expect(html).toContain('Totals are unavailable')
|
|
102
|
+
expect(html).not.toContain('USD 0')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('preserves a legitimate zero total', () => {
|
|
106
|
+
const html = renderMarkup({
|
|
107
|
+
presentation: 'single-page',
|
|
108
|
+
orderSummary: {
|
|
109
|
+
items: summary.items,
|
|
110
|
+
totals: { subtotal: usd('0'), total: usd('0') },
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
expect(html).toContain('USD 0')
|
|
114
|
+
expect(html).not.toContain('Totals are unavailable')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders the controlled pending state without amounts', () => {
|
|
118
|
+
const html = renderMarkup({ presentation: 'single-page', orderSummary: summary, totalsState: 'pending' })
|
|
119
|
+
expect(html).toContain('Updating totals')
|
|
120
|
+
expect(html).not.toContain('USD 113.00')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('renders the controlled error state with the retry action', () => {
|
|
124
|
+
const html = renderMarkup({
|
|
125
|
+
presentation: 'single-page',
|
|
126
|
+
orderSummary: { items: summary.items },
|
|
127
|
+
totalsState: 'error',
|
|
128
|
+
onRetryTotals: () => {},
|
|
129
|
+
})
|
|
130
|
+
expect(html).toContain('We could not update the order totals')
|
|
131
|
+
expect(html).toContain('Retry')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('renders no total row when the host supplies no totals and no retry action', () => {
|
|
135
|
+
const html = renderMarkup({ presentation: 'single-page', orderSummary: { items: summary.items } })
|
|
136
|
+
expect(html).toContain('Totals are unavailable')
|
|
137
|
+
expect(html).not.toContain('USD 0')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('Checkout — payment methods are data and carry no raw card input', () => {
|
|
142
|
+
const methods = [
|
|
143
|
+
{
|
|
144
|
+
id: 'pp_card',
|
|
145
|
+
name: 'Card',
|
|
146
|
+
type: 'card' as const,
|
|
147
|
+
isEnabled: true,
|
|
148
|
+
requiresSetup: false,
|
|
149
|
+
description: 'Pay by card',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'pp_disabled',
|
|
153
|
+
name: 'Legacy transfer',
|
|
154
|
+
type: 'bank_transfer' as const,
|
|
155
|
+
isEnabled: false,
|
|
156
|
+
requiresSetup: false,
|
|
157
|
+
description: 'Not available in this region',
|
|
158
|
+
},
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
it('renders one option per supplied method and the selected description', () => {
|
|
162
|
+
const html = renderMarkup({
|
|
163
|
+
presentation: 'single-page',
|
|
164
|
+
paymentMethods: methods,
|
|
165
|
+
values: { ...values, paymentMethod: 'pp_card' },
|
|
166
|
+
})
|
|
167
|
+
expect(html).toContain('Card')
|
|
168
|
+
expect(html).toContain('Pay by card')
|
|
169
|
+
expect(html).toContain('Legacy transfer')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('renders no card number, expiry, CVV or cardholder input even for a card method', () => {
|
|
173
|
+
const html = renderMarkup({
|
|
174
|
+
presentation: 'single-page',
|
|
175
|
+
paymentMethods: methods,
|
|
176
|
+
values: { ...values, paymentMethod: 'pp_card' },
|
|
177
|
+
})
|
|
178
|
+
expect(html).not.toContain('checkout-card-number')
|
|
179
|
+
expect(html).not.toContain('checkout-expiry')
|
|
180
|
+
expect(html).not.toContain('checkout-cvv')
|
|
181
|
+
expect(html).not.toContain('checkout-card-name')
|
|
182
|
+
expect(html).not.toContain('CVV')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('renders an empty state and keeps the rest of the checkout usable', () => {
|
|
186
|
+
const html = renderMarkup({ presentation: 'single-page', paymentMethods: [] })
|
|
187
|
+
expect(html).toContain('No payment methods are available for this region')
|
|
188
|
+
expect(html).toContain('Contact information')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('Checkout — truthful content and omitted unhandled controls', () => {
|
|
193
|
+
it('omits the promotional control when the host supplies no handler', () => {
|
|
194
|
+
const html = renderMarkup({ presentation: 'single-page', orderSummary: summary })
|
|
195
|
+
expect(html).not.toContain('checkout-promo-code')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('renders the promotional control when the host wires a handler', () => {
|
|
199
|
+
const html = renderMarkup({
|
|
200
|
+
presentation: 'single-page',
|
|
201
|
+
orderSummary: summary,
|
|
202
|
+
onApplyPromo: () => {},
|
|
203
|
+
})
|
|
204
|
+
expect(html).toContain('checkout-promo-code')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('omits consents the host did not wire and renders them when labelled', () => {
|
|
208
|
+
const withoutConsents = renderMarkup({ presentation: 'single-page' })
|
|
209
|
+
expect(withoutConsents).not.toContain('checkout-save-info')
|
|
210
|
+
expect(withoutConsents).not.toContain('checkout-newsletter')
|
|
211
|
+
|
|
212
|
+
const withConsents = renderMarkup({
|
|
213
|
+
presentation: 'single-page',
|
|
214
|
+
labels: { saveInfoLabel: 'Save my details', newsletterLabel: 'Send me offers' },
|
|
215
|
+
})
|
|
216
|
+
expect(withConsents).toContain('checkout-save-info')
|
|
217
|
+
expect(withConsents).toContain('checkout-newsletter')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('omits absent images and keys duplicate names by backend identity', () => {
|
|
221
|
+
const html = renderMarkup({
|
|
222
|
+
presentation: 'single-page',
|
|
223
|
+
orderSummary: {
|
|
224
|
+
items: [
|
|
225
|
+
{ id: 'line_1', name: 'Cable', quantity: 1, lineTotal: usd('5.00') },
|
|
226
|
+
{ id: 'line_2', name: 'Cable', quantity: 2, lineTotal: usd('10.00') },
|
|
227
|
+
],
|
|
228
|
+
totals: { subtotal: usd('15.00'), total: usd('15.00') },
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
expect(html).not.toContain('src=""')
|
|
232
|
+
expect(html.match(/Cable/g)?.length).toBe(2)
|
|
233
|
+
expect(html).not.toContain('Free shipping')
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('Checkout — bounded presentations', () => {
|
|
238
|
+
it('renders only the active step in the stepper presentation', () => {
|
|
239
|
+
const html = renderMarkup({ presentation: 'stepper' })
|
|
240
|
+
expect(html).toContain('Contact information')
|
|
241
|
+
expect(html).not.toContain('Shipping address')
|
|
242
|
+
expect(html).toContain('Checkout progress')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('renders every section in the single-page presentation with the summary first in DOM order', () => {
|
|
246
|
+
const html = renderMarkup({ presentation: 'single-page', orderSummary: summary })
|
|
247
|
+
expect(html).toContain('Contact information')
|
|
248
|
+
expect(html).toContain('Shipping address')
|
|
249
|
+
expect(html).toContain('Payment')
|
|
250
|
+
expect(html.indexOf('Order summary')).toBeLessThan(html.indexOf('Contact information'))
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('appends the review step when the review option is enabled', () => {
|
|
254
|
+
const withoutReview = renderMarkup({ presentation: 'stepper' })
|
|
255
|
+
expect(withoutReview).not.toContain('Review your order')
|
|
256
|
+
|
|
257
|
+
const withReview = renderMarkup({ presentation: 'stepper', showReviewStep: true, values })
|
|
258
|
+
expect(withReview).toContain('Review')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('renders all supplied review values in the single-page review section', () => {
|
|
262
|
+
const html = renderMarkup({
|
|
263
|
+
presentation: 'single-page',
|
|
264
|
+
showReviewStep: true,
|
|
265
|
+
values,
|
|
266
|
+
paymentMethods: [
|
|
267
|
+
{ id: 'pp_system_default', name: 'Manual TEST', type: 'cash_on_delivery', isEnabled: true, requiresSetup: false },
|
|
268
|
+
],
|
|
269
|
+
})
|
|
270
|
+
expect(html).toContain('shopper@example.com')
|
|
271
|
+
expect(html).toContain('1 Analytical Way')
|
|
272
|
+
expect(html).toContain('Manual TEST')
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('Checkout — copy and formatting', () => {
|
|
277
|
+
it('renders supplied labels instead of the defaults', () => {
|
|
278
|
+
const html = renderMarkup({
|
|
279
|
+
presentation: 'single-page',
|
|
280
|
+
labels: {
|
|
281
|
+
title: 'Finalizar compra',
|
|
282
|
+
emailLabel: 'Correo',
|
|
283
|
+
contactHeading: 'Datos de contacto',
|
|
284
|
+
completeOrderLabel: 'Confirmar pedido',
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
expect(html).toContain('Finalizar compra')
|
|
288
|
+
expect(html).toContain('Correo')
|
|
289
|
+
expect(html).toContain('Datos de contacto')
|
|
290
|
+
expect(html).toContain('Confirmar pedido')
|
|
291
|
+
expect(html).not.toContain('Contact information')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('formats amounts through the supplied formatter without a hardcoded symbol', () => {
|
|
295
|
+
const html = renderMarkup({
|
|
296
|
+
presentation: 'single-page',
|
|
297
|
+
orderSummary: summary,
|
|
298
|
+
formatMoney: (money) => `${money.currencyCode} ${money.amount}`,
|
|
299
|
+
})
|
|
300
|
+
expect(html).toContain('USD 110.00')
|
|
301
|
+
expect(html).not.toContain('$110')
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('Checkout — interactions', () => {
|
|
306
|
+
let container: HTMLElement
|
|
307
|
+
let root: Root
|
|
308
|
+
let user: ReturnType<typeof userEvent.setup>
|
|
309
|
+
|
|
310
|
+
beforeEach(() => {
|
|
311
|
+
container = document.createElement('div')
|
|
312
|
+
document.body.appendChild(container)
|
|
313
|
+
root = createRoot(container)
|
|
314
|
+
user = userEvent.setup()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
afterEach(() => {
|
|
318
|
+
act(() => root.unmount())
|
|
319
|
+
container.remove()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
const mount = (props: Partial<CheckoutProps> = {}) => {
|
|
323
|
+
act(() => {
|
|
324
|
+
root.render(<Checkout {...baseProps()} {...props} />)
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const heading = () => container.querySelector('h2')?.textContent
|
|
329
|
+
const continueButton = () =>
|
|
330
|
+
Array.from(container.querySelectorAll('button')).find((button) =>
|
|
331
|
+
button.textContent?.includes('Continue'),
|
|
332
|
+
)!
|
|
333
|
+
const submitButton = () =>
|
|
334
|
+
Array.from(container.querySelectorAll('button')).find((button) => {
|
|
335
|
+
const text = button.textContent ?? ''
|
|
336
|
+
return text.includes('Place order') || text.includes('Placing order')
|
|
337
|
+
})!
|
|
338
|
+
|
|
339
|
+
it('does not pre-select a country, shipping option or payment method', () => {
|
|
340
|
+
mount({
|
|
341
|
+
presentation: 'single-page',
|
|
342
|
+
countries: [{ value: 'GB', label: 'United Kingdom' }],
|
|
343
|
+
shippingOptions: [{ id: 'sm_std', name: 'Standard', price: usd('5.00') }],
|
|
344
|
+
paymentMethods: [
|
|
345
|
+
{ id: 'pp_card', name: 'Card', type: 'card', isEnabled: true, requiresSetup: false },
|
|
346
|
+
],
|
|
347
|
+
})
|
|
348
|
+
const radios = Array.from(container.querySelectorAll('[role="radio"]'))
|
|
349
|
+
expect(radios.length).toBe(2)
|
|
350
|
+
expect(radios.every((radio) => radio.getAttribute('aria-checked') !== 'true')).toBe(true)
|
|
351
|
+
expect(container.querySelector('#checkout-country')?.textContent).toContain('Select a country')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('reports every edit with its field and value in both presentations', async () => {
|
|
355
|
+
for (const presentation of ['stepper', 'single-page'] as const) {
|
|
356
|
+
const onChange = vi.fn()
|
|
357
|
+
mount({ presentation, onChange })
|
|
358
|
+
await user.type(container.querySelector('#checkout-email')!, 'a')
|
|
359
|
+
expect(onChange).toHaveBeenCalledWith('email', 'a')
|
|
360
|
+
act(() => root.unmount())
|
|
361
|
+
root = createRoot(container)
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('gates advancing on the injected validation and reports the step as a notification', async () => {
|
|
366
|
+
const onStepChange = vi.fn()
|
|
367
|
+
const validateStep = vi.fn(() => false)
|
|
368
|
+
mount({ onStepChange, validateStep })
|
|
369
|
+
await user.click(continueButton())
|
|
370
|
+
expect(heading()).toBe('Contact information')
|
|
371
|
+
expect(onStepChange).not.toHaveBeenCalled()
|
|
372
|
+
|
|
373
|
+
const validateAll = vi.fn(() => true)
|
|
374
|
+
mount({ onStepChange, validateStep: validateAll })
|
|
375
|
+
await user.click(continueButton())
|
|
376
|
+
expect(heading()).toBe('Shipping address')
|
|
377
|
+
expect(onStepChange).toHaveBeenCalledWith('shipping')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('advances without a validation provider and moves focus to the step heading', async () => {
|
|
381
|
+
mount()
|
|
382
|
+
await user.click(continueButton())
|
|
383
|
+
expect(heading()).toBe('Shipping address')
|
|
384
|
+
expect(document.activeElement).toBe(container.querySelector('h2'))
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('marks the active step with aria-current', async () => {
|
|
388
|
+
mount({ showReviewStep: true })
|
|
389
|
+
await user.click(continueButton())
|
|
390
|
+
const current = container.querySelector('[aria-current="step"]')
|
|
391
|
+
expect(current?.textContent).toContain('Shipping')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('requires every applicable step before reporting a submission', async () => {
|
|
395
|
+
const onSubmit = vi.fn()
|
|
396
|
+
mount({ validateStep: (step) => step !== 'payment', onSubmit })
|
|
397
|
+
await user.click(continueButton())
|
|
398
|
+
await user.click(continueButton())
|
|
399
|
+
expect(heading()).toBe('Payment')
|
|
400
|
+
await user.click(submitButton())
|
|
401
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
402
|
+
|
|
403
|
+
mount({ validateStep: () => true, onSubmit, values })
|
|
404
|
+
await user.click(continueButton())
|
|
405
|
+
await user.click(continueButton())
|
|
406
|
+
await user.click(submitButton())
|
|
407
|
+
expect(onSubmit).toHaveBeenCalledWith(values)
|
|
408
|
+
expect(container.textContent).not.toMatch(/thank you|order confirmed|success/i)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('blocks repeat activation while a submission is pending', async () => {
|
|
412
|
+
const onSubmit = vi.fn()
|
|
413
|
+
mount({ presentation: 'single-page', submitting: true, onSubmit })
|
|
414
|
+
const button = submitButton()
|
|
415
|
+
expect((button as HTMLButtonElement).disabled).toBe(true)
|
|
416
|
+
await user.click(button)
|
|
417
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('surfaces a failed submission with recovery while keeping the entered values', async () => {
|
|
421
|
+
const onRetrySubmit = vi.fn()
|
|
422
|
+
mount({ presentation: 'single-page', values, submitError: 'Payment was declined', onRetrySubmit })
|
|
423
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toContain('Payment was declined')
|
|
424
|
+
const retry = Array.from(container.querySelectorAll('button')).find((button) =>
|
|
425
|
+
button.textContent?.includes('Try again'),
|
|
426
|
+
)!
|
|
427
|
+
await user.click(retry)
|
|
428
|
+
expect(onRetrySubmit).toHaveBeenCalledTimes(1)
|
|
429
|
+
expect((container.querySelector('#checkout-email') as HTMLInputElement).value).toBe(
|
|
430
|
+
'shopper@example.com',
|
|
431
|
+
)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('does not select a method the host marked unavailable', async () => {
|
|
435
|
+
const onChange = vi.fn()
|
|
436
|
+
mount({
|
|
437
|
+
presentation: 'single-page',
|
|
438
|
+
onChange,
|
|
439
|
+
paymentMethods: [
|
|
440
|
+
{ id: 'pp_disabled', name: 'Legacy transfer', type: 'bank_transfer', isEnabled: false, requiresSetup: false },
|
|
441
|
+
],
|
|
442
|
+
})
|
|
443
|
+
const radio = container.querySelector('#payment-pp_disabled') as HTMLButtonElement
|
|
444
|
+
expect(radio.disabled).toBe(true)
|
|
445
|
+
await user.click(radio)
|
|
446
|
+
expect(onChange).not.toHaveBeenCalledWith('paymentMethod', 'pp_disabled')
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('is fully keyboard reachable in document order', async () => {
|
|
450
|
+
mount({ presentation: 'single-page' })
|
|
451
|
+
const order: string[] = []
|
|
452
|
+
for (let step = 0; step < 30; step += 1) {
|
|
453
|
+
await user.tab()
|
|
454
|
+
const active = document.activeElement as HTMLElement | null
|
|
455
|
+
if (!active || active === document.body) break
|
|
456
|
+
order.push(active.id || active.getAttribute('role') || active.tagName)
|
|
457
|
+
if (active.textContent?.includes('Place order')) break
|
|
458
|
+
}
|
|
459
|
+
const email = order.indexOf('checkout-email')
|
|
460
|
+
const address = order.indexOf('checkout-address')
|
|
461
|
+
const submit = order.findIndex((entry) => entry === 'BUTTON' || entry.includes('Place order'))
|
|
462
|
+
expect(email).toBeGreaterThanOrEqual(0)
|
|
463
|
+
expect(address).toBeGreaterThan(email)
|
|
464
|
+
expect(submit).toBeGreaterThan(address)
|
|
465
|
+
expect(order.filter((entry) => entry.startsWith('checkout-')).length).toBeGreaterThanOrEqual(9)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('selects a shipping option through the host callback', async () => {
|
|
469
|
+
const onSelectShippingOption = vi.fn()
|
|
470
|
+
mount({
|
|
471
|
+
presentation: 'single-page',
|
|
472
|
+
onSelectShippingOption,
|
|
473
|
+
shippingOptions: [{ id: 'sm_exp', name: 'Express', price: usd('15.00') }],
|
|
474
|
+
})
|
|
475
|
+
await user.click(container.querySelector('#shipping-sm_exp')!)
|
|
476
|
+
expect(onSelectShippingOption).toHaveBeenCalledWith('sm_exp')
|
|
477
|
+
})
|
|
478
|
+
})
|