@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +156 -40
  3. package/package.json +1 -1
  4. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  5. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  6. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  7. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  8. package/src/blocks/application/verification/verify-1.tsx +2 -2
  9. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  10. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  11. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  12. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  13. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  14. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  15. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  16. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  17. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  19. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  20. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  21. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  22. package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
  23. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  24. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  25. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  27. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
  29. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
  30. package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
  32. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  33. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  34. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  35. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  36. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  37. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  38. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  39. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  40. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  41. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  42. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  43. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  44. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  45. package/src/index.ts +80 -81
  46. package/src/lib/contracts-curated.test.ts +5 -5
  47. package/src/lib/contracts.test.ts +55 -0
  48. package/src/lib/contracts.ts +64 -2
  49. package/tsup.config.ts +1 -1
  50. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  51. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  52. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  53. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  54. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  55. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  56. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  57. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  58. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  59. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  60. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  61. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  62. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  63. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  67. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  68. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  69. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  70. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  71. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  72. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  73. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  74. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  75. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  76. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  77. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  78. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  79. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  80. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  81. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  82. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  83. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  84. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  85. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  86. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  87. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  88. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  89. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  90. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  91. 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
- CheckoutForm1ChangeHandler,
11
- CheckoutForm1CountryOption,
12
- CheckoutForm1FieldDescriptions,
13
- CheckoutForm1FieldErrors,
14
- CheckoutForm1FormData,
15
- CheckoutForm1ShippingOption,
16
- } from './checkout-form-1'
17
- import { CheckoutForm1CountryField } from './checkout-form-1-country-field'
11
+ CheckoutChangeHandler,
12
+ CheckoutCountryOption,
13
+ CheckoutFieldDescriptions,
14
+ CheckoutValuesFieldErrors,
15
+ CheckoutLabels,
16
+ CheckoutShippingOption,
17
+ CheckoutValues,
18
+ } from './checkout'
18
19
 
19
- export interface CheckoutForm1ShippingStepProps {
20
- formData: CheckoutForm1FormData
21
- onChange: CheckoutForm1ChangeHandler
22
- countries?: CheckoutForm1CountryOption[]
23
- shippingOptions?: CheckoutForm1ShippingOption[]
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?: CheckoutForm1FieldErrors
29
- fieldDescriptions?: CheckoutForm1FieldDescriptions
30
+ fieldErrors?: CheckoutValuesFieldErrors
31
+ fieldDescriptions?: CheckoutFieldDescriptions
30
32
  }
31
33
 
32
- export function CheckoutForm1ShippingStep({
33
- formData,
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
- }: CheckoutForm1ShippingStepProps) {
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">Street address</FieldLabel>
49
+ <FieldLabel htmlFor="checkout-address">{labels.addressLabel}</FieldLabel>
47
50
  <Input
48
51
  id="checkout-address"
49
- placeholder="123 Main St"
50
- value={formData.address}
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
- <div className="grid gap-4 md:grid-cols-2">
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">City</FieldLabel>
66
+ <FieldLabel htmlFor="checkout-city">{labels.cityLabel}</FieldLabel>
63
67
  <Input
64
68
  id="checkout-city"
65
- placeholder="San Francisco"
66
- value={formData.city}
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">State / Province</FieldLabel>
81
+ <FieldLabel htmlFor="checkout-state">{labels.stateLabel}</FieldLabel>
78
82
  <Input
79
83
  id="checkout-state"
80
- placeholder="CA"
81
- value={formData.state}
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
- <div className="grid gap-4 md:grid-cols-2">
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">ZIP / Postal code</FieldLabel>
99
+ <FieldLabel htmlFor="checkout-zip">{labels.zipCodeLabel}</FieldLabel>
95
100
  <Input
96
101
  id="checkout-zip"
97
- placeholder="94103"
98
- value={formData.zipCode}
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
- <CheckoutForm1CountryField
113
+ {countries && countries.length > 0 ? (
114
+ <CheckoutCountryField
115
+ labels={labels}
110
116
  countries={countries}
111
- value={formData.country}
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>Shipping method</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">{formatMoney(option.price, formatMoneyProp)}</span>
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
+ })