@thorprovider/blocks 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +156 -40
  3. package/package.json +1 -1
  4. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  5. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  6. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  7. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  8. package/src/blocks/application/verification/verify-1.tsx +2 -2
  9. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  10. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  11. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  12. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  13. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  14. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  15. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  16. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  17. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  19. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  20. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  21. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  22. package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
  23. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  24. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  25. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  27. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
  29. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
  30. package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
  32. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  33. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  34. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  35. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  36. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  37. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  38. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  39. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  40. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  41. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  42. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  43. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  44. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  45. package/src/index.ts +80 -81
  46. package/src/lib/contracts-curated.test.ts +5 -5
  47. package/src/lib/contracts.test.ts +55 -0
  48. package/src/lib/contracts.ts +64 -2
  49. package/tsup.config.ts +1 -1
  50. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  51. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  52. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  53. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  54. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  55. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  56. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  57. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  58. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  59. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  60. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  61. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  62. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  63. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  67. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  68. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  69. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  70. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  71. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  72. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  73. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  74. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  75. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  76. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  77. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  78. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  79. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  80. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  81. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  82. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  83. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  84. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  85. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  86. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  87. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  88. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  89. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  90. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  91. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -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 { CheckoutForm1CountryOption } from './checkout-form-1'
14
+ import type { CheckoutCountryOption, CheckoutLabels } from './checkout'
15
15
 
16
- export interface CheckoutForm1CountryFieldProps {
17
- countries: CheckoutForm1CountryOption[]
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 CheckoutForm1CountryField({
25
+ export function CheckoutCountryField({
26
+ labels,
25
27
  countries,
26
28
  value,
27
29
  onValueChange,
28
30
  errors,
29
31
  description,
30
- }: CheckoutForm1CountryFieldProps) {
32
+ }: CheckoutCountryFieldProps) {
31
33
  return (
32
34
  <Field data-invalid={!!errors?.length}>
33
- <FieldLabel htmlFor="checkout-country">Country</FieldLabel>
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="Select a country" />
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
+ }