diff --git a/docs/email-verification.md b/docs/email-verification.md new file mode 100644 index 0000000..b41e71f --- /dev/null +++ b/docs/email-verification.md @@ -0,0 +1,131 @@ +# Email Verification System + +This document describes the email verification system implemented for the SwapTrade waitlist signup process. + +## Overview + +The email verification system ensures that users provide valid email addresses and helps prevent spam signups. When users sign up for the waitlist, they receive a verification email with a unique token that they must click to activate their entry. + +## Features + +- **Secure Token Generation**: Uses JWT or random UUID for verification tokens +- **Token Expiration**: Tokens expire after a configurable period (default: 24 hours) +- **Email Templates**: Branded email templates matching the SwapTrade design +- **Error Handling**: Graceful handling of expired or invalid tokens +- **Resend Functionality**: Users can request new verification emails +- **Status Tracking**: User verification status is tracked in the database + +## Components + +### Frontend Components + +#### Pages +- `/signup` - Waitlist signup form +- `/verify` - Email verification page +- `/resend-verification` - Resend verification email page + +#### Components +- `VerificationStatus` - Displays verification status with appropriate icons and messages + +#### Hooks +- `useEmailVerification` - Custom hook for managing verification state and API calls + +#### Types +- `WaitlistUser` - User data structure +- `VerificationToken` - Token data structure +- `EmailVerificationRequest/Response` - API request/response types + +### API Endpoints (Backend) + +The following API endpoints need to be implemented in the backend: + +#### POST `/api/waitlist/signup` +- Accepts: `{ email: string }` +- Sends verification email with unique token +- Returns: Success/error message + +#### POST `/api/verify-email` +- Accepts: `{ token: string }` +- Validates token and updates user status to "verified" +- Returns: Success/error message + +#### POST `/api/resend-verification` +- Accepts: `{ email: string }` +- Sends new verification email +- Returns: Success/error message + +## User Flow + +1. User visits `/signup` and enters their email +2. System generates verification token and sends email +3. User receives email with verification link: `/verify?token=` +4. User clicks link, system validates token +5. If valid, user status becomes "verified" +6. If expired/invalid, user can request new verification email + +## Email Template + +The verification email should include: +- SwapTrade branding +- Clear call-to-action button +- Verification link with token +- Expiration notice +- Contact information for support + +## Security Considerations + +- Tokens should be cryptographically secure +- Tokens should expire after reasonable time (24 hours) +- Rate limiting on signup and resend endpoints +- Input validation and sanitization +- HTTPS required for all verification links + +## Error Handling + +- Invalid tokens: Display error message with option to resend +- Expired tokens: Display expiration message with resend option +- Network errors: Retry mechanism and user-friendly messages +- Duplicate signups: Handle gracefully without sending multiple emails + +## Integration + +### Email Service Provider +- SendGrid, Mailgun, or similar service +- Template management for branded emails +- Delivery tracking and analytics + +### Database Schema +```sql +-- Users table +CREATE TABLE waitlist_users ( + id UUID PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + status ENUM('pending', 'verified', 'expired') DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + verified_at TIMESTAMP NULL +); + +-- Verification tokens table +CREATE TABLE verification_tokens ( + token VARCHAR(255) PRIMARY KEY, + email VARCHAR(255) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +## Testing + +- Unit tests for token generation and validation +- Integration tests for email sending +- E2E tests for complete verification flow +- Edge cases: expired tokens, invalid tokens, network failures + +## Future Enhancements + +- Email preference management +- Bulk verification for enterprise users +- Analytics and conversion tracking +- Multi-language support for emails +- Advanced spam prevention measures \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a6e677d..4b69a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "swaptrade-frontend", "version": "0.1.0", "dependencies": { + "@heroicons/react": "^2.2.0", "next": "15.3.1", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -209,6 +210,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/package.json b/package.json index d9dd677..1832f46 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@heroicons/react": "^2.2.0", "next": "15.3.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/app/resend-verification/page.tsx b/src/app/resend-verification/page.tsx new file mode 100644 index 0000000..2fee318 --- /dev/null +++ b/src/app/resend-verification/page.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useEmailVerification } from '@/hooks/useEmailVerification'; +import VerificationStatus from '@/components/verification/VerificationStatus'; + +export default function ResendVerificationPage() { + const [email, setEmail] = useState(''); + const { isLoading, resendVerification } = useEmailVerification(); + const [message, setMessage] = useState(''); + const [messageType, setMessageType] = useState<'success' | 'error'>('success'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setMessage(''); + + const result = await resendVerification(email); + + setMessageType(result.success ? 'success' : 'error'); + setMessage(result.message); + + if (result.success) { + setEmail(''); + } + }; + + return ( +
+
+
+ + SwapTrade + +

