@thorprovider/blocks 0.1.1 → 0.1.3

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 (70) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +42 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +94 -1
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1266 -97
  7. package/dist/contracts.d.ts +1266 -97
  8. package/dist/contracts.js +225 -9
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +207 -10
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +756 -96
  13. package/dist/index.d.ts +756 -96
  14. package/dist/index.js +5114 -3702
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +4939 -3551
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +2 -2
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  32. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  33. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  34. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  35. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  36. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  37. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  38. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  39. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  40. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  41. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  42. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  43. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  44. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  45. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  46. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  47. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  48. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  49. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
  50. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
  51. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
  52. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
  53. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  54. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  55. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  56. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  57. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
  58. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
  59. package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
  60. package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
  61. package/src/index.ts +56 -0
  62. package/src/lib/cart.test.ts +15 -0
  63. package/src/lib/cart.ts +11 -0
  64. package/src/lib/contracts-curated.test.ts +207 -0
  65. package/src/lib/contracts-server-boundary.test.ts +52 -0
  66. package/src/lib/contracts.ts +234 -6
  67. package/src/lib/money.test.ts +64 -0
  68. package/src/lib/money.ts +65 -0
  69. package/tsup.config.ts +0 -1
  70. package/vitest.config.ts +9 -1
@@ -0,0 +1,216 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { PaymentMethod } from '@thorprovider/types'
4
+ import { CheckoutForm1PaymentStep } from './checkout-form-1-payment-step'
5
+ import { CheckoutForm1ShippingStep } from './checkout-form-1-shipping-step'
6
+ import { CheckoutForm1OrderSummary } from './checkout-form-1-order-summary'
7
+ import type { CheckoutForm1FormData } from './checkout-form-1'
8
+
9
+ const formData: CheckoutForm1FormData = {
10
+ email: '',
11
+ firstName: '',
12
+ lastName: '',
13
+ phone: '',
14
+ address: '',
15
+ city: '',
16
+ state: '',
17
+ zipCode: '',
18
+ country: 'US',
19
+ paymentMethod: 'pp_card',
20
+ cardNumber: '',
21
+ expiryDate: '',
22
+ cvv: '',
23
+ cardName: '',
24
+ saveInfo: false,
25
+ sameAsBilling: true,
26
+ newsletter: false,
27
+ promoCode: '',
28
+ }
29
+
30
+ const cardMethod: PaymentMethod = {
31
+ id: 'pp_card',
32
+ name: 'Tarjeta',
33
+ type: 'card',
34
+ isEnabled: true,
35
+ requiresSetup: true,
36
+ description: 'Paga con tarjeta',
37
+ }
38
+
39
+ const manualMethod: PaymentMethod = {
40
+ id: 'pp_system_default',
41
+ name: 'Pago manual TEST',
42
+ type: 'cash_on_delivery',
43
+ isEnabled: true,
44
+ requiresSetup: false,
45
+ description: 'Pago de prueba, sin tarjeta',
46
+ }
47
+
48
+ const noop = () => {}
49
+
50
+ const formatMoney = (money: { amount: string; currencyCode: string }) =>
51
+ `${money.currencyCode} ${money.amount}`
52
+
53
+ describe('CheckoutForm1 payment step — launch flow collects no raw card data', () => {
54
+ it('renders no card fields by default even for a card-type method', () => {
55
+ const html = renderToStaticMarkup(
56
+ <CheckoutForm1PaymentStep
57
+ formData={formData}
58
+ onChange={noop}
59
+ onCardNumberChange={noop}
60
+ onExpiryChange={noop}
61
+ paymentMethods={[cardMethod]}
62
+ />,
63
+ )
64
+ expect(html).not.toContain('checkout-card-number')
65
+ expect(html).not.toContain('checkout-cvv')
66
+ expect(html).toContain('Paga con tarjeta')
67
+ })
68
+
69
+ it('renders the approved manual TEST provider label only', () => {
70
+ const html = renderToStaticMarkup(
71
+ <CheckoutForm1PaymentStep
72
+ formData={{ ...formData, paymentMethod: 'pp_system_default' }}
73
+ onChange={noop}
74
+ onCardNumberChange={noop}
75
+ onExpiryChange={noop}
76
+ paymentMethods={[manualMethod]}
77
+ />,
78
+ )
79
+ expect(html).toContain('Pago manual TEST')
80
+ expect(html).toContain('Pago de prueba, sin tarjeta')
81
+ expect(html).not.toContain('checkout-card-number')
82
+ })
83
+
84
+ it('renders card fields only in the explicit legacy mode', () => {
85
+ const html = renderToStaticMarkup(
86
+ <CheckoutForm1PaymentStep
87
+ formData={formData}
88
+ onChange={noop}
89
+ onCardNumberChange={noop}
90
+ onExpiryChange={noop}
91
+ paymentMethods={[cardMethod]}
92
+ collectCardFields
93
+ />,
94
+ )
95
+ expect(html).toContain('checkout-card-number')
96
+ expect(html).toContain('checkout-cvv')
97
+ })
98
+ })
99
+
100
+ describe('CheckoutForm1 shipping step — controlled eligible options', () => {
101
+ it('renders only the supplied eligible shipping options with their prices', () => {
102
+ const html = renderToStaticMarkup(
103
+ <CheckoutForm1ShippingStep
104
+ formData={formData}
105
+ onChange={noop}
106
+ countries={[{ value: 'US', label: 'Estados Unidos' }]}
107
+ shippingOptions={[
108
+ { id: 'sm_std', name: 'Estándar', price: { amount: '5.00', currencyCode: 'USD' } },
109
+ { id: 'sm_exp', name: 'Exprés', price: { amount: '15.00', currencyCode: 'USD' } },
110
+ ]}
111
+ selectedShippingOptionId='sm_exp'
112
+ onSelectShippingOption={noop}
113
+ formatMoney={formatMoney}
114
+ />,
115
+ )
116
+ expect(html).toContain('Estándar')
117
+ expect(html).toContain('Exprés')
118
+ expect(html).toContain('USD 5.00')
119
+ expect(html).toContain('USD 15.00')
120
+ })
121
+
122
+ it('renders no shipping section when no options are supplied', () => {
123
+ const html = renderToStaticMarkup(
124
+ <CheckoutForm1ShippingStep formData={formData} onChange={noop} />,
125
+ )
126
+ expect(html).not.toContain('Shipping method')
127
+ })
128
+ })
129
+
130
+ describe('CheckoutForm1 order summary — authoritative totals and states', () => {
131
+ const items = [
132
+ {
133
+ id: 'line_1',
134
+ name: 'Taladro',
135
+ variant: 'Rojo',
136
+ quantity: 2,
137
+ lineTotal: { amount: '90.00', currencyCode: 'USD' },
138
+ },
139
+ ]
140
+
141
+ it('renders backend-authoritative totals verbatim', () => {
142
+ const html = renderToStaticMarkup(
143
+ <CheckoutForm1OrderSummary
144
+ orderSummary={{
145
+ items,
146
+ totals: {
147
+ subtotal: { amount: '90.00', currencyCode: 'USD' },
148
+ tax: { amount: '7.20', currencyCode: 'USD' },
149
+ shipping: { amount: '5.00', currencyCode: 'USD' },
150
+ total: { amount: '102.20', currencyCode: 'USD' },
151
+ },
152
+ }}
153
+ formatMoney={formatMoney}
154
+ />,
155
+ )
156
+ expect(html).toContain('USD 90.00')
157
+ expect(html).toContain('USD 7.20')
158
+ expect(html).toContain('USD 5.00')
159
+ expect(html).toContain('USD 102.20')
160
+ expect(html).not.toContain('Free shipping on orders over')
161
+ })
162
+
163
+ it('renders a controlled pending state without inventing amounts', () => {
164
+ const html = renderToStaticMarkup(
165
+ <CheckoutForm1OrderSummary orderSummary={{ items }} totalsState='pending' />,
166
+ )
167
+ expect(html).toContain('Actualizando totales')
168
+ expect(html).not.toContain('Total')
169
+ })
170
+
171
+ it('never renders a fake zero for absent or malformed required totals', () => {
172
+ const html = renderToStaticMarkup(
173
+ <CheckoutForm1OrderSummary
174
+ orderSummary={{
175
+ items,
176
+ totals: {
177
+ subtotal: { amount: '', currencyCode: 'USD' },
178
+ total: { amount: 'abc', currencyCode: 'USD' },
179
+ },
180
+ }}
181
+ formatMoney={formatMoney}
182
+ />,
183
+ )
184
+ expect(html).not.toContain('USD 0')
185
+ expect(html).toContain('Totales no disponibles')
186
+ })
187
+
188
+ it('preserves a legitimate zero total', () => {
189
+ const html = renderToStaticMarkup(
190
+ <CheckoutForm1OrderSummary
191
+ orderSummary={{
192
+ items,
193
+ totals: {
194
+ subtotal: { amount: '0', currencyCode: 'USD' },
195
+ total: { amount: '0', currencyCode: 'USD' },
196
+ },
197
+ }}
198
+ formatMoney={formatMoney}
199
+ />,
200
+ )
201
+ expect(html).toContain('USD 0')
202
+ expect(html).not.toContain('Totales no disponibles')
203
+ })
204
+
205
+ it('renders a controlled error state with a recovery action', () => {
206
+ const html = renderToStaticMarkup(
207
+ <CheckoutForm1OrderSummary
208
+ orderSummary={{ items }}
209
+ totalsState='error'
210
+ onRetryTotals={noop}
211
+ />,
212
+ )
213
+ expect(html).toContain('No se pudieron actualizar los totales')
214
+ expect(html).toContain('Reintentar')
215
+ })
216
+ })
@@ -11,7 +11,7 @@ import { CheckoutForm1PaymentStep } from './checkout-form-1-payment-step'
11
11
  import { CheckoutForm1OrderSummary } from './checkout-form-1-order-summary'
