@thorprovider/blocks 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +156 -40
- package/package.json +1 -1
- package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
- package/src/blocks/application/login-forms/login-page.tsx +240 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
- package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
- package/src/blocks/application/verification/verify-1.tsx +2 -2
- package/src/blocks/application/viewport-ownership.test.tsx +41 -0
- package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
- package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
- package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
- package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
- package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
- package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
- package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
- package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
- package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
- package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
- package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
- package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
- package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
- package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
- package/src/blocks/marketing/footers/footer-section.tsx +532 -0
- package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
- package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
- package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
- package/src/blocks/marketing/navbars/navbar.tsx +735 -0
- package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
- package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
- package/src/index.ts +80 -81
- package/src/lib/contracts-curated.test.ts +5 -5
- package/src/lib/contracts.test.ts +55 -0
- package/src/lib/contracts.ts +64 -2
- package/tsup.config.ts +1 -1
- package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
- package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
- package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
- package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
- package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
- package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
- package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
- package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Children, isValidElement, type ReactNode } from 'react'
|
|
2
2
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import {
|
|
4
|
+
import { StorefrontNavbarAccountMenu } from './storefront-navbar-account-menu'
|
|
5
5
|
import { DropdownMenuItem } from '../../../ui/dropdown-menu'
|
|
6
6
|
|
|
7
7
|
// Inspect the block-owned menu items without mounting Radix's browser portal.
|
|
@@ -17,36 +17,36 @@ function menuItems(node: ReactNode): Record<string, unknown>[] {
|
|
|
17
17
|
|
|
18
18
|
const user = { name: 'Ada', email: 'ada@example.test', initials: 'A' }
|
|
19
19
|
|
|
20
|
-
describe('
|
|
20
|
+
describe('StorefrontNavbarAccountMenu absent avatar', () => {
|
|
21
21
|
it('renders the fallback initials, never an empty image source, when avatar is absent', () => {
|
|
22
|
-
const html = renderToStaticMarkup(<
|
|
22
|
+
const html = renderToStaticMarkup(<StorefrontNavbarAccountMenu user={user} />)
|
|
23
23
|
expect(html).toContain('>A<')
|
|
24
24
|
expect(html).not.toContain('src=""')
|
|
25
25
|
expect(html).not.toContain('<img')
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
it('treats an empty avatar string as absent rather than an empty source', () => {
|
|
29
|
-
const html = renderToStaticMarkup(<
|
|
29
|
+
const html = renderToStaticMarkup(<StorefrontNavbarAccountMenu user={{ ...user, avatar: '' }} />)
|
|
30
30
|
expect(html).toContain('>A<')
|
|
31
31
|
expect(html).not.toContain('src=""')
|
|
32
32
|
})
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
describe('
|
|
35
|
+
describe('StorefrontNavbarAccountMenu consumer actions', () => {
|
|
36
36
|
it('omits help and sign-out when no consumer actions exist', () => {
|
|
37
|
-
expect(menuItems(
|
|
37
|
+
expect(menuItems(StorefrontNavbarAccountMenu({ user }))).toEqual([])
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
it('dispatches sign-out to its consumer', () => {
|
|
41
41
|
const onSignOut = vi.fn()
|
|
42
|
-
const [item] = menuItems(
|
|
42
|
+
const [item] = menuItems(StorefrontNavbarAccountMenu({ user, onSignOut }))
|
|
43
43
|
;(item.onSelect as () => void)()
|
|
44
44
|
expect(onSignOut).toHaveBeenCalledTimes(1)
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
it('prevents duplicate sign-out while the consumer reports the logout pending', () => {
|
|
48
48
|
const onSignOut = vi.fn()
|
|
49
|
-
const [item] = menuItems(
|
|
49
|
+
const [item] = menuItems(StorefrontNavbarAccountMenu({ user, pending: true, onSignOut }))
|
|
50
50
|
expect(item.disabled).toBe(true)
|
|
51
51
|
const select = item.onSelect as () => void
|
|
52
52
|
select()
|
|
@@ -26,7 +26,7 @@ export interface AccountMenuItem {
|
|
|
26
26
|
icon?: 'orders' | 'wishlist' | 'addresses' | 'settings'
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export interface
|
|
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
|
|
46
|
+
export function StorefrontNavbarAccountMenu({
|
|
47
47
|
user,
|
|
48
48
|
items = [],
|
|
49
49
|
onHelp,
|
|
50
50
|
onSignOut,
|
|
51
51
|
pending = false,
|
|
52
|
-
}:
|
|
52
|
+
}: StorefrontNavbarAccountMenuProps) {
|
|
53
53
|
if (!user) {
|
|
54
54
|
return null
|
|
55
55
|
}
|