@thorprovider/blocks 0.1.3 → 2.0.0

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