@thorprovider/blocks 0.1.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 (184) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/.turbo/turbo-test.log +10 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +161 -0
  6. package/components.json +33 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/contracts.d.mts +563 -0
  9. package/dist/contracts.d.ts +563 -0
  10. package/dist/contracts.js +207 -0
  11. package/dist/contracts.js.map +1 -0
  12. package/dist/contracts.mjs +168 -0
  13. package/dist/contracts.mjs.map +1 -0
  14. package/dist/index.d.mts +2408 -0
  15. package/dist/index.d.ts +2408 -0
  16. package/dist/index.js +16422 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.mjs +16352 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/docs/standards/data-contracts.md +70 -0
  21. package/docs/standards/theming.md +85 -0
  22. package/package.json +62 -0
  23. package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
  24. package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
  25. package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
  26. package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
  27. package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
  28. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
  29. package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
  30. package/src/blocks/application/verification/verify-1.tsx +307 -0
  31. package/src/blocks/application/verification/verify-confetti.tsx +192 -0
  32. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
  33. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
  34. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
  35. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
  36. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
  37. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
  38. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
  39. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
  40. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
  41. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
  42. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
  43. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
  44. package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
  45. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
  46. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
  47. package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
  48. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
  49. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
  50. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
  51. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
  52. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
  53. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
  54. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
  55. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
  56. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
  57. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
  58. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
  59. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
  60. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
  61. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
  62. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
  63. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
  67. package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
  68. package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
  69. package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
  70. package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
  71. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
  72. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
  73. package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
  74. package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
  75. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
  76. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
  77. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
  78. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
  79. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
  80. package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
  81. package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
  82. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
  83. package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
  84. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
  85. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
  86. package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
  87. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
  88. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
  89. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
  90. package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
  91. package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
  92. package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
  93. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
  94. package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
  95. package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
  96. package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
  97. package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
  98. package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
  99. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
  105. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
  106. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
  107. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
  108. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
  109. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
  110. package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
  111. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
  112. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
  113. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
  114. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
  115. package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
  116. package/src/blocks/marketing/banners/banner-1.tsx +188 -0
  117. package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
  118. package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
  119. package/src/blocks/marketing/features/feature-1.tsx +97 -0
  120. package/src/blocks/marketing/features/feature-2.tsx +82 -0
  121. package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
  122. package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
  123. package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
  124. package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
  125. package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
  126. package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
  127. package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
  128. package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
  129. package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
  130. package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
  131. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
  132. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
  133. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
  134. package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
  135. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
  136. package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
  137. package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
  138. package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
  139. package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
  140. package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
  141. package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
  142. package/src/blocks/shared/reaction-toggle.tsx +145 -0
  143. package/src/hooks/use-mobile.ts +19 -0
  144. package/src/index.ts +200 -0
  145. package/src/lib/contracts.test.ts +32 -0
  146. package/src/lib/contracts.ts +235 -0
  147. package/src/lib/types/menu.ts +43 -0
  148. package/src/lib/types.ts +4 -0
  149. package/src/lib/utils.ts +10 -0
  150. package/src/ui/accordion.tsx +65 -0
  151. package/src/ui/avatar.tsx +108 -0
  152. package/src/ui/badge.tsx +47 -0
  153. package/src/ui/breadcrumb.tsx +108 -0
  154. package/src/ui/button-group.tsx +83 -0
  155. package/src/ui/button.tsx +63 -0
  156. package/src/ui/card.tsx +91 -0
  157. package/src/ui/carousel.tsx +241 -0
  158. package/src/ui/dialog.tsx +158 -0
  159. package/src/ui/drawer.tsx +134 -0
  160. package/src/ui/dropdown-menu.tsx +256 -0
  161. package/src/ui/empty.tsx +103 -0
  162. package/src/ui/field.tsx +248 -0
  163. package/src/ui/input-group.tsx +170 -0
  164. package/src/ui/input-otp.tsx +76 -0
  165. package/src/ui/input.tsx +20 -0
  166. package/src/ui/label.tsx +23 -0
  167. package/src/ui/navigation-menu.tsx +167 -0
  168. package/src/ui/pagination.tsx +127 -0
  169. package/src/ui/progress.tsx +30 -0
  170. package/src/ui/radio-group.tsx +44 -0
  171. package/src/ui/select.tsx +189 -0
  172. package/src/ui/separator.tsx +27 -0
  173. package/src/ui/sheet.tsx +142 -0
  174. package/src/ui/sidebar.tsx +726 -0
  175. package/src/ui/skeleton.tsx +13 -0
  176. package/src/ui/table.tsx +115 -0
  177. package/src/ui/tabs.tsx +90 -0
  178. package/src/ui/textarea.tsx +17 -0
  179. package/src/ui/toggle-group.tsx +83 -0
  180. package/src/ui/toggle.tsx +46 -0
  181. package/src/ui/tooltip.tsx +56 -0
  182. package/tsconfig.json +20 -0
  183. package/tsup.config.ts +16 -0
  184. package/vitest.config.ts +9 -0
