diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..9d6cf24 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react" +import SignupWizard from "@/components/auth/signup-wizard" + +export default function SignupPage() { + return ( + + + + ) +} diff --git a/components/auth/otp-verification-step.tsx b/components/auth/otp-verification-step.tsx new file mode 100644 index 0000000..cc6fa6c --- /dev/null +++ b/components/auth/otp-verification-step.tsx @@ -0,0 +1,146 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { ArrowLeft, Loader2, Mail } from "lucide-react" +import { authClient } from "@/lib/auth-client" +import OtpInput from "./otp-input" + +interface OtpVerificationStepProps { + email: string + onSuccess: () => void + onBack: () => void +} + +export default function OtpVerificationStep({ + email, + onSuccess, + onBack, +}: OtpVerificationStepProps) { + const [otp, setOtp] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [resendTimer, setResendTimer] = useState(300) // 5 minutes + const [canResend, setCanResend] = useState(false) + + // Countdown timer + useEffect(() => { + if (resendTimer <= 0) { + setCanResend(true) + return + } + const interval = setInterval(() => { + setResendTimer((prev) => prev - 1) + }, 1000) + return () => clearInterval(interval) + }, [resendTimer]) + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, "0")}` + } + + const handleVerify = useCallback(async () => { + if (otp.length !== 6) return + + setIsLoading(true) + setError("") + + const { error } = await authClient.emailOtp.verifyEmail({ + email, + otp, + }) + + setIsLoading(false) + + if (error) { + setError( + error.code === "TOO_MANY_ATTEMPTS" + ? "Too many attempts. Please request a new code." + : "Invalid code. Please try again.", + ) + return + } + + onSuccess() + }, [otp, email, onSuccess]) + + // Auto-submit when all digits entered + useEffect(() => { + if (otp.length === 6) { + handleVerify() + } + }, [otp, handleVerify]) + + const handleResend = async () => { + setError("") + await authClient.emailOtp.sendVerificationOtp({ + email, + type: "email-verification", + }) + setResendTimer(300) + setCanResend(false) + } + + return ( +
+
+ +

+ Verify your email +

+

+ We sent a 6-digit code to{" "} + {email} +

