@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.
- package/.turbo/turbo-build.log +24 -0
- package/.turbo/turbo-test.log +10 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -0
- package/README.md +161 -0
- package/components.json +33 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/contracts.d.mts +563 -0
- package/dist/contracts.d.ts +563 -0
- package/dist/contracts.js +207 -0
- package/dist/contracts.js.map +1 -0
- package/dist/contracts.mjs +168 -0
- package/dist/contracts.mjs.map +1 -0
- package/dist/index.d.mts +2408 -0
- package/dist/index.d.ts +2408 -0
- package/dist/index.js +16422 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +16352 -0
- package/dist/index.mjs.map +1 -0
- package/docs/standards/data-contracts.md +70 -0
- package/docs/standards/theming.md +85 -0
- package/package.json +62 -0
- package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
- package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
- package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
- package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
- package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
- package/src/blocks/application/verification/verify-1.tsx +307 -0
- package/src/blocks/application/verification/verify-confetti.tsx +192 -0
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
- package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
- package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
- package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
- package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
- package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
- package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
- package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
- package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
- package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
- package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
- package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
- package/src/blocks/marketing/banners/banner-1.tsx +188 -0
- package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
- package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
- package/src/blocks/marketing/features/feature-1.tsx +97 -0
- package/src/blocks/marketing/features/feature-2.tsx +82 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
- package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
- package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
- package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
- package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
- package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
- package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
- package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
- package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
- package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
- package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
- package/src/blocks/shared/reaction-toggle.tsx +145 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +200 -0
- package/src/lib/contracts.test.ts +32 -0
- package/src/lib/contracts.ts +235 -0
- package/src/lib/types/menu.ts +43 -0
- package/src/lib/types.ts +4 -0
- package/src/lib/utils.ts +10 -0
- package/src/ui/accordion.tsx +65 -0
- package/src/ui/avatar.tsx +108 -0
- package/src/ui/badge.tsx +47 -0
- package/src/ui/breadcrumb.tsx +108 -0
- package/src/ui/button-group.tsx +83 -0
- package/src/ui/button.tsx +63 -0
- package/src/ui/card.tsx +91 -0
- package/src/ui/carousel.tsx +241 -0
- package/src/ui/dialog.tsx +158 -0
- package/src/ui/drawer.tsx +134 -0
- package/src/ui/dropdown-menu.tsx +256 -0
- package/src/ui/empty.tsx +103 -0
- package/src/ui/field.tsx +248 -0
- package/src/ui/input-group.tsx +170 -0
- package/src/ui/input-otp.tsx +76 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/label.tsx +23 -0
- package/src/ui/navigation-menu.tsx +167 -0
- package/src/ui/pagination.tsx +127 -0
- package/src/ui/progress.tsx +30 -0
- package/src/ui/radio-group.tsx +44 -0
- package/src/ui/select.tsx +189 -0
- package/src/ui/separator.tsx +27 -0
- package/src/ui/sheet.tsx +142 -0
- package/src/ui/sidebar.tsx +726 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/table.tsx +115 -0
- package/src/ui/tabs.tsx +90 -0
- package/src/ui/textarea.tsx +17 -0
- package/src/ui/toggle-group.tsx +83 -0
- package/src/ui/toggle.tsx +46 -0
- package/src/ui/tooltip.tsx +56 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +16 -0
- 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
|