- Created complete Next.js 15 teaser website with CHORUS brand styling - Implemented mobile-responsive 3D logo (128px mobile, 512px desktop) - Added proper Exo font loading via Next.js Google Fonts for iOS/Chrome compatibility - Built comprehensive early access form with GDPR compliance and rate limiting - Integrated PostgreSQL database with complete schema for lead capture - Added scroll indicators that auto-hide when scrolling begins - Optimized mobile modal forms with proper scrolling and submit button access - Deployed via Docker Swarm with Traefik SSL termination at chorus.services - Includes database migrations, consent tracking, and email notifications 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
282 lines
8.7 KiB
TypeScript
282 lines
8.7 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { query, transaction } from '../../../lib/db'
|
|
import nodemailer from 'nodemailer'
|
|
|
|
// Enhanced validation schema for early access leads
|
|
const EarlyAccessSchema = z.object({
|
|
firstName: z.string().min(1, 'First name is required').max(50),
|
|
lastName: z.string().min(1, 'Last name is required').max(50),
|
|
email: z.string().email('Invalid email address'),
|
|
companyName: z.string().optional(),
|
|
companyRole: z.string().optional(),
|
|
interestLevel: z.enum(['technical_evaluation', 'strategic_demo', 'general_interest']),
|
|
gdprConsent: z.boolean().refine(val => val === true, 'GDPR consent is required'),
|
|
marketingConsent: z.boolean().default(false),
|
|
leadSource: z.enum(['early_access_waitlist', 'request_early_access']),
|
|
countryCode: z.string().default('AU'),
|
|
customMessage: z.string().optional(),
|
|
})
|
|
|
|
// Database-based rate limiting
|
|
async function checkDatabaseRateLimit(clientIP: string, email: string): Promise<void> {
|
|
const now = new Date()
|
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
|
|
|
|
// Check IP-based rate limit (5 per hour)
|
|
const ipAttempts = await query(`
|
|
SELECT COUNT(*) as count FROM rate_limits
|
|
WHERE identifier = $1 AND identifier_type = 'ip'
|
|
AND window_start > $2 AND (blocked_until IS NULL OR blocked_until < NOW())
|
|
`, [clientIP, oneHourAgo])
|
|
|
|
if (parseInt(ipAttempts[0].count) >= 5) {
|
|
throw new Error('Too many requests from this IP address. Please try again later.')
|
|
}
|
|
|
|
// Check email-based rate limit (3 per day)
|
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
const emailAttempts = await query(`
|
|
SELECT COUNT(*) as count FROM rate_limits
|
|
WHERE identifier = $1 AND identifier_type = 'email'
|
|
AND window_start > $2 AND (blocked_until IS NULL OR blocked_until < NOW())
|
|
`, [email, oneDayAgo])
|
|
|
|
if (parseInt(emailAttempts[0].count) >= 3) {
|
|
throw new Error('This email has been used too frequently. Please try again tomorrow.')
|
|
}
|
|
|
|
// Record this attempt (insert without conflict handling for now)
|
|
try {
|
|
await query(`
|
|
INSERT INTO rate_limits (identifier, identifier_type, action_type, attempt_count)
|
|
VALUES ($1, 'ip', 'form_submission', 1)
|
|
`, [clientIP])
|
|
} catch (e) {
|
|
// Ignore duplicate key errors for now
|
|
}
|
|
|
|
try {
|
|
await query(`
|
|
INSERT INTO rate_limits (identifier, identifier_type, action_type, attempt_count)
|
|
VALUES ($1, 'email', 'form_submission', 1)
|
|
`, [email])
|
|
} catch (e) {
|
|
// Ignore duplicate key errors for now
|
|
}
|
|
}
|
|
|
|
// Email configuration
|
|
const createTransporter = () => {
|
|
return nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST,
|
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
|
secure: process.env.SMTP_SECURE === 'true',
|
|
auth: {
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Send notification email
|
|
async function sendNotificationEmail(leadData: any, leadId: string) {
|
|
if (!process.env.SMTP_USER || !process.env.NOTIFICATION_TO_EMAIL) {
|
|
console.log('Email notifications not configured, skipping...')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const transporter = createTransporter()
|
|
|
|
const emailContent = `
|
|
New ${leadData.leadSource === 'request_early_access' ? 'Early Access Request' : 'Waitlist Signup'} - CHORUS Teaser Website
|
|
|
|
Lead ID: ${leadId}
|
|
Name: ${leadData.firstName} ${leadData.lastName}
|
|
Email: ${leadData.email}
|
|
Company: ${leadData.companyName || 'Not provided'}
|
|
Role: ${leadData.companyRole || 'Not provided'}
|
|
Interest Level: ${leadData.interestLevel}
|
|
Lead Source: ${leadData.leadSource}
|
|
|
|
GDPR Consent: ${leadData.gdprConsent ? 'Given' : 'Not given'}
|
|
Marketing Consent: ${leadData.marketingConsent ? 'Given' : 'Not given'}
|
|
|
|
Custom Message: ${leadData.customMessage || 'None'}
|
|
|
|
Timestamp: ${new Date().toISOString()}
|
|
IP Address: ${leadData.clientIP}
|
|
User Agent: ${leadData.userAgent}
|
|
`
|
|
|
|
await transporter.sendMail({
|
|
from: process.env.NOTIFICATION_FROM_EMAIL,
|
|
to: process.env.NOTIFICATION_TO_EMAIL,
|
|
subject: `New CHORUS ${leadData.leadSource === 'request_early_access' ? 'Early Access Request' : 'Waitlist Signup'}`,
|
|
text: emailContent,
|
|
})
|
|
|
|
console.log('Notification email sent successfully')
|
|
} catch (error) {
|
|
console.error('Failed to send notification email:', error)
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
// Parse request body
|
|
const body = await request.json()
|
|
|
|
// Validate input
|
|
const validatedData = EarlyAccessSchema.parse(body)
|
|
|
|
// Get client IP for rate limiting
|
|
const clientIP = request.headers.get('x-forwarded-for') ||
|
|
request.headers.get('x-real-ip') ||
|
|
'127.0.0.1'
|
|
|
|
const userAgent = request.headers.get('user-agent') || ''
|
|
|
|
// Check rate limits using database
|
|
await checkDatabaseRateLimit(clientIP, validatedData.email)
|
|
|
|
// Insert lead into database using transaction
|
|
const leadId = await transaction(async (client) => {
|
|
// Insert the main lead record
|
|
const leadResult = await client.query(`
|
|
INSERT INTO leads (
|
|
first_name, last_name, email, company_name, company_role,
|
|
lead_source, inquiry_details, custom_message, ip_address, user_agent,
|
|
country_code, gdpr_consent_given, gdpr_consent_date, marketing_consent
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING id
|
|
`, [
|
|
validatedData.firstName,
|
|
validatedData.lastName,
|
|
validatedData.email,
|
|
validatedData.companyName || null,
|
|
validatedData.companyRole || null,
|
|
validatedData.leadSource,
|
|
validatedData.interestLevel,
|
|
validatedData.customMessage || null,
|
|
clientIP,
|
|
userAgent,
|
|
validatedData.countryCode,
|
|
validatedData.gdprConsent,
|
|
validatedData.gdprConsent ? new Date() : null, // gdpr_consent_date
|
|
validatedData.marketingConsent
|
|
])
|
|
|
|
const leadId = leadResult.rows[0].id
|
|
|
|
// Insert consent audit record
|
|
await client.query(`
|
|
INSERT INTO consent_audit (
|
|
lead_id, consent_type, consent_status, consent_method, ip_address, user_agent
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, [
|
|
leadId,
|
|
'gdpr_consent',
|
|
'given',
|
|
'web_form',
|
|
clientIP,
|
|
userAgent
|
|
])
|
|
|
|
// If marketing consent given, record that too
|
|
if (validatedData.marketingConsent) {
|
|
await client.query(`
|
|
INSERT INTO consent_audit (
|
|
lead_id, consent_type, consent_status, consent_method, ip_address, user_agent
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, [
|
|
leadId,
|
|
'marketing_consent',
|
|
'given',
|
|
'web_form',
|
|
clientIP,
|
|
userAgent
|
|
])
|
|
}
|
|
|
|
return leadId
|
|
})
|
|
|
|
// Send notification email (async, don't block response)
|
|
sendNotificationEmail({
|
|
...validatedData,
|
|
clientIP,
|
|
userAgent
|
|
}, leadId).catch(console.error)
|
|
|
|
// Log successful capture
|
|
console.log('Lead successfully captured:', {
|
|
leadId,
|
|
email: validatedData.email,
|
|
leadSource: validatedData.leadSource,
|
|
timestamp: new Date().toISOString()
|
|
})
|
|
|
|
// Send success response
|
|
const message = validatedData.leadSource === 'request_early_access'
|
|
? 'Early access request submitted successfully!'
|
|
: 'Successfully joined the CHORUS waitlist!'
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message,
|
|
leadId,
|
|
}, { status: 201 })
|
|
|
|
} catch (error) {
|
|
console.error('Early access signup error:', error)
|
|
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Validation error',
|
|
details: error.errors.map(err => ({
|
|
field: err.path.join('.'),
|
|
message: err.message
|
|
}))
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
// Check for specific database errors
|
|
if (error.message.includes('duplicate key')) {
|
|
return NextResponse.json(
|
|
{ error: 'This email has already been registered.' },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
if (error.message.includes('rate limit')) {
|
|
return NextResponse.json(
|
|
{ error: error.message },
|
|
{ status: 429 }
|
|
)
|
|
}
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error. Please try again.' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Handle OPTIONS for CORS
|
|
export async function OPTIONS(request: NextRequest) {
|
|
return new NextResponse(null, {
|
|
status: 200,
|
|
headers: {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
},
|
|
})
|
|
} |