@@ -0,0 +1,307 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useId, useState, type FormEvent } from 'react'
4
+ import { KeyRound, Loader2, ShieldCheck } from 'lucide-react'
5
+ import { Button } from '../../../ui/button'
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../ui/card'
7
+ import { Input } from '../../../ui/input'
8
+ import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '../../../ui/input-otp'
9
+ import { Label } from '../../../ui/label'
10
+ import { Confetti } from './verify-confetti'
11
+
12
+ /**
13
+ * Bloque de verificación de dos factores (solo render).
14
+ *
15
+ * Este bloque NO valida credenciales reales, no crea sesiones ni se comunica con
16
+ * ningún backend. Para producción, conecta la verificación real a través de las
17
+ * props `onVerify` y `onVerifyBackup` (devuelven `Promise<boolean>`) y de `onResend`
18
+ * / `onContinue`. Si no se proporcionan, se usa una demo interna documentada:
19
+ * código `123456`, código de respaldo `CQ4T-9WLM`, máximo 3 intentos antes del
20
+ * bloqueo y reenvío con espera de 30 segundos. La máquina de estados (modo
21
+ * otp/respaldo, intentos, bloqueo, cuenta atrás, verificado, pendiente) es el
22
+ * comportamiento valioso del bloque y se conserva en ambos modos.
23
+ */
24
+
25
+ const acceptedCode = '123456'
26
+ const acceptedBackupCode = 'CQ4T-9WLM'
27
+ const maxAttempts = 3
28
+ const resendCooldownSeconds = 30
29
+
30
+ async function demoVerifyCode(code: string): Promise<boolean> {
31
+ await new Promise(resolve => setTimeout(resolve, 700))
32
+ return code === acceptedCode
33
+ }
34
+
35
+ async function demoVerifyBackupCode(code: string): Promise<boolean> {
36
+ await new Promise(resolve => setTimeout(resolve, 700))
37
+ return code.trim().toUpperCase() === acceptedBackupCode
38
+ }
39
+
40
+ export interface Verify1Props {
41
+ verifyTitleLabel?: string
42
+ verifyDescriptionLabel?: string
43
+ backupCodeTitleLabel?: string
44
+ backupCodeDescriptionLabel?: string
45
+ backupCodeLabel?: string
46
+ backupCodePlaceholder?: string
47
+ otpAriaLabel?: string
48
+ verifyLabel?: string
49
+ resendCodeLabel?: string
50
+ /** Plantilla con {n} sustituido por los segundos restantes. */
51
+ resendCooldownLabel?: string
52
+ useBackupCodeLabel?: string
53
+ useAuthenticatorLabel?: string
54
+ tooManyAttemptsLabel?: string
55
+ /** Plantilla con {n} sustituido por los intentos restantes. */
56
+ attemptsLeftLabel?: string
57
+ verifiedTitleLabel?: string
58
+ verifiedDescriptionLabel?: string
59
+ continueLabel?: string
60
+ demoOtpHintLabel?: string
61
+ /** Plantilla con {code} sustituido por el código de respaldo de la demo. */
62
+ demoBackupHintLabel?: string
63
+ /** Verificación real del código OTP. Devuelve true si es correcto. */
64
+ onVerify?: (code: string) => Promise<boolean>
65
+ /** Verificación real del código de respaldo. Devuelve true si es correcto. */
66
+ onVerifyBackup?: (code: string) => Promise<boolean>
67
+ /** Se dispara al pedir reenviar el código. */
68
+ onResend?: () => void
69
+ /** Se dispara al continuar tras la verificación. */
70
+ onContinue?: () => void
71
+ }
72
+
73
+ export function Verify1({
74
+ verifyTitleLabel = 'Verificación de dos factores',
75
+ verifyDescriptionLabel = 'Ingresa el código de 6 dígitos de tu aplicación de autenticación',
76
+ backupCodeTitleLabel = 'Usa un código de respaldo',
77
+ backupCodeDescriptionLabel = 'Ingresa uno de tus códigos de respaldo de un solo uso',
78
+ backupCodeLabel = 'Código de respaldo',
79
+ backupCodePlaceholder = 'XXXX-XXXX',
80
+ otpAriaLabel = 'Código de verificación',
81
+ verifyLabel = 'Verificar',
82
+ resendCodeLabel = 'Reenviar código',
83
+ resendCooldownLabel = 'Reenviar código en {n}s',
84
+ useBackupCodeLabel = 'Usar un código de respaldo en su lugar',
85
+ useAuthenticatorLabel = 'Usa tu aplicación de autenticación',
86
+ tooManyAttemptsLabel = 'Demasiados códigos incorrectos. Espera quince minutos o usa un código de respaldo.',
87
+ attemptsLeftLabel = 'Ese código no es correcto. Te quedan {n} intentos.',
88
+ verifiedTitleLabel = 'Verificación completa',
89
+ verifiedDescriptionLabel = 'Este dispositivo es de confianza por treinta días. Puedes revocarlo desde la configuración de seguridad.',
90
+ continueLabel = 'Continuar',
91
+ demoOtpHintLabel = 'Prueba 123456 en esta demo.',
92
+ demoBackupHintLabel = 'Prueba {code} en esta demo. Cada código funciona una sola vez.',
93
+ onVerify,
94
+ onVerifyBackup,
95
+ onResend,
96
+ onContinue,
97
+ }: Verify1Props) {
98
+ const backupId = useId()
99
+ const [mode, setMode] = useState<'otp' | 'backup'>('otp')
100
+ const [code, setCode] = useState('')
101
+ const [backup, setBackup] = useState('')
102
+ const [pending, setPending] = useState(false)
103
+ const [error, setError] = useState<string | null>(null)
104
+ const [attempts, setAttempts] = useState(0)
105
+ const [verified, setVerified] = useState(false)
106
+ const [cooldown, setCooldown] = useState(resendCooldownSeconds)
107
+
108
+ const locked = attempts >= maxAttempts
109
+
110
+ useEffect(() => {
111
+ if (cooldown <= 0) {
112
+ return
113
+ }
114
+
115
+ const timer = window.setTimeout(() => setCooldown(current => current - 1), 1000)
116
+ return () => window.clearTimeout(timer)
117
+ }, [cooldown])
118
+
119
+ const handleResend = () => {
120
+ onResend?.()
121
+ setCooldown(resendCooldownSeconds)
122
+ }
123
+
124
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
125
+ event.preventDefault()
126
+
127
+ if (locked) {
128
+ return
129
+ }
130
+
131
+ setPending(true)
132
+ setError(null)
133
+
134
+ try {
135
+ const ok =
136
+ mode === 'otp'
137
+ ? onVerify
138
+ ? await onVerify(code)
139
+ : await demoVerifyCode(code)
140
+ : onVerifyBackup
141
+ ? await onVerifyBackup(backup)
142
+ : await demoVerifyBackupCode(backup)
143
+
144
+ if (ok) {
145
+ setVerified(true)
146
+ return
147
+ }
148
+
149
+ const next = attempts + 1
150
+ setAttempts(next)
151
+ setCode('')
152
+ setBackup('')
153
+ setError(
154
+ next >= maxAttempts
155
+ ? tooManyAttemptsLabel
156
+ : attemptsLeftLabel.replace('{n}', String(maxAttempts - next)),
157
+ )
158
+ } finally {
159
+ setPending(false)
160
+ }
161
+ }
162
+
163
+ if (verified) {
164
+ return (
165
+ <div className='bg-muted/30 relative flex min-h-dvh w-full items-center justify-center overflow-hidden px-4 py-12'>
166
+ {/* Fired once, over the whole field rather than the card, so the paper has somewhere
167
+ to fall. The success is stated in the heading below; this only decorates it. */}
168
+ <Confetti fire origin={{ x: 0.5, y: 0.5 }} />
169
+
170
+ <Card className='relative w-full max-w-md gap-6 py-8 shadow-lg'>
171
+ <CardContent className='flex flex-col items-center gap-4 px-8 py-8 text-center'>
172
+ <span
173
+ aria-hidden='true'
174
+ className='bg-primary/10 text-primary flex size-14 items-center justify-center rounded-full'
175
+ >
176
+ <ShieldCheck className='size-7' />
177
+ </span>
178
+ <h1 className='text-2xl font-semibold tracking-tight'>{verifiedTitleLabel}</h1>
179
+ <p className='text-muted-foreground max-w-xs text-sm'>{verifiedDescriptionLabel}</p>
180
+ <Button className='mt-2' onClick={onContinue}>
181
+ {continueLabel}
182
+ </Button>
183
+ </CardContent>
184
+ </Card>
185
+ </div>
186
+ )
187
+ }
188
+
189
+ return (
190
+ <div className='bg-muted/30 flex min-h-dvh w-full items-center justify-center px-4 py-12'>
191
+ <Card className='w-full max-w-md gap-6 py-8 shadow-lg'>
192
+ <CardHeader className='gap-2 px-8 text-center'>
193
+ <span
194
+ aria-hidden='true'
195
+ className='bg-muted text-muted-foreground mx-auto mb-3 flex size-14 items-center justify-center rounded-full'
196
+ >
197
+ {mode === 'otp' ? <ShieldCheck className='size-6' /> : <KeyRound className='size-6' />}
198
+ </span>
199
+ <CardTitle className='text-2xl font-semibold tracking-tight'>
200
+ {mode === 'otp' ? verifyTitleLabel : backupCodeTitleLabel}
201
+ </CardTitle>
202
+ <CardDescription>
203
+ {mode === 'otp' ? verifyDescriptionLabel : backupCodeDescriptionLabel}
204
+ </CardDescription>
205
+ </CardHeader>
206
+
207
+ <CardContent className='px-8'>
208
+ <form onSubmit={submit} noValidate className='flex flex-col items-center gap-6'>
209
+ {mode === 'otp' ? (
210
+ <>
211
+ <InputOTP
212
+ maxLength={6}
213
+ value={code}
214
+ onChange={setCode}
215
+ disabled={pending || locked}
216
+ aria-label={otpAriaLabel}
217
+ aria-invalid={error ? true : undefined}
218
+ aria-describedby={error ? 'verify-error' : undefined}
219
+ >
220
+ <InputOTPGroup>
221
+ <InputOTPSlot index={0} className='size-12 text-lg' />
222
+ <InputOTPSlot index={1} className='size-12 text-lg' />
223
+ <InputOTPSlot index={2} className='size-12 text-lg' />
224
+ </InputOTPGroup>
225
+ <InputOTPSeparator />
226
+ <InputOTPGroup>
227
+ <InputOTPSlot index={3} className='size-12 text-lg' />
228
+ <InputOTPSlot index={4} className='size-12 text-lg' />
229
+ <InputOTPSlot index={5} className='size-12 text-lg' />
230
+ </InputOTPGroup>
231
+ </InputOTP>
232
+ <p className='text-muted-foreground text-xs'>{demoOtpHintLabel}</p>
233
+ </>
234
+ ) : (
235
+ <div className='grid w-full gap-2'>
236
+ <Label htmlFor={backupId}>{backupCodeLabel}</Label>
237
+ <Input
238
+ id={backupId}
239
+ value={backup}
240
+ onChange={event => setBackup(event.target.value)}
241
+ placeholder={backupCodePlaceholder}
242
+ autoComplete='one-time-code'
243
+ className='font-mono tracking-wider'
244
+ aria-invalid={error ? true : undefined}
245
+ aria-describedby={error ? 'verify-error' : `${backupId}-hint`}
246
+ disabled={pending || locked}
247
+ />
248
+ <p id={`${backupId}-hint`} className='text-muted-foreground text-xs'>
249
+ {demoBackupHintLabel.replace('{code}', acceptedBackupCode)}
250
+ </p>
251
+ </div>
252
+ )}
253
+
254
+ {error ? (
255
+ <p id='verify-error' role='alert' className='text-destructive text-center text-sm'>
256
+ {error}
257
+ </p>
258
+ ) : null}
259
+
260
+ <Button
261
+ type='submit'
262
+ className='w-full'
263
+ disabled={
264
+ pending || locked || (mode === 'otp' ? code.length < 6 : backup.trim() === '')
265
+ }
266
+ aria-busy={pending}
267
+ >
268
+ {pending ? <Loader2 className='size-4 animate-spin' aria-hidden='true' /> : null}
269
+ {verifyLabel}
270
+ </Button>
271
+
272
+ <div className='flex w-full flex-col items-center gap-1 pt-1'>
273
+ {mode === 'otp' ? (
274
+ <Button
275
+ type='button'
276
+ variant='ghost'
277
+ size='sm'
278
+ disabled={cooldown > 0}
279
+ onClick={handleResend}
280
+ >
281
+ {cooldown > 0
282
+ ? resendCooldownLabel.replace('{n}', String(cooldown))
283
+ : resendCodeLabel}
284
+ </Button>
285
+ ) : null}
286
+
287
+ <Button
288
+ type='button'
289
+ variant='link'
290
+ size='sm'
291
+ className='text-muted-foreground'
292
+ onClick={() => {
293
+ setMode(current => (current === 'otp' ? 'backup' : 'otp'))
294
+ setError(null)
295
+ }}
296
+ >
297
+ {mode === 'otp' ? useBackupCodeLabel : useAuthenticatorLabel}
298
+ </Button>
299
+ </div>
300
+ </form>
301
+ </CardContent>
302
+ </Card>
303
+ </div>
304
+ )
305
+ }
306
+
307
+ export default Verify1
@@ -0,0 +1,192 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useImperativeHandle, useRef } from 'react'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ /**
8
+ * @title Confetti burst
9
+ * @description Canvas confetti fired once when something succeeds, with no animation library.
10
+ *
11
+ * Deliberately dependency-free. Every other confetti component reaches for `canvas-confetti`,
12
+ * which means a buyer pasting one block also installs a package. This is roughly a hundred
13
+ * lines of the same physics: throw particles from an origin, apply gravity and drag, spin them,
14
+ * fade them out, stop the loop when the last one dies.
15
+ *
16
+ * The canvas is `pointer-events-none` and `aria-hidden`, so it can never sit between a person
17
+ * and the button underneath it, and a screen reader never announces it. Success has to be
18
+ * stated in text as well; confetti is not a message.
19
+ *
20
+ * Under `prefers-reduced-motion` it renders nothing at all. A burst of moving particles is the
21
+ * exact thing that setting is asking us not to do, and dropping it costs no information.
22
+ */
23
+ const GRAVITY = 0.24
24
+ const DRAG = 0.992
25
+ const TERMINAL_VELOCITY = 7
26
+ const DECAY = 0.0125
27
+
28
+ /** Bright by default: a monochrome theme would fire grey paper, which reads as dust. */
29
+ const DEFAULT_COLORS = ['#f43f5e', '#f59e0b', '#22c55e', '#3b82f6', '#a855f7', '#14b8a6']
30
+
31
+ export interface ConfettiHandle {
32
+ /** Fire a burst now. Repeated calls stack rather than restart. */
33
+ fire: () => void
34
+ }
35
+
36
+ export interface ConfettiProps extends Omit<React.ComponentProps<'canvas'>, 'ref'> {
37
+ /** Flip to true to fire. Firing again needs a flip back to false first. */
38
+ fire?: boolean
39
+ /** Particles per burst. */
40
+ count?: number
41
+ /** Origin inside the canvas, 0 to 1 on each axis. */
42
+ origin?: { x: number; y: number }
43
+ /** How far the burst throws, in canvas pixels per frame. */
44
+ spread?: number
45
+ colors?: string[]
46
+ ref?: React.Ref<ConfettiHandle>
47
+ }
48
+
49
+ type Particle = {
50
+ x: number
51
+ y: number
52
+ vx: number
53
+ vy: number
54
+ size: number
55
+ tilt: number
56
+ spin: number
57
+ life: number
58
+ color: string
59
+ }
60
+
61
+ export function Confetti({
62
+ fire = false,
63
+ count = 90,
64
+ origin = { x: 0.5, y: 0.45 },
65
+ spread = 11,
66
+ colors = DEFAULT_COLORS,
67
+ className,
68
+ ref,
69
+ ...props
70
+ }: ConfettiProps) {
71
+ const canvasRef = useRef<HTMLCanvasElement>(null)
72
+ const particlesRef = useRef<Particle[]>([])
73
+ const frameRef = useRef<number | null>(null)
74
+
75
+ const draw = useCallback(() => {
76
+ const canvas = canvasRef.current
77
+ const context = canvas?.getContext('2d')
78
+
79
+ if (!canvas || !context) {
80
+ return
81
+ }
82
+
83
+ const ratio = window.devicePixelRatio || 1
84
+ context.clearRect(0, 0, canvas.width, canvas.height)
85
+
86
+ const alive: Particle[] = []
87
+
88
+ for (const particle of particlesRef.current) {
89
+ particle.vy = Math.min(particle.vy + GRAVITY, TERMINAL_VELOCITY)
90
+ particle.vx *= DRAG
91
+ particle.x += particle.vx
92
+ particle.y += particle.vy
93
+ particle.tilt += particle.spin
94
+ particle.life -= DECAY
95
+
96
+ if (particle.life <= 0) {
97
+ continue
98
+ }
99
+
100
+ alive.push(particle)
101
+
102
+ context.save()
103
+ context.translate(particle.x * ratio, particle.y * ratio)
104
+ context.rotate(particle.tilt)
105
+ context.globalAlpha = Math.min(1, particle.life)
106
+ context.fillStyle = particle.color
107
+ // Scaling the height by the tilt is what sells a flat rectangle as a tumbling
108
+ // piece of paper. Without it the pieces read as falling dots.
109
+ context.fillRect(
110
+ (-particle.size / 2) * ratio,
111
+ (-particle.size / 4) * ratio,
112
+ particle.size * ratio,
113
+ ((particle.size / 2) * Math.abs(Math.cos(particle.tilt)) + 1) * ratio,
114
+ )
115
+ context.restore()
116
+ }
117
+
118
+ particlesRef.current = alive
119
+
120
+ if (alive.length > 0) {
121
+ frameRef.current = window.requestAnimationFrame(draw)
122
+ return
123
+ }
124
+
125
+ frameRef.current = null
126
+ context.clearRect(0, 0, canvas.width, canvas.height)
127
+ }, [])
128
+
129
+ const start = useCallback(() => {
130
+ const canvas = canvasRef.current
131
+
132
+ if (!canvas || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
133
+ return
134
+ }
135
+
136
+ const ratio = window.devicePixelRatio || 1
137
+ const { width, height } = canvas.getBoundingClientRect()
138
+
139
+ canvas.width = Math.round(width * ratio)
140
+ canvas.height = Math.round(height * ratio)
141
+
142
+ for (let index = 0; index < count; index += 1) {
143
+ // Biased upward: paper thrown sideways at the floor never looks celebratory.
144
+ const angle = Math.PI * (1.15 + Math.random() * 0.7)
145
+ const velocity = spread * (0.45 + Math.random() * 0.55)
146
+
147
+ particlesRef.current.push({
148
+ x: origin.x * width,
149
+ y: origin.y * height,
150
+ vx: Math.cos(angle) * velocity,
151
+ vy: Math.sin(angle) * velocity,
152
+ size: 6 + Math.random() * 6,
153
+ tilt: Math.random() * Math.PI,
154
+ spin: (Math.random() - 0.5) * 0.34,
155
+ life: 1,
156
+ color: colors[index % colors.length],
157
+ })
158
+ }
159
+
160
+ if (frameRef.current === null) {
161
+ frameRef.current = window.requestAnimationFrame(draw)
162
+ }
163
+ }, [colors, count, draw, origin.x, origin.y, spread])
164
+
165
+ useImperativeHandle(ref, () => ({ fire: start }), [start])
166
+
167
+ useEffect(() => {
168
+ if (fire) {
169
+ start()
170
+ }
171
+ }, [fire, start])
172
+
173
+ useEffect(
174
+ () => () => {
175
+ if (frameRef.current !== null) {
176
+ window.cancelAnimationFrame(frameRef.current)
177
+ }
178
+ },
179
+ [],
180
+ )
181
+
182
+ return (
183
+ <canvas
184
+ ref={canvasRef}
185
+ aria-hidden="true"
186
+ className={cn('pointer-events-none absolute inset-0 z-10 size-full', className)}
187
+ {...props}
188
+ />
189
+ )
190
+ }
191
+
192
+ export default Confetti