12
12
  import { CheckoutForm1Progress } from './checkout-form-1-progress'
13
13
  import { formatCardNumber, formatExpiryDate } from './card-formatting'
14
- import type { PaymentMethod } from '@thorprovider/types'
14
+ import type { Money, PaymentMethod } from '@thorprovider/types'
15
15
  import type { CheckoutFieldErrors } from './checkout-form-types'
16
16
 
17
17
  export interface CheckoutForm1FormData {
@@ -44,17 +44,37 @@ export type CheckoutForm1FieldDescriptions = Partial<
44
44
  export interface CheckoutForm1OrderSummaryItem {
45
45
  id: string | number
46
46
  name: string
47
- variant: string
48
- price: number
47
+ variant?: string
49
48
  quantity: number
50
- image: string
49
+ /** Canonical backend line total; preferred over `price * quantity`. */
50
+ lineTotal?: Money
51
+ /** @deprecated legacy numeric price, used only when `lineTotal` is absent. */
52
+ price?: number
53
+ /** Optional; an absent image is omitted rather than serialized as `""`. */
54
+ image?: string
51
55
  }
52
56
 
57
+ export interface CheckoutForm1Totals {
58
+ subtotal: Money
59
+ discount?: Money
60
+ tax?: Money
61
+ shipping?: Money
62
+ total: Money
63
+ }
64
+
65
+ export type CheckoutForm1TotalsState = 'idle' | 'pending' | 'error'
66
+
53
67
  export interface CheckoutForm1OrderSummary {
54
68
  items: CheckoutForm1OrderSummaryItem[]
55
- shipping: number
56
- tax: number
69
+ /** Backend-authoritative totals; rendered verbatim when present. */
70
+ totals?: CheckoutForm1Totals
71
+ /** @deprecated legacy numeric amounts, used only when `totals` is absent. */
72
+ shipping?: number
73
+ /** @deprecated */
74
+ tax?: number
75
+ /** @deprecated */
57
76
  discount?: number
77
+ /** @deprecated */
58
78
  promoDiscount?: number
59
79
  }
60
80
 
@@ -63,6 +83,15 @@ export interface CheckoutForm1CountryOption {
63
83
  label: string
64
84
  }
65
85
 
86
+ export interface CheckoutForm1ShippingOption {
87
+ /** Backend shipping method id — the controlled selection identity. */
88
+ id: string
89
+ name: string
90
+ price?: Money
91
+ description?: string
92
+ estimatedDays?: string
93
+ }
94
+
66
95
  export type CheckoutForm1ChangeHandler = (
67
96
  field: keyof CheckoutForm1FormData,
68
97
  value: string | boolean
@@ -78,11 +107,35 @@ export interface CheckoutForm1Props {
78
107
  title?: string
79
108
  description?: string
80
109
  orderSummary?: CheckoutForm1OrderSummary
110
+ /** Controlled state of the totals fetch/mutation. */
111
+ totalsState?: CheckoutForm1TotalsState
112
+ totalsErrorLabel?: string
113
+ retryLabel?: string
114
+ onRetryTotals?: () => void
81
115
  countries?: CheckoutForm1CountryOption[]
116
+ /** Eligible shipping options for the cart's region/address. */
117
+ shippingOptions?: CheckoutForm1ShippingOption[]
118
+ /** Controlled selection of a shipping option id. */
119
+ selectedShippingOptionId?: string
120
+ onSelectShippingOption?: (id: string) => void
82
121
  paymentMethods?: PaymentMethod[]
83
122
  noPaymentMethodsLabel?: string
123
+ /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
124
+ formatMoney?: (money: Money) => string
125
+ /**
126
+ * Raw card fields are legacy-only and never collected in the launch flow.
127
+ * Defaults to `false`: the curated surface renders no card inputs.
128
+ */
129
+ collectCardFields?: boolean
130
+ onApplyPromo?: (code: string) => void
131
+ promoPending?: boolean
132
+ promoApplyLabel?: string
133
+ promoPlaceholder?: string
134
+ /** Optional trust copy; rendered only when supplied (never fabricated). */
135
+ trustNotes?: string[]
84
136
  backLabel?: string
85
137
  completeOrderLabel?: string
138
+ processingLabel?: string
86
139
  continueLabel?: string
87
140
  values: CheckoutForm1FormData
88
141
  onChange: CheckoutForm1ChangeHandler
@@ -94,17 +147,35 @@ export interface CheckoutForm1Props {
94
147
  submitting?: boolean
95
148
  /** Order-level error surfaced above the footer actions. */
96
149
  submitError?: string
150
+ /** Optional recovery action for a failed submission. */
151
+ onRetrySubmit?: () => void
152
+ submitErrorActionLabel?: string
97
153
  }
98
154
 
99
155
  export default function CheckoutForm1({
100
156
  title,
101
157
  description,
102
158
  orderSummary,
159
+ totalsState = 'idle',
160
+ totalsErrorLabel,
161
+ retryLabel,
162
+ onRetryTotals,
103
163
  countries,
164
+ shippingOptions,
165
+ selectedShippingOptionId,
166
+ onSelectShippingOption,
104
167
  paymentMethods,
105
168
  noPaymentMethodsLabel,
169
+ formatMoney,
170
+ collectCardFields = false,
171
+ onApplyPromo,
172
+ promoPending = false,
173
+ promoApplyLabel,
174
+ promoPlaceholder,
175
+ trustNotes,
106
176
  backLabel = 'Atrás',
107
177
  completeOrderLabel = 'Completar pedido',
178
+ processingLabel = 'Procesando pedido…',
108
179
  continueLabel = 'Continuar',
109
180
  values,
110
181
  onChange,
@@ -114,6 +185,8 @@ export default function CheckoutForm1({
114
185
  onSubmit,
115
186
  submitting = false,
116
187
  submitError,
188
+ onRetrySubmit,
189
+ submitErrorActionLabel = 'Reintentar',
117
190
  }: CheckoutForm1Props) {
118
191
  const [step, setStep] = useState(1)
119
192
 
@@ -175,6 +248,10 @@ export default function CheckoutForm1({
175
248
  formData={values}
176
249
  onChange={onChange}
177
250
  countries={countries}
251
+ shippingOptions={shippingOptions}
252
+ selectedShippingOptionId={selectedShippingOptionId}
253
+ onSelectShippingOption={onSelectShippingOption}
254
+ formatMoney={formatMoney}
178
255
  fieldErrors={fieldErrors}
179
256
  fieldDescriptions={fieldDescriptions}
180
257
  />
@@ -187,6 +264,7 @@ export default function CheckoutForm1({
187
264
  onExpiryChange={handleExpiryChange}
188
265
  paymentMethods={paymentMethods}
189
266
  noPaymentMethodsLabel={noPaymentMethodsLabel}
267
+ collectCardFields={collectCardFields}
190
268
  fieldErrors={fieldErrors}
191
269
  fieldDescriptions={fieldDescriptions}
192
270
  />
@@ -195,9 +273,20 @@ export default function CheckoutForm1({
195
273
  {submitError && (
196
274
  <div
197
275
  role="alert"
198
- className="mt-6 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
276
+ className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
199
277
  >
200
- {submitError}
278
+ <span>{submitError}</span>
279
+ {onRetrySubmit ? (
280
+ <Button
281
+ type="button"
282
+ variant="outline"
283
+ size="sm"
284
+ onClick={onRetrySubmit}
285
+ disabled={submitting}
286
+ >
287
+ {submitErrorActionLabel}
288
+ </Button>
289
+ ) : null}
201
290
  </div>
202
291
  )}
203
292
 
@@ -213,7 +302,7 @@ export default function CheckoutForm1({
213
302
  {isLastStep ? (
214
303
  <Button type="button" onClick={handleSubmit} disabled={submitting}>
215
304
  <Lock className="size-4" />
216
- {submitting ? 'Procesando pedido…' : completeOrderLabel}
305
+ {submitting ? processingLabel : completeOrderLabel}
217
306
  </Button>
218
307
  ) : (
219
308
  <Button type="button" onClick={handleContinue} disabled={submitting}>
@@ -226,10 +315,24 @@ export default function CheckoutForm1({
226
315
 
227
316
  {orderSummary && (
228
317
  <div className="lg:col-span-1">
229
- <CheckoutForm1OrderSummary orderSummary={orderSummary} />
318
+ <CheckoutForm1OrderSummary
319
+ orderSummary={orderSummary}
320
+ totalsState={totalsState}
321
+ totalsErrorLabel={totalsErrorLabel}
322
+ retryLabel={retryLabel}
323
+ onRetryTotals={onRetryTotals}
324
+ promoCode={values.promoCode}
325
+ onPromoCodeChange={(value) => onChange('promoCode', value)}
326
+ onApplyPromo={onApplyPromo}
327
+ promoPending={promoPending}
328
+ promoApplyLabel={promoApplyLabel}
329
+ promoPlaceholder={promoPlaceholder}
330
+ trustNotes={trustNotes}
331
+ formatMoney={formatMoney}
332
+ />
230
333
  </div>
231
334
  )}
232
335
  </div>
233
336
  </div>
234
337
  )
235
- }
338
+ }
@@ -0,0 +1,27 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { BackToTop, backToTopPropsSchema, resolveScrollBehavior } from './back-to-top'
5
+
6
+ describe('BackToTop', () => {
7
+ it('parses a valid props object', () => {
8
+ const result = backToTopPropsSchema.parse({ threshold: 300 })
9
+ expect(result.threshold).toBe(300)
10
+ })
11
+
12
+ it('accepts an empty props object', () => {
13
+ expect(() => backToTopPropsSchema.parse({})).not.toThrow()
14
+ })
15
+
16
+ it('uses an instant jump under reduced motion', () => {
17
+ expect(resolveScrollBehavior(true)).toBe('auto')
18
+ })
19
+
20
+ it('uses a smooth scroll when motion is allowed', () => {
21
+ expect(resolveScrollBehavior(false)).toBe('smooth')
22
+ })
23
+
24
+ it('renders nothing before it becomes visible', () => {
25
+ expect(renderToStaticMarkup(<BackToTop />)).toBe('')
26
+ })
27
+ })
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import { ArrowUp } from 'lucide-react'
4
+ import { useEffect, useState } from 'react'
5
+ import { z } from 'zod'
6
+
7
+ import { cn } from '../../../lib/utils'
8
+
9
+ export const backToTopPropsSchema = z.object({
10
+ threshold: z.number().optional(),
11
+ label: z.string().optional(),
12
+ className: z.string().optional(),
13
+ })
14
+
15
+ export type BackToTopProps = z.infer<typeof backToTopPropsSchema>
16
+
17
+ const DEFAULT_THRESHOLD = 400
18
+
19
+ /**
20
+ * Resolve the scroll behavior for a back-to-top activation. Pure so it can be
21
+ * unit-tested without a DOM: reduced motion yields an instant jump.
22
+ */
23
+ export function resolveScrollBehavior(
24
+ prefersReducedMotion: boolean,
25
+ ): 'auto' | 'smooth' {
26
+ return prefersReducedMotion ? 'auto' : 'smooth'
27
+ }
28
+
29
+ /**
30
+ * Back-to-top control that appears once the page is scrolled past `threshold`
31
+ * and returns the viewport to the top. Honors `prefers-reduced-motion` (instant
32
+ * jump instead of a smooth scroll). Framework-agnostic; icon-only with an
33
+ * accessible label.
34
+ */
35
+ export function BackToTop({
36
+ threshold = DEFAULT_THRESHOLD,
37
+ label = 'Back to top',
38
+ className,
39
+ }: BackToTopProps) {
40
+ const [visible, setVisible] = useState(false)
41
+
42
+ useEffect(() => {
43
+ const onScroll = () => setVisible(window.scrollY > threshold)
44
+ onScroll()
45
+ window.addEventListener('scroll', onScroll, { passive: true })
46
+ return () => window.removeEventListener('scroll', onScroll)
47
+ }, [threshold])
48
+
49
+ if (!visible) return null
50
+
51
+ const handleClick = () => {
52
+ const reduce =
53
+ typeof window !== 'undefined' &&
54
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
55
+ window.scrollTo({ top: 0, behavior: resolveScrollBehavior(reduce) })
56
+ }
57
+
58
+ return (
59
+ <button
60
+ type="button"
61
+ onClick={handleClick}
62
+ aria-label={label}
63
+ className={cn(
64
+ 'fixed bottom-4 right-4 z-40 inline-flex size-11 items-center justify-center rounded-full border bg-card text-foreground shadow-lg transition-colors hover:bg-secondary',
65
+ className,
66
+ )}
67
+ >
68
+ <ArrowUp className="size-5" aria-hidden="true" />
69
+ </button>
70
+ )
71
+ }