Skip to content

Commit d3eddec

Browse files
feat: OTP email sign up/in
1 parent e8d529c commit d3eddec

File tree

4 files changed

+293
-43
lines changed

4 files changed

+293
-43
lines changed

components/AuthModal.tsx

Lines changed: 247 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useState } from "react";
3+
import React, { useState, useEffect } from "react";
44
import { useAuth } from "../contexts/AuthContext";
55
import { Button } from "./Button";
66

@@ -15,10 +15,34 @@ export const AuthModal: React.FC<AuthModalProps> = ({
1515
onClose,
1616
trialExpired = false,
1717
}) => {
18-
const { signInWithEmail, signInWithGoogle, signInWithApple } = useAuth();
18+
const { signInWithEmail, verifyEmailOTP, signInWithGoogle, signInWithApple } =
19+
useAuth();
1920
const [email, setEmail] = useState("");
21+
const [otp, setOtp] = useState("");
22+
const [userId, setUserId] = useState<string | null>(null);
2023
const [isLoading, setIsLoading] = useState(false);
24+
const [isVerifying, setIsVerifying] = useState(false);
25+
const [isRedirecting, setIsRedirecting] = useState(false);
2126
const [message, setMessage] = useState("");
27+
const [step, setStep] = useState<"email" | "otp">("email");
28+
const [lastUsedMethod, setLastUsedMethod] = useState<{
29+
type: string;
30+
value: string;
31+
} | null>(null);
32+
33+
// Load last used method from localStorage
34+
useEffect(() => {
35+
if (isOpen) {
36+
const saved = localStorage.getItem("lastUsedAuthMethod");
37+
if (saved) {
38+
try {
39+
setLastUsedMethod(JSON.parse(saved));
40+
} catch (e) {
41+
// Invalid JSON, ignore
42+
}
43+
}
44+
}
45+
}, [isOpen]);
2246

2347
if (!isOpen) return null;
2448

@@ -31,11 +55,16 @@ export const AuthModal: React.FC<AuthModalProps> = ({
3155

3256
try {
3357
const result = await signInWithEmail(email.trim());
34-
if (result.success) {
35-
setMessage("Check your email for the login link!");
36-
setEmail("");
58+
if (result.success && result.userId) {
59+
setUserId(result.userId);
60+
// Save last used method
61+
const method = { type: "email", value: email.trim() };
62+
setLastUsedMethod(method);
63+
localStorage.setItem("lastUsedAuthMethod", JSON.stringify(method));
64+
setStep("otp");
65+
setMessage("");
3766
} else {
38-
setMessage(result.error || "Failed to send login link");
67+
setMessage(result.error || "Failed to send OTP");
3968
}
4069
} catch (error) {
4170
setMessage("An error occurred. Please try again.");
@@ -44,9 +73,44 @@ export const AuthModal: React.FC<AuthModalProps> = ({
4473
}
4574
};
4675

76+
const handleOTPVerification = async (e: React.FormEvent) => {
77+
e.preventDefault();
78+
if (!otp.trim() || !userId) return;
79+
80+
setIsVerifying(true);
81+
setMessage("");
82+
83+
try {
84+
const result = await verifyEmailOTP(userId, otp.trim());
85+
if (result.success) {
86+
setIsVerifying(false);
87+
setIsRedirecting(true);
88+
setMessage("Redirecting...");
89+
setTimeout(() => {
90+
onClose();
91+
setStep("email");
92+
setEmail("");
93+
setOtp("");
94+
setUserId(null);
95+
setIsRedirecting(false);
96+
}, 2000);
97+
} else {
98+
setIsVerifying(false);
99+
setMessage(result.error || "Invalid OTP code");
100+
}
101+
} catch (error) {
102+
setIsVerifying(false);
103+
setMessage("An error occurred. Please try again.");
104+
}
105+
};
106+
47107
const handleGoogleSignIn = async () => {
48108
setIsLoading(true);
49109
try {
110+
// Save last used method
111+
const method = { type: "google", value: "Google" };
112+
setLastUsedMethod(method);
113+
localStorage.setItem("lastUsedAuthMethod", JSON.stringify(method));
50114
await signInWithGoogle();
51115
} catch (error) {
52116
setMessage("Failed to sign in with Google");
@@ -57,6 +121,10 @@ export const AuthModal: React.FC<AuthModalProps> = ({
57121
const handleAppleSignIn = async () => {
58122
setIsLoading(true);
59123
try {
124+
// Save last used method
125+
const method = { type: "apple", value: "Apple" };
126+
setLastUsedMethod(method);
127+
localStorage.setItem("lastUsedAuthMethod", JSON.stringify(method));
60128
await signInWithApple();
61129
} catch (error) {
62130
setMessage("Failed to sign in with Apple");
@@ -89,34 +157,169 @@ export const AuthModal: React.FC<AuthModalProps> = ({
89157
</div>
90158

91159
{/* Email OTP Sign In */}
92-
<form onSubmit={handleEmailSignIn} className="mb-6">
93-
<div className="mb-4">
94-
<label
95-
htmlFor="email"
96-
className="block text-sm font-medium text-slate-300 mb-2"
160+
{step === "email" ? (
161+
<form onSubmit={handleEmailSignIn} className="mb-6">
162+
<div className="mb-4">
163+
<label
164+
htmlFor="email"
165+
className="block text-sm font-medium text-slate-300 mb-2"
166+
>
167+
Email Address
168+
</label>
169+
<input
170+
type="email"
171+
id="email"
172+
value={email}
173+
onChange={(e) => setEmail(e.target.value)}
174+
placeholder="Enter your email"
175+
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
176+
required
177+
/>
178+
</div>
179+
<Button
180+
type="submit"
181+
intent="primary"
182+
size="medium"
183+
disabled={isLoading}
184+
className="w-full relative flex items-center justify-center"
97185
>
98-
Email Address
99-
</label>
100-
<input
101-
type="email"
102-
id="email"
103-
value={email}
104-
onChange={(e) => setEmail(e.target.value)}
105-
placeholder="Enter your email"
106-
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-md text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
107-
required
108-
/>
186+
{isLoading && (
187+
<svg
188+
className="animate-spin mr-2 h-4 w-4 text-white"
189+
xmlns="http://www.w3.org/2000/svg"
190+
fill="none"
191+
viewBox="0 0 24 24"
192+
>
193+
<circle
194+
className="opacity-25"
195+
cx="12"
196+
cy="12"
197+
r="10"
198+
stroke="currentColor"
199+
strokeWidth="4"
200+
></circle>
201+
<path
202+
className="opacity-75"
203+
fill="currentColor"
204+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
205+
></path>
206+
</svg>
207+
)}
208+
{isLoading ? "Sending..." : "Continue with Email"}
209+
{!isLoading && lastUsedMethod?.type === "email" && (
210+
<span className="absolute -top-1 -right-1 bg-blue-500 text-white text-xs px-2 py-1 rounded-full">
211+
Last Used
212+
</span>
213+
)}
214+
</Button>
215+
</form>
216+
) : (
217+
<div className="mb-6">
218+
<div className="text-center mb-6">
219+
<h3 className="text-xl font-semibold text-white mb-2">
220+
Verification
221+
</h3>
222+
<p className="text-slate-300 text-sm">
223+
If you have an account, we have sent a code to{" "}
224+
<span className="font-medium text-white">
225+
{lastUsedMethod?.value}
226+
</span>
227+
.
228+
<br />
229+
Enter it below.
230+
</p>
231+
</div>
232+
233+
<form onSubmit={handleOTPVerification} className="mb-4">
234+
<div className="mb-4">
235+
<div className="flex gap-2 justify-center">
236+
{[0, 1, 2, 3, 4, 5].map((index) => (
237+
<input
238+
key={index}
239+
type="text"
240+
value={otp[index] || ""}
241+
onChange={(e) => {
242+
const newOtp = otp.split("");
243+
newOtp[index] = e.target.value
244+
.replace(/\D/g, "")
245+
.slice(0, 1);
246+
setOtp(newOtp.join(""));
247+
// Auto-focus next input
248+
if (e.target.value && index < 5) {
249+
const nextInput = document.getElementById(
250+
`otp-${index + 1}`,
251+
);
252+
nextInput?.focus();
253+
}
254+
}}
255+
className="w-12 h-12 text-center text-lg font-mono bg-slate-700 border border-slate-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
256+
maxLength={1}
257+
id={`otp-${index}`}
258+
required
259+
/>
260+
))}
261+
</div>
262+
</div>
263+
264+
{(isVerifying || isRedirecting) && (
265+
<div className="text-center mb-4">
266+
<div className="flex items-center justify-center gap-2 text-white">
267+
<span>{isVerifying ? "Verifying" : "Redirecting"}</span>
268+
<svg
269+
className="animate-spin h-4 w-4"
270+
xmlns="http://www.w3.org/2000/svg"
271+
fill="none"
272+
viewBox="0 0 24 24"
273+
>
274+
<circle
275+
className="opacity-25"
276+
cx="12"
277+
cy="12"
278+
r="10"
279+
stroke="currentColor"
280+
strokeWidth="4"
281+
></circle>
282+
<path
283+
className="opacity-75"
284+
fill="currentColor"
285+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
286+
></path>
287+
</svg>
288+
</div>
289+
</div>
290+
)}
291+
292+
<Button
293+
type="submit"
294+
intent="primary"
295+
size="medium"
296+
disabled={isVerifying || isRedirecting || otp.length !== 6}
297+
className="w-full"
298+
>
299+
{isVerifying
300+
? "Verifying..."
301+
: isRedirecting
302+
? "Redirecting..."
303+
: "Verify Code"}
304+
</Button>
305+
</form>
306+
307+
<button
308+
type="button"
309+
onClick={() => {
310+
setStep("email");
311+
setOtp("");
312+
setUserId(null);
313+
setMessage("");
314+
setIsVerifying(false);
315+
setIsRedirecting(false);
316+
}}
317+
className="w-full text-center text-sm text-blue-400 hover:text-blue-300"
318+
>
319+
← Back
320+
</button>
109321
</div>
110-
<Button
111-
type="submit"
112-
intent="primary"
113-
size="medium"
114-
disabled={isLoading}
115-
className="w-full"
116-
>
117-
{isLoading ? "Sending..." : "Send Login Link"}
118-
</Button>
119-
</form>
322+
)}
120323

121324
{/* Divider */}
122325
<div className="flex items-center mb-6">
@@ -133,7 +336,7 @@ export const AuthModal: React.FC<AuthModalProps> = ({
133336
size="medium"
134337
onClick={handleGoogleSignIn}
135338
disabled={isLoading}
136-
className="w-full flex items-center justify-center gap-3"
339+
className="w-full flex items-center justify-center gap-3 relative"
137340
>
138341
<svg className="w-5 h-5" viewBox="0 0 24 24">
139342
<path
@@ -154,6 +357,11 @@ export const AuthModal: React.FC<AuthModalProps> = ({
154357
/>
155358
</svg>
156359
Continue with Google
360+
{!isLoading && lastUsedMethod?.type === "google" && (
361+
<span className="absolute -top-1 -right-1 bg-blue-500 text-white text-xs px-2 py-1 rounded-full">
362+
Last Used
363+
</span>
364+
)}
157365
</Button>
158366

159367
<Button
@@ -162,7 +370,7 @@ export const AuthModal: React.FC<AuthModalProps> = ({
162370
size="medium"
163371
onClick={handleAppleSignIn}
164372
disabled={isLoading}
165-
className="w-full flex items-center justify-center gap-3"
373+
className="w-full flex items-center justify-center gap-3 relative"
166374
>
167375
<svg className="w-5 h-5" viewBox="0 0 24 24">
168376
<path
@@ -171,6 +379,11 @@ export const AuthModal: React.FC<AuthModalProps> = ({
171379
/>
172380
</svg>
173381
Continue with Apple
382+
{!isLoading && lastUsedMethod?.type === "apple" && (
383+
<span className="absolute -top-1 -right-1 bg-blue-500 text-white text-xs px-2 py-1 rounded-full">
384+
Last Used
385+
</span>
386+
)}
174387
</Button>
175388
</div>
176389

0 commit comments

Comments
 (0)