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 })
|
||||
}
|
||||
}
|
||||
323
modules/teaser/app/globals.css
Normal file
323
modules/teaser/app/globals.css
Normal file
@@ -0,0 +1,323 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* CHORUS Proportional Typography System - 16px Base */
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* CHORUS Brand CSS Variables */
|
||||
:root {
|
||||
/* Core Brand Colors */
|
||||
--color-carbon: #000000;
|
||||
--color-mulberry: #0b0213;
|
||||
--color-walnut: #403730;
|
||||
--color-nickel: #c1bfb1;
|
||||
|
||||
/* CHORUS Semantic Color Tokens - 8 Color System */
|
||||
--chorus-primary: #0b0213; /* mulberry-950 */
|
||||
--chorus-secondary: #000000; /* carbon-950 */
|
||||
--chorus-accent: #403730; /* walnut-900 */
|
||||
--chorus-neutral: #c1bfb1; /* nickel-400 */
|
||||
--chorus-info: #3a4654; /* ocean-900 */
|
||||
--chorus-success: #3a4540; /* eucalyptus-900 */
|
||||
--chorus-warning: #99886E; /* sand-900 */
|
||||
--chorus-danger: #7B5D5A; /* coral-900 */
|
||||
|
||||
/* Dark Theme Variables (Primary for teaser) */
|
||||
--bg-primary: #000000; /* carbon-950 */
|
||||
--bg-secondary: #0b0213; /* mulberry-950 */
|
||||
--bg-tertiary: #1a1426; /* mulberry-900 */
|
||||
--bg-accent: #2a2639; /* mulberry-800 */
|
||||
|
||||
--text-primary: #FFFFFF; /* white */
|
||||
--text-secondary: #f0f4ff; /* mulberry-50 */
|
||||
--text-tertiary: #dae4fe; /* mulberry-100 */
|
||||
--text-subtle: #9aa0b8; /* mulberry-300 */
|
||||
--text-ghost: #7a7e95; /* mulberry-400 */
|
||||
|
||||
--border-invisible: #0a0a0a; /* carbon-900 */
|
||||
--border-subtle: #1a1a1a; /* carbon-800 */
|
||||
--border-defined: #2a2a2a; /* carbon-700 */
|
||||
--border-emphasis: #666666; /* carbon-600 */
|
||||
|
||||
--accent-primary: #0b0213; /* Mulberry */
|
||||
--accent-secondary: #403730; /* Walnut */
|
||||
--accent-system: #7a90b2; /* Ocean */
|
||||
|
||||
/* Spacing System */
|
||||
--space-micro: 0.25rem; /* 4px */
|
||||
--space-xs: 0.5rem; /* 8px */
|
||||
--space-sm: 0.75rem; /* 12px */
|
||||
--space-base: 1rem; /* 16px */
|
||||
--space-md: 1.5rem; /* 24px */
|
||||
--space-lg: 2rem; /* 32px */
|
||||
--space-xl: 3rem; /* 48px */
|
||||
--space-xxl: 4rem; /* 64px */
|
||||
}
|
||||
|
||||
/* Light Theme Variables */
|
||||
:root:not(.dark) {
|
||||
/* Light Theme Colors */
|
||||
--bg-primary: #FFFFFF; /* white */
|
||||
--bg-secondary: #f8f8f8; /* carbon-50 */
|
||||
--bg-tertiary: #f0f0f0; /* carbon-100 */
|
||||
--bg-accent: #e0e0e0; /* carbon-200 */
|
||||
|
||||
--text-primary: #000000; /* carbon-950 */
|
||||
--text-secondary: #1a1a1a; /* carbon-800 */
|
||||
--text-tertiary: #2a2a2a; /* carbon-700 */
|
||||
--text-subtle: #666666; /* carbon-600 */
|
||||
--text-ghost: #808080; /* carbon-500 */
|
||||
|
||||
--border-invisible: #f8f8f8; /* carbon-50 */
|
||||
--border-subtle: #f0f0f0; /* carbon-100 */
|
||||
--border-defined: #e0e0e0; /* carbon-200 */
|
||||
--border-emphasis: #c0c0c0; /* carbon-300 */
|
||||
|
||||
--accent-primary: #0b0213; /* Mulberry */
|
||||
--accent-secondary: #403730; /* Walnut */
|
||||
--accent-system: #7a90b2; /* Ocean */
|
||||
}
|
||||
|
||||
/* CHORUS Typography System */
|
||||
@font-face {
|
||||
font-family: 'Inter Tight';
|
||||
src: url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Exo';
|
||||
src: url('https://fonts.googleapis.com/css2?family=Exo:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inconsolata';
|
||||
src: url('https://fonts.googleapis.com/css2?family=Inconsolata:wght@200;300;400;500;600;700;800;900&display=swap');
|
||||
}
|
||||
|
||||
|
||||
/* Base Styles */
|
||||
body {
|
||||
font-family: 'Inter Tight', 'Inter', system-ui, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* CHORUS Button System */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--sand-400, #AFA28E) 0%, var(--sand-300, #DBD6CF) 100%);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--sand-400, #AFA28E);
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 300ms ease-out;
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px rgba(175, 162, 142, 0.3);
|
||||
}
|
||||
|
||||
.dark .btn-primary {
|
||||
background: linear-gradient(135deg, var(--chorus-primary) 0%, var(--chorus-accent) 100%);
|
||||
border: 2px solid var(--chorus-primary);
|
||||
box-shadow: 0 4px 12px rgba(11, 2, 19, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(175, 162, 142, 0.5);
|
||||
background: linear-gradient(135deg, var(--sand-300, #DBD6CF) 0%, var(--sand-400, #AFA28E) 100%);
|
||||
}
|
||||
|
||||
.dark .btn-primary:hover {
|
||||
box-shadow: 0 8px 24px rgba(11, 2, 19, 0.4);
|
||||
background: linear-gradient(135deg, var(--chorus-accent) 0%, var(--chorus-primary) 100%);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--border-emphasis);
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 300ms ease-out;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 16px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* CHORUS Form System */
|
||||
.form-input {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--border-defined);
|
||||
padding: 0.875rem 1rem;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 300ms ease-out;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--chorus-primary);
|
||||
box-shadow: 0 0 0 3px rgba(11, 2, 19, 0.1);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-subtle);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* CHORUS Typography Classes */
|
||||
.text-h1 {
|
||||
font-size: 4.768rem;
|
||||
line-height: 6.96rem;
|
||||
font-weight: 100;
|
||||
font-family: 'Exo', 'Inter Tight', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.text-h2 {
|
||||
font-size: 3.052rem;
|
||||
line-height: 4.768rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.text-h3 {
|
||||
font-size: 2.441rem;
|
||||
line-height: 3.052rem;
|
||||
font-weight: 600;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
}
|
||||
|
||||
.text-h4 {
|
||||
font-size: 1.953rem;
|
||||
line-height: 2.441rem;
|
||||
font-weight: 600;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
}
|
||||
|
||||
.text-h5 {
|
||||
font-size: 1.563rem;
|
||||
line-height: 1.953rem;
|
||||
font-weight: 500;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
}
|
||||
|
||||
.text-h6 {
|
||||
font-size: 1.250rem;
|
||||
line-height: 1.563rem;
|
||||
font-weight: 500;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
}
|
||||
|
||||
.text-h7 {
|
||||
font-size: 1.000rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
font-family: 'Inter Tight', sans-serif;
|
||||
}
|
||||
|
||||
.text-display-lg {
|
||||
font-size: 5.96rem;
|
||||
line-height: 1.0;
|
||||
font-weight: 800;
|
||||
font-family: 'Exo', 'Inter Tight', sans-serif;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.text-logo {
|
||||
font-family: 'Exo', 'Inter Tight', sans-serif;
|
||||
font-weight: 100;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
/* CHORUS Animation System */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
/* CHORUS Motion System */
|
||||
* {
|
||||
transition: opacity 200ms ease-out,
|
||||
color 200ms ease-out,
|
||||
background-color 200ms ease-out,
|
||||
border-color 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
79
modules/teaser/app/layout.tsx
Normal file
79
modules/teaser/app/layout.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter, Exo } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
const exo = Exo({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-exo',
|
||||
display: 'swap',
|
||||
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'CHORUS Services - Contextual AI Orchestration Platform',
|
||||
description: 'Revolutionary AI orchestration platform. The right context, to the right agent, at the right time. Join the waitlist for early access.',
|
||||
keywords: ['contextual AI', 'agent orchestration', 'enterprise AI', 'knowledge fabric', 'AI platform'],
|
||||
authors: [{ name: 'Anthony Lewis Rawlins', url: 'https://deepblack.cloud' }],
|
||||
creator: 'Deep Black Cloud',
|
||||
publisher: 'CHORUS Services',
|
||||
metadataBase: new URL('https://chorus.services'),
|
||||
alternates: {
|
||||
canonical: 'https://chorus.services',
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'https://chorus.services',
|
||||
siteName: 'CHORUS Services',
|
||||
title: 'CHORUS Services - Contextual AI Orchestration Platform',
|
||||
description: 'Revolutionary AI orchestration platform. The right context, to the right agent, at the right time.',
|
||||
images: [
|
||||
{
|
||||
url: '/logos/logo-ring-only.png',
|
||||
width: 256,
|
||||
height: 256,
|
||||
alt: 'CHORUS Services Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'CHORUS Services - Contextual AI Orchestration',
|
||||
description: 'The right context, to the right agent, at the right time.',
|
||||
images: ['/logos/chorus-landscape-on-blue.png'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
// Add Google Search Console verification when available
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.variable} ${exo.variable} font-sans`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
87
modules/teaser/app/page.tsx
Normal file
87
modules/teaser/app/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useEarlyAccessCapture } from '../hooks/useEarlyAccessCapture'
|
||||
import TeaserHero from '../components/TeaserHero'
|
||||
import MissionStatement from '../components/MissionStatement'
|
||||
import EarlyAccessForm from '../components/EarlyAccessForm'
|
||||
import ThemeToggle from '../components/ThemeToggle'
|
||||
|
||||
export default function HomePage() {
|
||||
const { isModalOpen, currentLeadSource, openModal, closeModal } = useEarlyAccessCapture()
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white dark:bg-carbon-950 text-carbon-950 dark:text-white overflow-x-hidden font-sans antialiased">
|
||||
{/* Hero Section */}
|
||||
<TeaserHero onEarlyAccess={openModal} />
|
||||
|
||||
{/* Mission Statement */}
|
||||
<MissionStatement />
|
||||
|
||||
{/* Coming Soon Footer */}
|
||||
<section className="py-chorus-xxl px-chorus-lg border-t border-mulberry-800/30 dark:border-mulberry-800/30 border-sand-300/50 bg-gradient-to-b from-sand-200 to-white dark:from-carbon-950 dark:to-mulberry-950">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<p className="text-carbon-700 dark:text-mulberry-200 text-lg font-light mb-chorus-xl leading-relaxed">
|
||||
CHORUS Services is currently in development.<br/>
|
||||
Join our waitlist to be first to experience the future of contextual AI orchestration.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-chorus-md justify-center flex-wrap">
|
||||
<button
|
||||
onClick={() => openModal('request_early_access')}
|
||||
className="btn-primary text-lg px-chorus-xl py-chorus-md"
|
||||
>
|
||||
Request Early Access
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('early_access_waitlist')}
|
||||
className="btn-secondary text-lg px-chorus-xl py-chorus-md"
|
||||
>
|
||||
Join Waitlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Minimal Footer */}
|
||||
<footer className="py-chorus-xl px-chorus-lg border-t border-sand-300/30 dark:border-mulberry-800/20 bg-sand-100 dark:bg-mulberry-950">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<div className="text-carbon-600 dark:text-mulberry-300 text-sm font-medium">
|
||||
© 2025 Deep Black Cloud. All rights reserved.
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-chorus-lg text-sm">
|
||||
<a
|
||||
href="mailto:contact@chorus.services"
|
||||
className="text-carbon-600 dark:text-mulberry-300 hover:text-carbon-950 dark:hover:text-white transition-colors duration-300 ease-out font-medium"
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
<a
|
||||
href="/privacy"
|
||||
className="text-carbon-600 dark:text-mulberry-300 hover:text-carbon-950 dark:hover:text-white transition-colors duration-300 ease-out font-medium"
|
||||
>
|
||||
Privacy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Details */}
|
||||
<div className="mt-chorus-lg pt-chorus-lg border-t border-sand-300/30 dark:border-mulberry-800/30">
|
||||
<div className="text-xs text-carbon-500 dark:text-mulberry-400 space-y-1 leading-relaxed">
|
||||
<p className="font-medium">CHORUS.services - Anthony Lewis Rawlins</p>
|
||||
<p>ABN: 38558842858 | Lucas, Victoria 3350, Australia</p>
|
||||
<p className="text-carbon-400 dark:text-mulberry-500">AI Development & IT Consultancy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Early Access Form Modal */}
|
||||
<EarlyAccessForm isOpen={isModalOpen} onClose={closeModal} leadSource={currentLeadSource} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
131
modules/teaser/app/privacy/page.tsx
Normal file
131
modules/teaser/app/privacy/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import ThemeToggle from '../../components/ThemeToggle'
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white dark:bg-carbon-950 text-carbon-950 dark:text-white font-sans antialiased">
|
||||
<div className="max-w-4xl mx-auto px-chorus-lg py-chorus-xxl">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-chorus-xxl">
|
||||
<h1 className="text-h1 font-logo font-thin text-carbon-950 dark:text-white mb-chorus-md text-center">
|
||||
CHORUS
|
||||
</h1>
|
||||
<nav className="text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-carbon-600 dark:text-mulberry-300 hover:text-carbon-950 dark:hover:text-white transition-colors font-medium"
|
||||
>
|
||||
← Back to Home
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Privacy Policy Content */}
|
||||
<article className="prose prose-lg max-w-none">
|
||||
<header className="mb-chorus-xl">
|
||||
<h1 className="text-h2 font-bold text-carbon-950 dark:text-white mb-chorus-md">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p className="text-carbon-600 dark:text-mulberry-300 font-medium">
|
||||
Effective Date: January 1, 2025
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-chorus-lg text-carbon-700 dark:text-mulberry-100 leading-relaxed">
|
||||
<p>
|
||||
This Privacy Policy explains how we collect, use, and protect your information when you use our website.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2 className="text-h4 font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
Information We Collect
|
||||
</h2>
|
||||
<p>
|
||||
<strong>Email Address:</strong> When you sign up for early access or join the waitlist, we collect your email address.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-h4 font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
How We Use Your Information
|
||||
</h2>
|
||||
<ul className="list-disc pl-chorus-lg space-y-2">
|
||||
<li>To notify you about early access opportunities, product updates, and launch information.</li>
|
||||
<li>To communicate directly with you regarding our product.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-h4 font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
Data Storage and Security
|
||||
</h2>
|
||||
<ul className="list-disc pl-chorus-lg space-y-2">
|
||||
<li>Your email address is stored in a secure, encrypted database.</li>
|
||||
<li>We take reasonable steps to protect your data from unauthorized access, disclosure, or misuse.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-h4 font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
Sharing of Information
|
||||
</h2>
|
||||
<p>
|
||||
We do not sell, rent, or share your personal information with any third parties.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-h4 font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
Your Rights
|
||||
</h2>
|
||||
<ul className="list-disc pl-chorus-lg space-y-2">
|
||||
<li>You may request the deletion of your email address from our records at any time by contacting us at policy@chorus.services.</li>
|
||||
<li>You can unsubscribe from our communications at any time via the link in our emails.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-h4 font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
Changes to This Policy
|
||||
</h2>
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. Any changes will be posted on this page with a new effective date.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-h4 font-semibold text-carbon-950 dark:text-white mb-chorus-sm">
|
||||
Contact Us
|
||||
</h2>
|
||||
<p>
|
||||
If you have any questions about this Privacy Policy or your data, please contact us at:{" "}
|
||||
<a
|
||||
href="mailto:policy@chorus.services"
|
||||
className="text-carbon-950 dark:text-white font-semibold hover:underline"
|
||||
>
|
||||
policy@chorus.services
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-chorus-xxl pt-chorus-xl border-t border-sand-300/30 dark:border-mulberry-800/30">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-carbon-500 dark:text-mulberry-400">
|
||||
<p className="font-medium">CHORUS.services - Anthony Lewis Rawlins</p>
|
||||
<p>ABN: 38558842858 | Lucas, Victoria 3350, Australia</p>
|
||||
<p className="text-carbon-400 dark:text-mulberry-500">AI Development & IT Consultancy</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user