feat: Add CHORUS teaser website with mobile-responsive design
- 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>
This commit is contained in:
282
modules/teaser/app/api/early-access/route.ts
Normal file
282
modules/teaser/app/api/early-access/route.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
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',
|
||||
},
|
||||
})
|
||||
}
|
||||
22
modules/teaser/app/api/health/route.ts
Normal file
22
modules/teaser/app/api/health/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { healthCheck } from '../../../lib/db'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const dbHealth = await healthCheck()
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'chorus-teaser-website',
|
||||
database: dbHealth
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'chorus-teaser-website',
|
||||
error: 'Database connection failed'
|
||||
}, { status: 503 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user