@thorprovider/blocks 0.1.3 → 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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. 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
+ })