+ Resend Verification Email +

+

+ Enter your email address to receive a new verification link +

+
+
+ +
+
+
+
+ +
+ setEmail(e.target.value)} + className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-[#16a34a] focus:border-[#16a34a] sm:text-sm" + placeholder="Enter your email" + /> +
+
+ +
+ +
+
+ + {message && ( +
+ +
+ )} + +
+
+
+
+
+
+ Remember your verification link? +
+
+ +
+ + Back to Signup + + + Back to Home + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..3bc01ca --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; + +export default function SignupPage() { + const [email, setEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [message, setMessage] = useState(''); + const [messageType, setMessageType] = useState<'success' | 'error'>('success'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + setMessage(''); + + try { + const response = await fetch('/api/waitlist/signup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }); + + const data = await response.json(); + + if (response.ok) { + setMessageType('success'); + setMessage('Check your email for a verification link to complete your signup!'); + setEmail(''); + } else { + setMessageType('error'); + setMessage(data.message || 'Something went wrong. Please try again.'); + } + } catch { + setMessageType('error'); + setMessage('Network error. Please check your connection and try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ + SwapTrade + +

+ Join the Waitlist +

+

+ Be the first to experience risk-free crypto trading +

+
+
+ +
+
+
+
+ +
+ setEmail(e.target.value)} + className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-[#16a34a] focus:border-[#16a34a] sm:text-sm" + placeholder="Enter your email" + /> +
+
+ +
+ +
+
+ + {message && ( +
+

{message}

+
+ )} + +
+
+
+
+
+
+ Already have an account? +
+
+ +
+ + Back to Home + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/verify/page.tsx b/src/app/verify/page.tsx new file mode 100644 index 0000000..063b3b1 --- /dev/null +++ b/src/app/verify/page.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { useEmailVerification } from '@/hooks/useEmailVerification'; +import VerificationStatus from '@/components/verification/VerificationStatus'; + +function VerifyPageContent() { + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const { isLoading, isVerified, error, verifyEmail } = useEmailVerification(); + + useEffect(() => { + if (token && !isLoading && !isVerified && !error) { + verifyEmail(token); + } + }, [token, verifyEmail, isLoading, isVerified, error]); + + const handleResend = async () => { + // For resend, we'd need the email, but since we don't have it in the URL, + // redirect to resend page + window.location.href = '/resend-verification'; + }; + + if (!token) { + return ( +
+
+
+ + SwapTrade + +

+ Email Verification +

+
+
+ +
+
+ +
+ + Resend verification email + +
+
+
+
+ ); + } + + return ( +
+
+
+ + SwapTrade + +

+ Email Verification +

+
+
+ +
+
+ {isLoading && ( +
+
+

Verifying your email...

+
+ )} + + {isVerified && ( +
+ + + Back to Home + +
+ )} + + {error && ( +
+ + + Back to Signup + +
+ )} +
+
+
+ ); +} + +export default function VerifyPage() { + return ( + +
+
+
+

Loading...

+
+
+
+ }> + + + ); +} \ No newline at end of file diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 4cb37df..936e01e 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -1,5 +1,4 @@ import Link from 'next/link'; -import Image from 'next/image'; export default function Hero() { return ( diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index afa971f..f42fd55 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -5,10 +5,10 @@ import { useTheme } from "./context/ThemeContext"; import { BsSun, BsMoon } from "react-icons/bs"; interface NavbarProps { - currentPath: string; + currentPath?: string; } -const Navbar: React.FC = ({ currentPath }) => { +const Navbar: React.FC = ({ currentPath = '/' }) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const { isDarkMode, toggleDarkMode } = useTheme(); diff --git a/src/components/verification/VerificationStatus.tsx b/src/components/verification/VerificationStatus.tsx new file mode 100644 index 0000000..2807b15 --- /dev/null +++ b/src/components/verification/VerificationStatus.tsx @@ -0,0 +1,68 @@ +import { AiOutlineCheckCircle, AiOutlineCloseCircle, AiOutlineClockCircle } from 'react-icons/ai'; + +interface VerificationStatusProps { + status: 'pending' | 'verified' | 'expired' | 'error'; + message: string; + onResend?: () => void; +} + +export default function VerificationStatus({ status, message, onResend }: VerificationStatusProps) { + const getStatusConfig = () => { + switch (status) { + case 'verified': + return { + icon: AiOutlineCheckCircle, + bgColor: 'bg-green-50', + textColor: 'text-green-800', + iconColor: 'text-green-400', + }; + case 'error': + return { + icon: AiOutlineCloseCircle, + bgColor: 'bg-red-50', + textColor: 'text-red-800', + iconColor: 'text-red-400', + }; + case 'expired': + return { + icon: AiOutlineClockCircle, + bgColor: 'bg-yellow-50', + textColor: 'text-yellow-800', + iconColor: 'text-yellow-400', + }; + default: + return { + icon: AiOutlineClockCircle, + bgColor: 'bg-blue-50', + textColor: 'text-blue-800', + iconColor: 'text-blue-400', + }; + } + }; + + const config = getStatusConfig(); + const Icon = config.icon; + + return ( +
+
+
+
+
+

{message}

+ {status === 'expired' && onResend && ( +
+ +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/verification/index.ts b/src/components/verification/index.ts new file mode 100644 index 0000000..0449331 --- /dev/null +++ b/src/components/verification/index.ts @@ -0,0 +1 @@ +export { default } from './VerificationStatus'; \ No newline at end of file diff --git a/src/hooks/useEmailVerification.ts b/src/hooks/useEmailVerification.ts new file mode 100644 index 0000000..e647d46 --- /dev/null +++ b/src/hooks/useEmailVerification.ts @@ -0,0 +1,77 @@ +import { useState } from 'react'; + +export interface VerificationState { + isLoading: boolean; + isVerified: boolean; + error: string | null; +} + +export function useEmailVerification() { + const [state, setState] = useState({ + isLoading: false, + isVerified: false, + error: null, + }); + + const verifyEmail = async (token: string) => { + setState({ isLoading: true, isVerified: false, error: null }); + + try { + const response = await fetch('/api/verify-email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }); + + const data = await response.json(); + + if (response.ok) { + setState({ isLoading: false, isVerified: true, error: null }); + return { success: true, message: data.message }; + } else { + setState({ isLoading: false, isVerified: false, error: data.message }); + return { success: false, message: data.message }; + } + } catch { + const errorMessage = 'Network error. Please try again.'; + setState({ isLoading: false, isVerified: false, error: errorMessage }); + return { success: false, message: errorMessage }; + } + }; + + const resendVerification = async (email: string) => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await fetch('/api/resend-verification', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }); + + const data = await response.json(); + + if (response.ok) { + setState(prev => ({ ...prev, isLoading: false })); + return { success: true, message: data.message }; + } else { + setState(prev => ({ ...prev, isLoading: false, error: data.message })); + return { success: false, message: data.message }; + } + } catch { + const errorMessage = 'Network error. Please try again.'; + setState(prev => ({ ...prev, isLoading: false, error: errorMessage })); + return { success: false, message: errorMessage }; + } + }; + + return { + ...state, + verifyEmail, + resendVerification, + }; +} \ No newline at end of file diff --git a/src/types/emailVerification.ts b/src/types/emailVerification.ts new file mode 100644 index 0000000..b3c7b13 --- /dev/null +++ b/src/types/emailVerification.ts @@ -0,0 +1,33 @@ +export interface WaitlistUser { + id: string; + email: string; + status: 'pending' | 'verified' | 'expired'; + createdAt: Date; + verifiedAt?: Date; +} + +export interface VerificationToken { + token: string; + email: string; + expiresAt: Date; + used: boolean; +} + +export interface EmailVerificationRequest { + email: string; +} + +export interface EmailVerificationResponse { + success: boolean; + message: string; + user?: WaitlistUser; +} + +export interface ResendVerificationRequest { + email: string; +} + +export interface ResendVerificationResponse { + success: boolean; + message: string; +} \ No newline at end of file