@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
@@ -0,0 +1,550 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { Lock } from 'lucide-react'
5
+ import { Button } from '../../../ui/button'
6
+ import { Card, CardContent } from '../../../ui/card'
7
+ import { cn } from '../../../lib/utils'
8
+ import { CheckoutContactStep } from './checkout-contact-step'
9
+ import { CheckoutShippingStep } from './checkout-shipping-step'
10
+ import { CheckoutPaymentStep } from './checkout-payment-step'
11
+ import { CheckoutReviewStep } from './checkout-review-step'
12
+ import { CheckoutOrderSummaryPanel } from './checkout-order-summary'
13
+ import { CheckoutProgress } from './checkout-progress'
14
+ import type { Money, PaymentMethod } from '@thorprovider/types'
15
+ import type { CheckoutFieldErrors as GenericCheckoutFieldErrors } from './checkout-form-types'
16
+
17
+ /**
18
+ * The canonical checkout is a page-level unit. The host owns the commercial
19
+ * state (values, selections, totals, submission); the block owns only the
20
+ * ephemeral step index of the `stepper` presentation.
21
+ */
22
+ export type CheckoutPresentation = 'single-page' | 'stepper'
23
+
24
+ export type CheckoutStepId = 'contact' | 'shipping' | 'payment' | 'review'
25
+
26
+ /** Flat, controlled form values. No card data: payment entry belongs to the host provider surface. */
27
+ export interface CheckoutValues {
28
+ email: string
29
+ firstName: string
30
+ lastName: string
31
+ phone: string
32
+ address: string
33
+ city: string
34
+ state: string
35
+ zipCode: string
36
+ country: string
37
+ paymentMethod: string
38
+ saveInfo: boolean
39
+ newsletter: boolean
40
+ promoCode: string
41
+ }
42
+
43
+ export type CheckoutValuesFieldErrors = GenericCheckoutFieldErrors<keyof CheckoutValues>
44
+
45
+ export type CheckoutFieldDescriptions = Partial<Record<keyof CheckoutValues, string>>
46
+
47
+ export interface CheckoutOrderLine {
48
+ /** Backend line identity — the stable React key. */
49
+ id: string | number
50
+ name: string
51
+ variant?: string
52
+ quantity: number
53
+ /** Backend-authoritative line total; rendered verbatim. */
54
+ lineTotal?: Money
55
+ /** Optional unit price shown as secondary text; never multiplied. */
56
+ unitPrice?: Money
57
+ /** Optional; an absent image is omitted rather than serialized as `""`. */
58
+ image?: string
59
+ }
60
+
61
+ export interface CheckoutTotals {
62
+ subtotal: Money
63
+ discount?: Money
64
+ tax?: Money
65
+ shipping?: Money
66
+ total: Money
67
+ }
68
+
69
+ export interface CheckoutOrderSummary {
70
+ items: CheckoutOrderLine[]
71
+ /** Backend-authoritative totals; rendered verbatim. */
72
+ totals?: CheckoutTotals
73
+ }
74
+
75
+ export type CheckoutTotalsState = 'idle' | 'pending' | 'error'
76
+
77
+ export interface CheckoutCountryOption {
78
+ value: string
79
+ label: string
80
+ }
81
+
82
+ export interface CheckoutShippingOption {
83
+ /** Backend shipping method id — the controlled selection identity. */
84
+ id: string
85
+ name: string
86
+ price?: Money
87
+ description?: string
88
+ estimatedDays?: string
89
+ }
90
+
91
+ export type CheckoutChangeHandler = (field: keyof CheckoutValues, value: string | boolean) => void
92
+
93
+ /**
94
+ * Every user-facing string is overridable. Neutral English defaults ship with
95
+ * the block; the host supplies its own copy through this object.
96
+ */
97
+ export interface CheckoutLabels {
98
+ title: string
99
+ description: string
100
+ orderSummaryTitle: string
101
+ stepProgressLabel: string
102
+ contactStepName: string
103
+ shippingStepName: string
104
+ paymentStepName: string
105
+ reviewStepName: string
106
+ contactHeading: string
107
+ contactDescription: string
108
+ shippingHeading: string
109
+ shippingDescription: string
110
+ paymentHeading: string
111
+ paymentDescription: string
112
+ reviewHeading: string
113
+ reviewDescription: string
114
+ emailLabel: string
115
+ firstNameLabel: string
116
+ lastNameLabel: string
117
+ phoneLabel: string
118
+ addressLabel: string
119
+ cityLabel: string
120
+ stateLabel: string
121
+ zipCodeLabel: string
122
+ countryLabel: string
123
+ countryPlaceholder: string
124
+ shippingMethodLegend: string
125
+ paymentMethodLegend: string
126
+ noPaymentMethodsLabel: string
127
+ unavailableMethodLabel: string
128
+ requiresSetupLabel: string
129
+ saveInfoLabel: string
130
+ newsletterLabel: string
131
+ promoCodeLabel: string
132
+ promoPlaceholder: string
133
+ promoApplyLabel: string
134
+ subtotalLabel: string
135
+ discountLabel: string
136
+ taxLabel: string
137
+ shippingLabel: string
138
+ totalLabel: string
139
+ eachLabel: string
140
+ totalsPendingLabel: string
141
+ totalsErrorLabel: string
142
+ totalsUnavailableLabel: string
143
+ amountUnavailableLabel: string
144
+ retryLabel: string
145
+ reviewContactLabel: string
146
+ reviewShipToLabel: string
147
+ reviewPaymentLabel: string
148
+ reviewShippingLabel: string
149
+ reviewEmptyLabel: string
150
+ backLabel: string
151
+ continueLabel: string
152
+ completeOrderLabel: string
153
+ processingLabel: string
154
+ submitErrorActionLabel: string
155
+ }
156
+
157
+ const DEFAULT_LABELS: CheckoutLabels = {
158
+ title: 'Checkout',
159
+ description: '',
160
+ orderSummaryTitle: 'Order summary',
161
+ stepProgressLabel: 'Checkout progress',
162
+ contactStepName: 'Contact',
163
+ shippingStepName: 'Shipping',
164
+ paymentStepName: 'Payment',
165
+ reviewStepName: 'Review',
166
+ contactHeading: 'Contact information',
167
+ contactDescription: 'We use these details to keep you informed about your order.',
168
+ shippingHeading: 'Shipping address',
169
+ shippingDescription: 'Tell us where the order should be delivered.',
170
+ paymentHeading: 'Payment',
171
+ paymentDescription: 'Choose how you want to pay for this order.',
172
+ reviewHeading: 'Review your order',
173
+ reviewDescription: 'Check the details below before placing the order.',
174
+ emailLabel: 'Email',
175
+ firstNameLabel: 'First name',
176
+ lastNameLabel: 'Last name',
177
+ phoneLabel: 'Phone (optional)',
178
+ addressLabel: 'Street address',
179
+ cityLabel: 'City',
180
+ stateLabel: 'State / Province',
181
+ zipCodeLabel: 'ZIP / Postal code',
182
+ countryLabel: 'Country',
183
+ countryPlaceholder: 'Select a country',
184
+ shippingMethodLegend: 'Shipping method',
185
+ paymentMethodLegend: 'Payment method',
186
+ noPaymentMethodsLabel: 'No payment methods are available for this region.',
187
+ unavailableMethodLabel: 'Unavailable',
188
+ requiresSetupLabel: 'Requires additional setup',
189
+ saveInfoLabel: 'Save this information for next time',
190
+ newsletterLabel: 'Email me about news and offers',
191
+ promoCodeLabel: 'Promotional code',
192
+ promoPlaceholder: 'Enter the code',
193
+ promoApplyLabel: 'Apply',
194
+ subtotalLabel: 'Subtotal',
195
+ discountLabel: 'Discount',
196
+ taxLabel: 'Tax',
197
+ shippingLabel: 'Shipping',
198
+ totalLabel: 'Total',
199
+ eachLabel: 'each',
200
+ totalsPendingLabel: 'Updating totals…',
201
+ totalsErrorLabel: 'We could not update the order totals.',
202
+ totalsUnavailableLabel: 'Totals are unavailable.',
203
+ amountUnavailableLabel: '—',
204
+ retryLabel: 'Retry',
205
+ reviewContactLabel: 'Contact',
206
+ reviewShipToLabel: 'Ship to',
207
+ reviewPaymentLabel: 'Payment',
208
+ reviewShippingLabel: 'Shipping method',
209
+ reviewEmptyLabel: 'No details provided yet.',
210
+ backLabel: 'Back',
211
+ continueLabel: 'Continue',
212
+ completeOrderLabel: 'Place order',
213
+ processingLabel: 'Placing order…',
214
+ submitErrorActionLabel: 'Try again',
215
+ }
216
+
217
+ const BASE_STEPS: CheckoutStepId[] = ['contact', 'shipping', 'payment']
218
+
219
+ const STEP_COPY: Record<
220
+ CheckoutStepId,
221
+ { name: keyof CheckoutLabels; heading: keyof CheckoutLabels; description: keyof CheckoutLabels }
222
+ > = {
223
+ contact: {
224
+ name: 'contactStepName',
225
+ heading: 'contactHeading',
226
+ description: 'contactDescription',
227
+ },
228
+ shipping: {
229
+ name: 'shippingStepName',
230
+ heading: 'shippingHeading',
231
+ description: 'shippingDescription',
232
+ },
233
+ payment: {
234
+ name: 'paymentStepName',
235
+ heading: 'paymentHeading',
236
+ description: 'paymentDescription',
237
+ },
238
+ review: {
239
+ name: 'reviewStepName',
240
+ heading: 'reviewHeading',
241
+ description: 'reviewDescription',
242
+ },
243
+ }
244
+
245
+ export interface CheckoutProps {
246
+ presentation: CheckoutPresentation
247
+ /** Appends a confirmation step (`stepper`) or section (`single-page`) before submission. */
248
+ showReviewStep?: boolean
249
+ values: CheckoutValues
250
+ onChange: CheckoutChangeHandler
251
+ orderSummary?: CheckoutOrderSummary
252
+ totalsState?: CheckoutTotalsState
253
+ onRetryTotals?: () => void
254
+ countries?: CheckoutCountryOption[]
255
+ shippingOptions?: CheckoutShippingOption[]
256
+ selectedShippingOptionId?: string
257
+ onSelectShippingOption?: (id: string) => void
258
+ paymentMethods?: PaymentMethod[]
259
+ onApplyPromo?: (code: string) => void
260
+ promoPending?: boolean
261
+ /** Optional trust copy; rendered only when supplied. */
262
+ trustNotes?: string[]
263
+ labels?: Partial<CheckoutLabels>
264
+ /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
265
+ formatMoney?: (money: Money) => string
266
+ fieldErrors?: CheckoutValuesFieldErrors
267
+ fieldDescriptions?: CheckoutFieldDescriptions
268
+ /** Consulted before advancing and for every applicable step before submitting. */
269
+ validateStep?: (step: CheckoutStepId) => boolean
270
+ /** Notification only — never an authoritative controlled value. */
271
+ onStepChange?: (step: CheckoutStepId) => void
272
+ onSubmit?: (values: CheckoutValues) => void
273
+ submitting?: boolean
274
+ submitError?: string
275
+ onRetrySubmit?: () => void
276
+ }
277
+
278
+ export function Checkout({
279
+ presentation,
280
+ showReviewStep = false,
281
+ values,
282
+ onChange,
283
+ orderSummary,
284
+ totalsState = 'idle',
285
+ onRetryTotals,
286
+ countries,
287
+ shippingOptions,
288
+ selectedShippingOptionId,
289
+ onSelectShippingOption,
290
+ paymentMethods = [],
291
+ onApplyPromo,
292
+ promoPending = false,
293
+ trustNotes,
294
+ labels,
295
+ formatMoney,
296
+ fieldErrors,
297
+ fieldDescriptions,
298
+ validateStep,
299
+ onStepChange,
300
+ onSubmit,
301
+ submitting = false,
302
+ submitError,
303
+ onRetrySubmit,
304
+ }: CheckoutProps) {
305
+ const resolved: CheckoutLabels = { ...DEFAULT_LABELS, ...labels }
306
+ const steps = showReviewStep ? [...BASE_STEPS, 'review' as const] : BASE_STEPS
307
+ const [stepIndex, setStepIndex] = useState(0)
308
+ const activeStep = steps[Math.min(stepIndex, steps.length - 1)]
309
+
310
+ const headingRef = useRef<HTMLHeadingElement>(null)
311
+ const hasMounted = useRef(false)
312
+
313
+ useEffect(() => {
314
+ if (presentation !== 'stepper') return
315
+ if (!hasMounted.current) {
316
+ hasMounted.current = true
317
+ return
318
+ }
319
+ headingRef.current?.focus()
320
+ }, [presentation, activeStep])
321
+
322
+ const moveTo = (nextIndex: number) => {
323
+ const next = steps[nextIndex]
324
+ if (!next) return
325
+ setStepIndex(nextIndex)
326
+ onStepChange?.(next)
327
+ }
328
+
329
+ const handleContinue = () => {
330
+ if (validateStep && !validateStep(activeStep)) return
331
+ moveTo(stepIndex + 1)
332
+ }
333
+
334
+ const handleBack = () => {
335
+ if (stepIndex === 0) return
336
+ moveTo(stepIndex - 1)
337
+ }
338
+
339
+ const handleSubmit = () => {
340
+ if (submitting) return
341
+ if (validateStep && !steps.every((step) => validateStep(step))) return
342
+ onSubmit?.(values)
343
+ }
344
+
345
+ const renderStep = (step: CheckoutStepId) => {
346
+ switch (step) {
347
+ case 'contact':
348
+ return (
349
+ <CheckoutContactStep
350
+ values={values}
351
+ onChange={onChange}
352
+ labels={resolved}
353
+ showSaveInfo={labels?.saveInfoLabel !== undefined}
354
+ showNewsletter={labels?.newsletterLabel !== undefined}
355
+ fieldErrors={fieldErrors}
356
+ fieldDescriptions={fieldDescriptions}
357
+ />
358
+ )
359
+ case 'shipping':
360
+ return (
361
+ <CheckoutShippingStep
362
+ values={values}
363
+ onChange={onChange}
364
+ labels={resolved}
365
+ countries={countries}
366
+ shippingOptions={shippingOptions}
367
+ selectedShippingOptionId={selectedShippingOptionId}
368
+ onSelectShippingOption={onSelectShippingOption}
369
+ formatMoney={formatMoney}
370
+ fieldErrors={fieldErrors}
371
+ fieldDescriptions={fieldDescriptions}
372
+ />
373
+ )
374
+ case 'payment':
375
+ return (
376
+ <CheckoutPaymentStep
377
+ values={values}
378
+ onChange={onChange}
379
+ labels={resolved}
380
+ paymentMethods={paymentMethods}
381
+ fieldErrors={fieldErrors}
382
+ fieldDescriptions={fieldDescriptions}
383
+ />
384
+ )
385
+ case 'review':
386
+ return (
387
+ <CheckoutReviewStep
388
+ values={values}
389
+ labels={resolved}
390
+ selectedShippingOptionId={selectedShippingOptionId}
391
+ shippingOptions={shippingOptions}
392
+ paymentMethods={paymentMethods}
393
+ />
394
+ )
395
+ }
396
+ }
397
+
398
+ const isLastStep = stepIndex === steps.length - 1
399
+
400
+ const submitAction = (
401
+ <Button type="button" onClick={handleSubmit} disabled={submitting}>
402
+ <Lock className="size-4" />
403
+ {submitting ? resolved.processingLabel : resolved.completeOrderLabel}
404
+ </Button>
405
+ )
406
+
407
+ const submitErrorNode = submitError ? (
408
+ <div
409
+ role="alert"
410
+ className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
411
+ >
412
+ <span>{submitError}</span>
413
+ {onRetrySubmit ? (
414
+ <Button
415
+ type="button"
416
+ variant="outline"
417
+ size="sm"
418
+ onClick={onRetrySubmit}
419
+ disabled={submitting}
420
+ >
421
+ {resolved.submitErrorActionLabel}
422
+ </Button>
423
+ ) : null}
424
+ </div>
425
+ ) : null
426
+
427
+ const summaryNode = orderSummary ? (
428
+ <div
429
+ className={cn(
430
+ 'min-w-0 lg:col-span-1',
431
+ presentation === 'single-page' && 'lg:order-last',
432
+ )}
433
+ >
434
+ <CheckoutOrderSummaryPanel
435
+ summary={orderSummary}
436
+ labels={resolved}
437
+ totalsState={totalsState}
438
+ onRetryTotals={onRetryTotals}
439
+ promoCode={values.promoCode}
440
+ onPromoCodeChange={(value) => onChange('promoCode', value)}
441
+ onApplyPromo={onApplyPromo}
442
+ promoPending={promoPending}
443
+ trustNotes={trustNotes}
444
+ formatMoney={formatMoney}
445
+ />
446
+ </div>
447
+ ) : null
448
+
449
+ return (
450
+ <div className="mx-auto max-w-6xl px-4 py-12 sm:px-6 lg:px-8">
451
+ {(resolved.title || resolved.description) && (
452
+ <header className="mb-8 text-center">
453
+ {resolved.title ? (
454
+ <h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{resolved.title}</h1>
455
+ ) : null}
456
+ {resolved.description ? (
457
+ <p className="mt-2 text-muted-foreground">{resolved.description}</p>
458
+ ) : null}
459
+ </header>
460
+ )}
461
+
462
+ {presentation === 'stepper' ? (
463
+ <CheckoutProgress
464
+ steps={steps.map((step) => ({
465
+ id: step,
466
+ label: resolved[STEP_COPY[step].name],
467
+ }))}
468
+ currentStep={activeStep}
469
+ ariaLabel={resolved.stepProgressLabel}
470
+ />
471
+ ) : null}
472
+
473
+ <div className={cn('mt-8 grid grid-cols-1 gap-6', orderSummary && 'lg:grid-cols-3')}>
474
+ {presentation === 'single-page' ? summaryNode : null}
475
+
476
+ <div className={cn('min-w-0', orderSummary ? 'lg:col-span-2' : 'lg:col-span-3')}>
477
+ <Card>
478
+ <CardContent className="p-6">
479
+ {presentation === 'stepper' ? (
480
+ <>
481
+ <div className="mb-6">
482
+ <h2 ref={headingRef} tabIndex={-1} className="text-lg font-semibold outline-none">
483
+ {resolved[STEP_COPY[activeStep].heading]}
484
+ </h2>
485
+ <p className="text-sm text-muted-foreground">
486
+ {resolved[STEP_COPY[activeStep].description]}
487
+ </p>
488
+ </div>
489
+
490
+ {renderStep(activeStep)}
491
+ </>
492
+ ) : (
493
+ <div className="flex flex-col gap-8">
494
+ {steps.map((step) => (
495
+ <section key={step} className="flex flex-col gap-4">
496
+ <div>
497
+ <h2 className="text-lg font-semibold">
498
+ {resolved[STEP_COPY[step].heading]}
499
+ </h2>
500
+ <p className="text-sm text-muted-foreground">
501
+ {resolved[STEP_COPY[step].description]}
502
+ </p>
503
+ </div>
504
+ {renderStep(step)}
505
+ </section>
506
+ ))}
507
+ </div>
508
+ )}
509
+
510
+ {submitErrorNode}
511
+
512
+ <div
513
+ className={cn(
514
+ 'mt-6 flex flex-wrap items-center gap-4',
515
+ presentation === 'stepper' ? 'justify-between' : 'justify-end',
516
+ )}
517
+ >
518
+ {presentation === 'stepper' ? (
519
+ <>
520
+ <Button
521
+ type="button"
522
+ variant="outline"
523
+ onClick={handleBack}
524
+ disabled={stepIndex === 0 || submitting}
525
+ >
526
+ {resolved.backLabel}
527
+ </Button>
528
+ {isLastStep ? (
529
+ submitAction
530
+ ) : (
531
+ <Button type="button" onClick={handleContinue} disabled={submitting}>
532
+ {resolved.continueLabel}
533
+ </Button>
534
+ )}
535
+ </>
536
+ ) : (
537
+ submitAction
538
+ )}
539
+ </div>
540
+ </CardContent>
541
+ </Card>
542
+ </div>
543
+
544
+ {presentation === 'stepper' ? summaryNode : null}
545
+ </div>
546
+ </div>
547
+ )
548
+ }
549
+
550
+ export default Checkout
@@ -0,0 +1,56 @@
1
+ import { Children, isValidElement, type ReactNode } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { StorefrontNavbarAccountMenu } from './storefront-navbar-account-menu'
5
+ import { DropdownMenuItem } from '../../../ui/dropdown-menu'
6
+
7
+ // Inspect the block-owned menu items without mounting Radix's browser portal.
8
+ function menuItems(node: ReactNode): Record<string, unknown>[] {
9
+ return Children.toArray(node).flatMap(child => {
10
+ if (!isValidElement<{ children?: ReactNode }>(child)) return []
11
+ return [
12
+ ...(child.type === DropdownMenuItem ? [child.props as Record<string, unknown>] : []),
13
+ ...menuItems(child.props.children),
14
+ ]
15
+ })
16
+ }
17
+
18
+ const user = { name: 'Ada', email: 'ada@example.test', initials: 'A' }
19
+
20
+ describe('StorefrontNavbarAccountMenu absent avatar', () => {
21
+ it('renders the fallback initials, never an empty image source, when avatar is absent', () => {
22
+ const html = renderToStaticMarkup(<StorefrontNavbarAccountMenu user={user} />)
23
+ expect(html).toContain('>A<')
24
+ expect(html).not.toContain('src=""')
25
+ expect(html).not.toContain('<img')
26
+ })
27
+
28
+ it('treats an empty avatar string as absent rather than an empty source', () => {
29
+ const html = renderToStaticMarkup(<StorefrontNavbarAccountMenu user={{ ...user, avatar: '' }} />)
30
+ expect(html).toContain('>A<')
31
+ expect(html).not.toContain('src=""')
32
+ })
33
+ })
34
+
35
+ describe('StorefrontNavbarAccountMenu consumer actions', () => {
36
+ it('omits help and sign-out when no consumer actions exist', () => {
37
+ expect(menuItems(StorefrontNavbarAccountMenu({ user }))).toEqual([])
38
+ })
39
+
40
+ it('dispatches sign-out to its consumer', () => {
41
+ const onSignOut = vi.fn()
42
+ const [item] = menuItems(StorefrontNavbarAccountMenu({ user, onSignOut }))
43
+ ;(item.onSelect as () => void)()
44
+ expect(onSignOut).toHaveBeenCalledTimes(1)
45
+ })
46
+
47
+ it('prevents duplicate sign-out while the consumer reports the logout pending', () => {
48
+ const onSignOut = vi.fn()
49
+ const [item] = menuItems(StorefrontNavbarAccountMenu({ user, pending: true, onSignOut }))
50
+ expect(item.disabled).toBe(true)
51
+ const select = item.onSelect as () => void
52
+ select()
53
+ select()
54
+ expect(onSignOut).not.toHaveBeenCalled()
55
+ })
56
+ })
@@ -26,7 +26,7 @@ export interface AccountMenuItem {
26
26
  icon?: 'orders' | 'wishlist' | 'addresses' | 'settings'
27
27
  }
28
28
 
29
- export interface Navbar4AccountMenuProps {
29
+ export interface StorefrontNavbarAccountMenuProps {
30
30
  /** Signed-in user. When omitted, the menu is not rendered. */
31
31
  user?: StoreUser
32
32
  /** Account links shown below the user header. Defaults to `[]`. */
@@ -43,13 +43,13 @@ const accountIcons = {
43
43
  settings: Settings,
44
44
  } as const
45
45
 
46
- export function Navbar4AccountMenu({
46
+ export function StorefrontNavbarAccountMenu({
47
47
  user,
48
48
  items = [],
49
49
  onHelp,
50
50
  onSignOut,
51
51
  pending = false,
52
- }: Navbar4AccountMenuProps) {
52
+ }: StorefrontNavbarAccountMenuProps) {
53
53
  if (!user) {
54
54
  return null
55
55
  }