@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,149 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Field, FieldContent, FieldGroup, FieldLabel } from '../../../ui/field'
|
|
4
|
+
import { Input } from '../../../ui/input'
|
|
5
|
+
import { CheckboxCheck } from './checkbox-check'
|
|
6
|
+
import { CheckoutFieldFeedback, checkoutFieldAria } from './checkout-field-feedback'
|
|
7
|
+
import type {
|
|
8
|
+
CheckoutChangeHandler,
|
|
9
|
+
CheckoutFieldDescriptions,
|
|
10
|
+
CheckoutValuesFieldErrors,
|
|
11
|
+
CheckoutLabels,
|
|
12
|
+
CheckoutValues,
|
|
13
|
+
} from './checkout'
|
|
14
|
+
|
|
15
|
+
export interface CheckoutContactStepProps {
|
|
16
|
+
values: CheckoutValues
|
|
17
|
+
onChange: CheckoutChangeHandler
|
|
18
|
+
labels: CheckoutLabels
|
|
19
|
+
/** Rendered only when the host supplies the corresponding label. */
|
|
20
|
+
showSaveInfo: boolean
|
|
21
|
+
showNewsletter: boolean
|
|
22
|
+
fieldErrors?: CheckoutValuesFieldErrors
|
|
23
|
+
fieldDescriptions?: CheckoutFieldDescriptions
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function CheckoutContactStep({
|
|
27
|
+
values,
|
|
28
|
+
onChange,
|
|
29
|
+
labels,
|
|
30
|
+
showSaveInfo,
|
|
31
|
+
showNewsletter,
|
|
32
|
+
fieldErrors,
|
|
33
|
+
fieldDescriptions,
|
|
34
|
+
}: CheckoutContactStepProps) {
|
|
35
|
+
return (
|
|
36
|
+
<FieldGroup className="grid grid-cols-1 gap-4">
|
|
37
|
+
<Field data-invalid={!!fieldErrors?.email?.length}>
|
|
38
|
+
<FieldLabel htmlFor="checkout-email">{labels.emailLabel}</FieldLabel>
|
|
39
|
+
<Input
|
|
40
|
+
id="checkout-email"
|
|
41
|
+
type="email"
|
|
42
|
+
inputMode="email"
|
|
43
|
+
autoComplete="email"
|
|
44
|
+
value={values.email}
|
|
45
|
+
onChange={(e) => onChange('email', e.target.value)}
|
|
46
|
+
{...checkoutFieldAria('checkout-email', fieldErrors?.email)}
|
|
47
|
+
/>
|
|
48
|
+
<CheckoutFieldFeedback
|
|
49
|
+
id="checkout-email"
|
|
50
|
+
errors={fieldErrors?.email}
|
|
51
|
+
description={fieldDescriptions?.email}
|
|
52
|
+
/>
|
|
53
|
+
</Field>
|
|
54
|
+
|
|
55
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
56
|
+
<Field data-invalid={!!fieldErrors?.firstName?.length}>
|
|
57
|
+
<FieldLabel htmlFor="checkout-first-name">{labels.firstNameLabel}</FieldLabel>
|
|
58
|
+
<Input
|
|
59
|
+
id="checkout-first-name"
|
|
60
|
+
autoComplete="given-name"
|
|
61
|
+
value={values.firstName}
|
|
62
|
+
onChange={(e) => onChange('firstName', e.target.value)}
|
|
63
|
+
{...checkoutFieldAria('checkout-first-name', fieldErrors?.firstName)}
|
|
64
|
+
/>
|
|
65
|
+
<CheckoutFieldFeedback
|
|
66
|
+
id="checkout-first-name"
|
|
67
|
+
errors={fieldErrors?.firstName}
|
|
68
|
+
description={fieldDescriptions?.firstName}
|
|
69
|
+
/>
|
|
70
|
+
</Field>
|
|
71
|
+
<Field data-invalid={!!fieldErrors?.lastName?.length}>
|
|
72
|
+
<FieldLabel htmlFor="checkout-last-name">{labels.lastNameLabel}</FieldLabel>
|
|
73
|
+
<Input
|
|
74
|
+
id="checkout-last-name"
|
|
75
|
+
autoComplete="family-name"
|
|
76
|
+
value={values.lastName}
|
|
77
|
+
onChange={(e) => onChange('lastName', e.target.value)}
|
|
78
|
+
{...checkoutFieldAria('checkout-last-name', fieldErrors?.lastName)}
|
|
79
|
+
/>
|
|
80
|
+
<CheckoutFieldFeedback
|
|
81
|
+
id="checkout-last-name"
|
|
82
|
+
errors={fieldErrors?.lastName}
|
|
83
|
+
description={fieldDescriptions?.lastName}
|
|
84
|
+
/>
|
|
85
|
+
</Field>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<Field data-invalid={!!fieldErrors?.phone?.length}>
|
|
89
|
+
<FieldLabel htmlFor="checkout-phone">{labels.phoneLabel}</FieldLabel>
|
|
90
|
+
<Input
|
|
91
|
+
id="checkout-phone"
|
|
92
|
+
type="tel"
|
|
93
|
+
inputMode="tel"
|
|
94
|
+
autoComplete="tel"
|
|
95
|
+
value={values.phone}
|
|
96
|
+
onChange={(e) => onChange('phone', e.target.value)}
|
|
97
|
+
{...checkoutFieldAria('checkout-phone', fieldErrors?.phone)}
|
|
98
|
+
/>
|
|
99
|
+
<CheckoutFieldFeedback
|
|
100
|
+
id="checkout-phone"
|
|
101
|
+
errors={fieldErrors?.phone}
|
|
102
|
+
description={fieldDescriptions?.phone}
|
|
103
|
+
/>
|
|
104
|
+
</Field>
|
|
105
|
+
|
|
106
|
+
{showSaveInfo ? (
|
|
107
|
+
<Field orientation="horizontal" className="gap-2" data-invalid={!!fieldErrors?.saveInfo?.length}>
|
|
108
|
+
<CheckboxCheck
|
|
109
|
+
id="checkout-save-info"
|
|
110
|
+
checked={values.saveInfo}
|
|
111
|
+
onCheckedChange={(checked) => onChange('saveInfo', checked === true)}
|
|
112
|
+
{...checkoutFieldAria('checkout-save-info', fieldErrors?.saveInfo)}
|
|
113
|
+
/>
|
|
114
|
+
<FieldContent>
|
|
115
|
+
<FieldLabel htmlFor="checkout-save-info" className="text-sm font-normal">
|
|
116
|
+
{labels.saveInfoLabel}
|
|
117
|
+
</FieldLabel>
|
|
118
|
+
<CheckoutFieldFeedback
|
|
119
|
+
id="checkout-save-info"
|
|
120
|
+
errors={fieldErrors?.saveInfo}
|
|
121
|
+
description={fieldDescriptions?.saveInfo}
|
|
122
|
+
/>
|
|
123
|
+
</FieldContent>
|
|
124
|
+
</Field>
|
|
125
|
+
) : null}
|
|
126
|
+
|
|
127
|
+
{showNewsletter ? (
|
|
128
|
+
<Field orientation="horizontal" className="gap-2" data-invalid={!!fieldErrors?.newsletter?.length}>
|
|
129
|
+
<CheckboxCheck
|
|
130
|
+
id="checkout-newsletter"
|
|
131
|
+
checked={values.newsletter}
|
|
132
|
+
onCheckedChange={(checked) => onChange('newsletter', checked === true)}
|
|
133
|
+
{...checkoutFieldAria('checkout-newsletter', fieldErrors?.newsletter)}
|
|
134
|
+
/>
|
|
135
|
+
<FieldContent>
|
|
136
|
+
<FieldLabel htmlFor="checkout-newsletter" className="text-sm font-normal">
|
|
137
|
+
{labels.newsletterLabel}
|
|
138
|
+
</FieldLabel>
|
|
139
|
+
<CheckoutFieldFeedback
|
|
140
|
+
id="checkout-newsletter"
|
|
141
|
+
errors={fieldErrors?.newsletter}
|
|
142
|
+
description={fieldDescriptions?.newsletter}
|
|
143
|
+
/>
|
|
144
|
+
</FieldContent>
|
|
145
|
+
</Field>
|
|
146
|
+
) : null}
|
|
147
|
+
</FieldGroup>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -11,29 +11,31 @@ import {
|
|
|
11
11
|
} from '../../../ui/select'
|
|
12
12
|
import { CheckoutFieldFeedback, checkoutFieldAria } from './checkout-field-feedback'
|
|
13
13
|
import type { CheckoutFieldError } from './checkout-form-types'
|
|
14
|
-
import type {
|
|
14
|
+
import type { CheckoutCountryOption, CheckoutLabels } from './checkout'
|
|
15
15
|
|
|
16
|
-
export interface
|
|
17
|
-
|
|
16
|
+
export interface CheckoutCountryFieldProps {
|
|
17
|
+
labels: CheckoutLabels
|
|
18
|
+
countries: CheckoutCountryOption[]
|
|
18
19
|
value: string
|
|
19
20
|
onValueChange: (value: string) => void
|
|
20
21
|
errors?: CheckoutFieldError[]
|
|
21
22
|
description?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export function
|
|
25
|
+
export function CheckoutCountryField({
|
|
26
|
+
labels,
|
|
25
27
|
countries,
|
|
26
28
|
value,
|
|
27
29
|
onValueChange,
|
|
28
30
|
errors,
|
|
29
31
|
description,
|
|
30
|
-
}:
|
|
32
|
+
}: CheckoutCountryFieldProps) {
|
|
31
33
|
return (
|
|
32
34
|
<Field data-invalid={!!errors?.length}>
|
|
33
|
-
<FieldLabel htmlFor="checkout-country">
|
|
35
|
+
<FieldLabel htmlFor="checkout-country">{labels.countryLabel}</FieldLabel>
|
|
34
36
|
<Select value={value} onValueChange={onValueChange}>
|
|
35
37
|
<SelectTrigger id="checkout-country" {...checkoutFieldAria('checkout-country', errors)}>
|
|
36
|
-
<SelectValue placeholder=
|
|
38
|
+
<SelectValue placeholder={labels.countryPlaceholder} />
|
|
37
39
|
</SelectTrigger>
|
|
38
40
|
<SelectContent>
|
|
39
41
|
<SelectGroup>
|
|
@@ -48,4 +50,4 @@ export function CheckoutForm1CountryField({
|
|
|
48
50
|
<CheckoutFieldFeedback id="checkout-country" errors={errors} description={description} />
|
|
49
51
|
</Field>
|
|
50
52
|
)
|
|
51
|
-
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Shield, Truck } from 'lucide-react'
|
|
4
|
+
import { Badge } from '../../../ui/badge'
|
|
5
|
+
import { Button } from '../../../ui/button'
|
|
6
|
+
import { Card, CardContent, CardTitle } from '../../../ui/card'
|
|
7
|
+
import { Field, FieldLabel } from '../../../ui/field'
|
|
8
|
+
import { Input } from '../../../ui/input'
|
|
9
|
+
import { Separator } from '../../../ui/separator'
|
|
10
|
+
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
11
|
+
import type { Money } from '@thorprovider/types'
|
|
12
|
+
import type {
|
|
13
|
+
CheckoutLabels,
|
|
14
|
+
CheckoutOrderSummary,
|
|
15
|
+
CheckoutOrderLine,
|
|
16
|
+
CheckoutTotalsState,
|
|
17
|
+
} from './checkout'
|
|
18
|
+
|
|
19
|
+
export interface CheckoutOrderSummaryPanelProps {
|
|
20
|
+
summary: CheckoutOrderSummary
|
|
21
|
+
labels: CheckoutLabels
|
|
22
|
+
formatMoney?: (money: Money) => string
|
|
23
|
+
totalsState?: CheckoutTotalsState
|
|
24
|
+
onRetryTotals?: () => void
|
|
25
|
+
promoCode: string
|
|
26
|
+
onPromoCodeChange: (value: string) => void
|
|
27
|
+
onApplyPromo?: (code: string) => void
|
|
28
|
+
promoPending?: boolean
|
|
29
|
+
trustNotes?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Internal projection of the checkout's own cart. It is not a public block: the
|
|
34
|
+
* amount contract is `Money` only and every total is rendered verbatim.
|
|
35
|
+
*/
|
|
36
|
+
export function CheckoutOrderSummaryPanel({
|
|
37
|
+
summary,
|
|
38
|
+
labels,
|
|
39
|
+
formatMoney: formatMoneyProp,
|
|
40
|
+
totalsState = 'idle',
|
|
41
|
+
onRetryTotals,
|
|
42
|
+
promoCode,
|
|
43
|
+
onPromoCodeChange,
|
|
44
|
+
onApplyPromo,
|
|
45
|
+
promoPending = false,
|
|
46
|
+
trustNotes,
|
|
47
|
+
}: CheckoutOrderSummaryPanelProps) {
|
|
48
|
+
const format = (value: Money | null | undefined) => formatMoney(value, formatMoneyProp)
|
|
49
|
+
const lineAmount = (item: CheckoutOrderLine): string | null =>
|
|
50
|
+
isAvailableMoney(item.lineTotal) ? format(item.lineTotal) : null
|
|
51
|
+
|
|
52
|
+
const renderTotals = () => {
|
|
53
|
+
if (totalsState === 'pending') {
|
|
54
|
+
return (
|
|
55
|
+
<p className="text-muted-foreground text-sm" aria-live="polite">
|
|
56
|
+
{labels.totalsPendingLabel}
|
|
57
|
+
</p>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
if (totalsState === 'error') {
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex flex-col gap-2" role="alert">
|
|
63
|
+
<p className="text-destructive text-sm">{labels.totalsErrorLabel}</p>
|
|
64
|
+
{onRetryTotals ? (
|
|
65
|
+
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetryTotals}>
|
|
66
|
+
{labels.retryLabel}
|
|
67
|
+
</Button>
|
|
68
|
+
) : null}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const totals = summary.totals
|
|
74
|
+
if (!totals) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex flex-col gap-2" role="status">
|
|
77
|
+
<p className="text-muted-foreground text-sm">{labels.totalsUnavailableLabel}</p>
|
|
78
|
+
{onRetryTotals ? (
|
|
79
|
+
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetryTotals}>
|
|
80
|
+
{labels.retryLabel}
|
|
81
|
+
</Button>
|
|
82
|
+
) : null}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const subtotalText = format(totals.subtotal)
|
|
88
|
+
const totalText = format(totals.total)
|
|
89
|
+
// Required totals: an absent/malformed amount is unavailable, never a
|
|
90
|
+
// fabricated zero.
|
|
91
|
+
if (subtotalText === null || totalText === null) {
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex flex-col gap-2" role="status">
|
|
94
|
+
<p className="text-muted-foreground text-sm">{labels.totalsUnavailableLabel}</p>
|
|
95
|
+
{onRetryTotals ? (
|
|
96
|
+
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={onRetryTotals}>
|
|
97
|
+
{labels.retryLabel}
|
|
98
|
+
</Button>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex flex-col gap-2 text-sm">
|
|
106
|
+
<div className="flex items-center justify-between">
|
|
107
|
+
<span className="text-muted-foreground">{labels.subtotalLabel}</span>
|
|
108
|
+
<span className="font-medium tabular-nums">{subtotalText}</span>
|
|
109
|
+
</div>
|
|
110
|
+
{isAvailableMoney(totals.discount) ? (
|
|
111
|
+
<div className="flex items-center justify-between">
|
|
112
|
+
<span className="text-muted-foreground">{labels.discountLabel}</span>
|
|
113
|
+
<span className="font-medium tabular-nums">{format(totals.discount)}</span>
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
{isAvailableMoney(totals.tax) ? (
|
|
117
|
+
<div className="flex items-center justify-between">
|
|
118
|
+
<span className="text-muted-foreground">{labels.taxLabel}</span>
|
|
119
|
+
<span className="font-medium tabular-nums">{format(totals.tax)}</span>
|
|
120
|
+
</div>
|
|
121
|
+
) : null}
|
|
122
|
+
{isAvailableMoney(totals.shipping) ? (
|
|
123
|
+
<div className="flex items-center justify-between">
|
|
124
|
+
<span className="flex items-center gap-1.5 text-muted-foreground">
|
|
125
|
+
<Truck aria-hidden="true" className="size-4" />
|
|
126
|
+
{labels.shippingLabel}
|
|
127
|
+
</span>
|
|
128
|
+
<span className="font-medium tabular-nums">{format(totals.shipping)}</span>
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
131
|
+
<Separator />
|
|
132
|
+
<div className="flex items-center justify-between">
|
|
133
|
+
<span className="font-semibold">{labels.totalLabel}</span>
|
|
134
|
+
<span className="font-semibold tabular-nums">{totalText}</span>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<Card className="lg:sticky lg:top-8">
|
|
142
|
+
<CardContent className="flex flex-col gap-4 p-6">
|
|
143
|
+
<CardTitle>{labels.orderSummaryTitle}</CardTitle>
|
|
144
|
+
|
|
145
|
+
<div className="flex flex-col gap-4">
|
|
146
|
+
{summary.items.map((item) => {
|
|
147
|
+
const amount = lineAmount(item)
|
|
148
|
+
return (
|
|
149
|
+
<div key={String(item.id)} className="flex items-center gap-3">
|
|
150
|
+
<div className="relative">
|
|
151
|
+
{item.image ? (
|
|
152
|
+
<img
|
|
153
|
+
src={item.image}
|
|
154
|
+
alt={item.name}
|
|
155
|
+
width={64}
|
|
156
|
+
height={64}
|
|
157
|
+
className="bg-muted size-16 rounded-lg object-cover"
|
|
158
|
+
/>
|
|
159
|
+
) : (
|
|
160
|
+
<div aria-hidden="true" className="bg-muted size-16 rounded-lg" />
|
|
161
|
+
)}
|
|
162
|
+
<Badge
|
|
163
|
+
variant="secondary"
|
|
164
|
+
className="absolute -right-2 -top-2 size-6 justify-center rounded-full p-0 text-xs"
|
|
165
|
+
>
|
|
166
|
+
{item.quantity}
|
|
167
|
+
</Badge>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="min-w-0 flex-1">
|
|
170
|
+
<p className="text-sm font-medium">{item.name}</p>
|
|
171
|
+
{item.variant ? (
|
|
172
|
+
<p className="text-sm text-muted-foreground">{item.variant}</p>
|
|
173
|
+
) : null}
|
|
174
|
+
{item.quantity > 1 && isAvailableMoney(item.unitPrice) ? (
|
|
175
|
+
<p className="text-muted-foreground text-xs">
|
|
176
|
+
{format(item.unitPrice)} {labels.eachLabel}
|
|
177
|
+
</p>
|
|
178
|
+
) : null}
|
|
179
|
+
</div>
|
|
180
|
+
<p className="text-sm font-medium tabular-nums">
|
|
181
|
+
{amount ?? labels.amountUnavailableLabel}
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{onApplyPromo ? (
|
|
189
|
+
<>
|
|
190
|
+
<Separator />
|
|
191
|
+
<Field className="gap-2">
|
|
192
|
+
<FieldLabel htmlFor="checkout-promo-code">{labels.promoCodeLabel}</FieldLabel>
|
|
193
|
+
<div className="flex gap-2">
|
|
194
|
+
<Input
|
|
195
|
+
id="checkout-promo-code"
|
|
196
|
+
placeholder={labels.promoPlaceholder}
|
|
197
|
+
value={promoCode}
|
|
198
|
+
onChange={(e) => onPromoCodeChange(e.target.value)}
|
|
199
|
+
/>
|
|
200
|
+
<Button
|
|
201
|
+
type="button"
|
|
202
|
+
variant="outline"
|
|
203
|
+
disabled={promoPending}
|
|
204
|
+
onClick={() => onApplyPromo(promoCode)}
|
|
205
|
+
>
|
|
206
|
+
{labels.promoApplyLabel}
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
</Field>
|
|
210
|
+
</>
|
|
211
|
+
) : null}
|
|
212
|
+
|
|
213
|
+
<Separator />
|
|
214
|
+
|
|
215
|
+
{renderTotals()}
|
|
216
|
+
|
|
217
|
+
{trustNotes && trustNotes.length > 0 ? (
|
|
218
|
+
<div className="text-muted-foreground flex flex-col gap-2 text-sm">
|
|
219
|
+
{trustNotes.map((note, index) => (
|
|
220
|
+
<span key={`${index}:${note}`} className="flex items-center gap-2">
|
|
221
|
+
<Shield aria-hidden="true" className="size-4" />
|
|
222
|
+
{note}
|
|
223
|
+
</span>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
) : null}
|
|
227
|
+
</CardContent>
|
|
228
|
+
</Card>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Field, FieldContent, FieldLabel, FieldLegend, FieldSet } from '../../../ui/field'
|
|
4
|
+
import { RadioGroup, RadioGroupItem } from '../../../ui/radio-group'
|
|
5
|
+
import { CheckoutFieldFeedback, checkoutFieldAria } from './checkout-field-feedback'
|
|
6
|
+
import type { PaymentMethod } from '@thorprovider/types'
|
|
7
|
+
import type {
|
|
8
|
+
CheckoutChangeHandler,
|
|
9
|
+
CheckoutFieldDescriptions,
|
|
10
|
+
CheckoutValuesFieldErrors,
|
|
11
|
+
CheckoutLabels,
|
|
12
|
+
CheckoutValues,
|
|
13
|
+
} from './checkout'
|
|
14
|
+
|
|
15
|
+
export interface CheckoutPaymentStepProps {
|
|
16
|
+
values: CheckoutValues
|
|
17
|
+
onChange: CheckoutChangeHandler
|
|
18
|
+
labels: CheckoutLabels
|
|
19
|
+
paymentMethods?: PaymentMethod[]
|
|
20
|
+
fieldErrors?: CheckoutValuesFieldErrors
|
|
21
|
+
fieldDescriptions?: CheckoutFieldDescriptions
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CheckoutPaymentStep({
|
|
25
|
+
values,
|
|
26
|
+
onChange,
|
|
27
|
+
labels,
|
|
28
|
+
paymentMethods = [],
|
|
29
|
+
fieldErrors,
|
|
30
|
+
fieldDescriptions,
|
|
31
|
+
}: CheckoutPaymentStepProps) {
|
|
32
|
+
const selectedMethod = paymentMethods.find((method) => method.id === values.paymentMethod)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<FieldSet>
|
|
36
|
+
<FieldLegend>{labels.paymentMethodLegend}</FieldLegend>
|
|
37
|
+
{paymentMethods.length === 0 ? (
|
|
38
|
+
<p className="text-muted-foreground text-sm">{labels.noPaymentMethodsLabel}</p>
|
|
39
|
+
) : (
|
|
40
|
+
<RadioGroup
|
|
41
|
+
value={values.paymentMethod}
|
|
42
|
+
onValueChange={(value) => value && onChange('paymentMethod', String(value))}
|
|
43
|
+
className="grid grid-cols-1 gap-3"
|
|
44
|
+
>
|
|
45
|
+
{paymentMethods.map((method) => {
|
|
46
|
+
const unavailable = method.isEnabled === false
|
|
47
|
+
return (
|
|
48
|
+
<Field
|
|
49
|
+
key={method.id}
|
|
50
|
+
orientation="horizontal"
|
|
51
|
+
className="flex-wrap items-start justify-between gap-3 rounded-lg border p-4"
|
|
52
|
+
>
|
|
53
|
+
<FieldContent className="min-w-0">
|
|
54
|
+
<FieldLabel htmlFor={`payment-${method.id}`} className="flex flex-col gap-0.5">
|
|
55
|
+
<span>{method.name}</span>
|
|
56
|
+
{method.description ? (
|
|
57
|
+
<span className="text-muted-foreground text-xs font-normal">
|
|
58
|
+
{method.description}
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
{unavailable ? (
|
|
62
|
+
<span className="text-muted-foreground text-xs font-normal">
|
|
63
|
+
{labels.unavailableMethodLabel}
|
|
64
|
+
</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</FieldLabel>
|
|
67
|
+
</FieldContent>
|
|
68
|
+
<RadioGroupItem
|
|
69
|
+
value={method.id}
|
|
70
|
+
id={`payment-${method.id}`}
|
|
71
|
+
disabled={unavailable}
|
|
72
|
+
/>
|
|
73
|
+
</Field>
|
|
74
|
+
)
|
|
75
|
+
})}
|
|
76
|
+
</RadioGroup>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{selectedMethod ? (
|
|
80
|
+
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
|
81
|
+
<p className="font-medium">{selectedMethod.name}</p>
|
|
82
|
+
{selectedMethod.description ? (
|
|
83
|
+
<p className="text-muted-foreground mt-1">{selectedMethod.description}</p>
|
|
84
|
+
) : null}
|
|
85
|
+
{selectedMethod.requiresSetup ? (
|
|
86
|
+
<p className="text-muted-foreground mt-1 text-xs">{labels.requiresSetupLabel}</p>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
<Field data-invalid={!!fieldErrors?.paymentMethod?.length}>
|
|
92
|
+
<CheckoutFieldFeedback
|
|
93
|
+
id="checkout-payment-method"
|
|
94
|
+
errors={fieldErrors?.paymentMethod}
|
|
95
|
+
description={fieldDescriptions?.paymentMethod}
|
|
96
|
+
/>
|
|
97
|
+
</Field>
|
|
98
|
+
</FieldSet>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Check } from 'lucide-react'
|
|
4
|
+
import { cn } from '../../../lib/utils'
|
|
5
|
+
import type { CheckoutStepId } from './checkout'
|
|
6
|
+
|
|
7
|
+
export interface CheckoutProgressProps {
|
|
8
|
+
steps: Array<{ id: CheckoutStepId; label: string }>
|
|
9
|
+
currentStep: CheckoutStepId
|
|
10
|
+
ariaLabel: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CheckoutProgress({ steps, currentStep, ariaLabel }: CheckoutProgressProps) {
|
|
14
|
+
const currentIndex = steps.findIndex((step) => step.id === currentStep)
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<nav aria-label={ariaLabel}>
|
|
18
|
+
<ol className="flex flex-wrap items-center justify-center gap-2">
|
|
19
|
+
{steps.map((step, index) => {
|
|
20
|
+
const isCurrent = step.id === currentStep
|
|
21
|
+
const isComplete = index < currentIndex
|
|
22
|
+
return (
|
|
23
|
+
<li
|
|
24
|
+
key={step.id}
|
|
25
|
+
aria-current={isCurrent ? 'step' : undefined}
|
|
26
|
+
className="flex items-center gap-2"
|
|
27
|
+
>
|
|
28
|
+
{index > 0 ? (
|
|
29
|
+
<div
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
className={cn('h-1 w-8 sm:w-16', isComplete || isCurrent ? 'bg-primary' : 'bg-muted')}
|
|
32
|
+
/>
|
|
33
|
+
) : null}
|
|
34
|
+
<span
|
|
35
|
+
className={cn(
|
|
36
|
+
'flex size-8 items-center justify-center rounded-full text-sm font-medium',
|
|
37
|
+
isComplete || isCurrent
|
|
38
|
+
? 'bg-primary text-primary-foreground'
|
|
39
|
+
: 'bg-muted text-muted-foreground',
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{isComplete ? (
|
|
43
|
+
<Check aria-hidden="true" className="size-4" />
|
|
44
|
+
) : (
|
|
45
|
+
<span aria-hidden="true">{index + 1}</span>
|
|
46
|
+
)}
|
|
47
|
+
</span>
|
|
48
|
+
<span
|
|
49
|
+
className={cn(
|
|
50
|
+
'text-sm',
|
|
51
|
+
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{step.label}
|
|
55
|
+
</span>
|
|
56
|
+
</li>
|
|
57
|
+
)
|
|
58
|
+
})}
|
|
59
|
+
</ol>
|
|
60
|
+
</nav>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { PaymentMethod } from '@thorprovider/types'
|
|
4
|
+
import type { CheckoutLabels, CheckoutShippingOption, CheckoutValues } from './checkout'
|
|
5
|
+
|
|
6
|
+
export interface CheckoutReviewStepProps {
|
|
7
|
+
values: CheckoutValues
|
|
8
|
+
labels: CheckoutLabels
|
|
9
|
+
selectedShippingOptionId?: string
|
|
10
|
+
shippingOptions?: CheckoutShippingOption[]
|
|
11
|
+
paymentMethods?: PaymentMethod[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function CheckoutReviewStep({
|
|
15
|
+
values,
|
|
16
|
+
labels,
|
|
17
|
+
selectedShippingOptionId,
|
|
18
|
+
shippingOptions,
|
|
19
|
+
paymentMethods,
|
|
20
|
+
}: CheckoutReviewStepProps) {
|
|
21
|
+
const shippingName = shippingOptions?.find(
|
|
22
|
+
(method) => method.id === selectedShippingOptionId,
|
|
23
|
+
)?.name
|
|
24
|
+
const paymentName = paymentMethods?.find((method) => method.id === values.paymentMethod)?.name
|
|
25
|
+
|
|
26
|
+
const shipTo = [
|
|
27
|
+
[values.firstName, values.lastName].filter(Boolean).join(' '),
|
|
28
|
+
values.address,
|
|
29
|
+
[values.city, values.state, values.zipCode].filter(Boolean).join(' '),
|
|
30
|
+
values.country,
|
|
31
|
+
]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(', ')
|
|
34
|
+
|
|
35
|
+
const rows = [
|
|
36
|
+
{ label: labels.reviewContactLabel, value: values.email },
|
|
37
|
+
{ label: labels.reviewShipToLabel, value: shipTo },
|
|
38
|
+
{ label: labels.reviewPaymentLabel, value: paymentName ?? '' },
|
|
39
|
+
{ label: labels.reviewShippingLabel, value: shippingName ?? '' },
|
|
40
|
+
].filter((row) => row.value.trim() !== '')
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="bg-muted/50 rounded-lg p-6">
|
|
44
|
+
<h3 className="mb-4 font-medium">{labels.reviewHeading}</h3>
|
|
45
|
+
{rows.length === 0 ? (
|
|
46
|
+
<p className="text-muted-foreground text-sm">{labels.reviewEmptyLabel}</p>
|
|
47
|
+
) : (
|
|
48
|
+
<dl className="flex flex-col gap-4 text-sm">
|
|
49
|
+
{rows.map((row) => (
|
|
50
|
+
<div key={row.label}>
|
|
51
|
+
<dt className="inline font-medium">{row.label}: </dt>
|
|
52
|
+
<dd className="inline">{row.value}</dd>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</dl>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|