+
+ +
+ + +
+ +
+ {canResend ? ( + + ) : ( + + Resend code in{" "} + + {formatTime(resendTimer)} + + + )} +
+ + + + +
+ ) +} diff --git a/components/auth/passkey-setup-step.tsx b/components/auth/passkey-setup-step.tsx new file mode 100644 index 0000000..6ef47d1 --- /dev/null +++ b/components/auth/passkey-setup-step.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useState } from "react" +import { KeyRound, Check, Loader2, Fingerprint } from "lucide-react" +import { authClient } from "@/lib/auth-client" + +interface PasskeySetupStepProps { + onSuccess: () => void + onSkip: () => void +} + +export default function PasskeySetupStep({ + onSuccess, + onSkip, +}: PasskeySetupStepProps) { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState("") + + const isPasskeySupported = + typeof window !== "undefined" && + "PublicKeyCredential" in window + + const handleAddPasskey = async () => { + setIsLoading(true) + setError("") + + const { error } = await authClient.passkey.addPasskey({ + name: "Primary passkey", + }) + + setIsLoading(false) + + if (error) { + setError( + error.message || + "Failed to set up passkey. You can try again later.", + ) + return + } + + onSuccess() + } + + return ( +
+
+ +

+ Set up a passkey +

+

+ Sign in faster with biometrics or your device's security + key. No password needed. +

+
+ + {/* Benefits list */} +
+
+ + Faster sign-in with fingerprint or face +
+
+ + More secure than passwords +
+
+ + Works across your devices +
+
+ + {error && ( +
+ {error} +
+ )} + + {isPasskeySupported ? ( + <> + + + + ) : ( +
+

+ Passkeys are not supported on this device. +

+ +
+ )} +
+ ) +} diff --git a/components/auth/signup-form-step.tsx b/components/auth/signup-form-step.tsx new file mode 100644 index 0000000..08c6358 --- /dev/null +++ b/components/auth/signup-form-step.tsx @@ -0,0 +1,152 @@ +"use client" + +import { useState } from "react" +import { Loader2 } from "lucide-react" +import { authClient } from "@/lib/auth-client" +import { signupSchema, type SignupInput } from "@/lib/auth/validation" +import SocialButtons from "./social-buttons" +import PasswordStrengthMeter from "./password-strength" +import Link from "next/link" + +interface SignupFormStepProps { + onSuccess: (email: string) => void +} + +export default function SignupFormStep({ onSuccess }: SignupFormStepProps) { + const [name, setName] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [errors, setErrors] = useState>({}) + const [serverError, setServerError] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setErrors({}) + setServerError("") + + const result = signupSchema.safeParse({ name, email, password }) + if (!result.success) { + const fieldErrors: Record = {} + result.error.issues.forEach((issue) => { + const field = issue.path[0] as string + fieldErrors[field] = issue.message + }) + setErrors(fieldErrors) + return + } + + setIsLoading(true) + const { error } = await authClient.signUp.email({ + name, + email, + password, + }) + setIsLoading(false) + + if (error) { + setServerError( + error.message || "Something went wrong. Please try again.", + ) + return + } + + onSuccess(email) + } + + return ( +
+
+

+ Create your account +

+

+ Choose how you'd like to sign up +

+
+ + + +
+
+ + or use email + +
+
+ + {serverError && ( +
+ {serverError} +
+ )} + +
+ + setName(e.target.value)} + placeholder="Your name" + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> + {errors.name && ( +

{errors.name}

+ )} +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + setPassword(e.target.value)} + placeholder="Min. 10 characters" + className="w-full px-3 py-2.5 rounded-lg border border-white/10 bg-white/[0.03] text-[#ebe4f1] text-sm placeholder:text-[#ebe4f1]/40 outline-none focus:border-[#eb3779] focus:ring-2 focus:ring-[#eb3779]/50 transition-colors" + /> + {errors.password && ( +

+ {errors.password} +

+ )} + +
+ + + +

+ Already have an account?{" "} + + Sign in + +

+ + ) +} diff --git a/components/auth/signup-wizard.tsx b/components/auth/signup-wizard.tsx new file mode 100644 index 0000000..46e0bad --- /dev/null +++ b/components/auth/signup-wizard.tsx @@ -0,0 +1,74 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import StepIndicator from "./step-indicator" +import SignupFormStep from "./signup-form-step" +import OtpVerificationStep from "./otp-verification-step" +import PasskeySetupStep from "./passkey-setup-step" + +export default function SignupWizard() { + const router = useRouter() + const searchParams = useSearchParams() + const stepParam = searchParams.get("step") + + const [step, setStep] = useState<1 | 2 | 3>(() => { + if (stepParam === "otp") return 2 + if (stepParam === "passkey") return 3 + return 1 + }) + const [email, setEmail] = useState("") + + // Update URL when step changes + useEffect(() => { + const params = new URLSearchParams() + if (step === 2) params.set("step", "otp") + if (step === 3) params.set("step", "passkey") + const query = params.toString() + router.replace(`/signup${query ? `?${query}` : ""}`, { + scroll: false, + }) + }, [step, router]) + + const handleSignupSuccess = (userEmail: string) => { + setEmail(userEmail) + setStep(2) + } + + const handleOtpSuccess = () => { + setStep(3) + } + + const handlePasskeyComplete = () => { + router.push("/") + } + + const handlePasskeySkip = () => { + router.push("/") + } + + return ( +
+ + + {step === 1 && ( + + )} + + {step === 2 && ( + setStep(1)} + /> + )} + + {step === 3 && ( + + )} +
+ ) +}