diff --git a/apps/landing/.env.example b/apps/landing/.env.example new file mode 100644 index 0000000..5a126f8 --- /dev/null +++ b/apps/landing/.env.example @@ -0,0 +1,6 @@ +# Landing App Environment Variables + +# Waitlist logs path (absolute path required) +# Development: use local directory +# Production: use Docker volume path +WAITLIST_LOGS_PATH=/absolute/path/to/waitlist-logs diff --git a/apps/landing/.gitignore b/apps/landing/.gitignore index 5ef6a52..9ff866b 100644 --- a/apps/landing/.gitignore +++ b/apps/landing/.gitignore @@ -32,6 +32,10 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example + +# waitlist logs +/waitlist-logs/ # vercel .vercel diff --git a/apps/landing/src/app/homepage/_components/HeroSection.tsx b/apps/landing/src/app/homepage/_components/HeroSection.tsx index 7aa92bc..783adcd 100644 --- a/apps/landing/src/app/homepage/_components/HeroSection.tsx +++ b/apps/landing/src/app/homepage/_components/HeroSection.tsx @@ -1,9 +1,10 @@ 'use client'; import { useState } from 'react'; -import { Zap, Globe, FlaskConical, AtSign, Link } from 'lucide-react'; +import { Zap, Globe, FlaskConical, AtSign, Link, Check } from 'lucide-react'; import GlowEffect from './GlowEffect'; import WaitlistPopup from './WaitlistPopup'; +import { submitEmail, submitWaitlistData } from '@/lib/actions/waitlistActions'; export const styles = ` .gradient-text { @@ -34,6 +35,44 @@ export const styles = ` 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + + .hero-btn { + transition: all 0.2s ease; + } + + .hero-btn.enabled { + cursor: pointer; + } + + .hero-btn.disabled { + cursor: not-allowed; + } + + .hero-btn.disabled:hover { + background: rgba(100, 100, 120, 0.4) !important; + } + + .success-checkmark-ring { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%); + border: 1px solid rgba(34, 197, 94, 0.3); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .success-checkmark-inner { + width: 22px; + height: 22px; + border-radius: 50%; + background: linear-gradient(135deg, #22c55e 0%, #10b981 100%); + display: flex; + align-items: center; + justify-content: center; + } `; const badges = [ @@ -44,21 +83,42 @@ const badges = [ { icon: Link, text: 'Prompt URLs', variant: 'cyan' }, ]; +const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + export function HeroSection() { const [email, setEmail] = useState(''); + const [isInvalid, setIsInvalid] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); const [showPopup, setShowPopup] = useState(false); - const handleSubmit = (e: React.FormEvent) => { + const isEnabled = email.length > 0 && isValidEmail(email); + + const handleEmailChange = (e: React.ChangeEvent) => { + setEmail(e.target.value); + if (isInvalid) setIsInvalid(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - console.log('Email submitted:', email); - setShowPopup(true); + if (!isValidEmail(email)) { + setIsInvalid(true); + return; + } + setIsInvalid(false); + + const result = await submitEmail(email); + if (result.success) { + setShowPopup(true); + } + }; + + const handlePopupClose = () => { + setShowPopup(false); + setIsSubmitted(true); }; const handleWaitlistSubmit = async (data: { selected: string[]; other: string }) => { - await fetch('/api/brevo', { - method: 'POST', - body: JSON.stringify({ email, useCases: data.selected, other: data.other }), - }); + await submitWaitlistData(email, data); }; return ( @@ -82,25 +142,44 @@ export function HeroSection() {

-
- setEmail(e.target.value)} - placeholder="your@email.com" - className="flex-1 px-4 py-3 bg-transparent border-none rounded-md text-white outline-none placeholder:text-white/40 focus:bg-white/[0.03]" - /> - -
+
+
+ +
+
+ Done! You're in the list + + ) : ( +
+ + +
+ )}

Free early access. No credit card required.

@@ -125,7 +204,7 @@ export function HeroSection() { setShowPopup(false)} + onClose={handlePopupClose} onSubmit={handleWaitlistSubmit} /> diff --git a/apps/landing/src/lib/actions/waitlistActions.ts b/apps/landing/src/lib/actions/waitlistActions.ts new file mode 100644 index 0000000..22adb11 --- /dev/null +++ b/apps/landing/src/lib/actions/waitlistActions.ts @@ -0,0 +1,47 @@ +'use server'; + +import { storeMail, updateMail } from '@/lib/logger/waitlistLogger'; + +const submissionTimestamps = new Map(); +const RATE_LIMIT_WINDOW = 60000; +const MAX_SUBMISSIONS = 3; + +function isRateLimited(identifier: string): boolean { + const now = Date.now(); + const timestamps = submissionTimestamps.get(identifier) || []; + const recentTimestamps = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW); + + if (recentTimestamps.length >= MAX_SUBMISSIONS) { + return true; + } + + submissionTimestamps.set(identifier, [...recentTimestamps, now]); + return false; +} + +export async function submitEmail(email: string): Promise<{ success: boolean; error?: string }> { + try { + if (isRateLimited(email)) { + return { success: false, error: 'Too many requests' }; + } + + await storeMail(email); + return { success: true }; + } catch (error) { + console.error('Failed to store email:', error); + return { success: false, error: 'Failed to save' }; + } +} + +export async function submitWaitlistData( + email: string, + data: { selected: string[]; other: string } +): Promise<{ success: boolean; error?: string }> { + try { + await updateMail(email, data); + return { success: true }; + } catch (error) { + console.error('Failed to update waitlist data:', error); + return { success: false, error: 'Failed to save' }; + } +} diff --git a/apps/landing/src/lib/logger/waitlistLogger.ts b/apps/landing/src/lib/logger/waitlistLogger.ts new file mode 100644 index 0000000..0ec2a0b --- /dev/null +++ b/apps/landing/src/lib/logger/waitlistLogger.ts @@ -0,0 +1,120 @@ +import fs from 'fs/promises'; +import path from 'path'; + +const LOGS_PATH = process.env.WAITLIST_LOGS_PATH; + +let isInitialized = false; + +async function ensureInitialized(): Promise { + if (isInitialized) return; + + if (!LOGS_PATH) { + throw new Error('WAITLIST_LOGS_PATH environment variable is not set'); + } + + try { + await fs.access(LOGS_PATH); + } catch { + throw new Error(`Logs directory does not exist: ${LOGS_PATH}`); + } + + const testFile = path.join(LOGS_PATH, '.write-test'); + try { + await fs.writeFile(testFile, 'test'); + await fs.unlink(testFile); + } catch { + throw new Error(`No write access to logs directory: ${LOGS_PATH}`); + } + + isInitialized = true; +} + +function sanitizeEmail(email: string): string { + return email + .toLowerCase() + .replace(/[^a-z0-9@._-]/g, '_') + .replace(/@/g, '-at-') + .substring(0, 50); +} + +function getMonth(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; +} + +async function getNextIndex(): Promise { + await ensureInitialized(); + + const files = await fs.readdir(LOGS_PATH!); + const mailFiles = files.filter((f) => f.startsWith('mail-')); + + let maxIndex = 0; + for (const file of mailFiles) { + const match = file.match(/^mail-(\d+)-/); + if (match) { + maxIndex = Math.max(maxIndex, parseInt(match[1], 10)); + } + } + + return String(maxIndex + 1).padStart(3, '0'); +} + +async function findFileByEmail(email: string): Promise { + await ensureInitialized(); + + const sanitized = sanitizeEmail(email); + const files = await fs.readdir(LOGS_PATH!); + + const match = files.find((f) => f.endsWith(`${sanitized}.md`)); + return match ? path.join(LOGS_PATH!, match) : null; +} + +export async function storeMail(email: string): Promise { + await ensureInitialized(); + + const index = await getNextIndex(); + const month = getMonth(); + const sanitized = sanitizeEmail(email); + const filename = `mail-${index}-${month}-${sanitized}.md`; + const filepath = path.join(LOGS_PATH!, filename); + + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); + + const content = `${email} +${timestamp} +`; + + await fs.writeFile(filepath, content, 'utf-8'); +} + +export async function updateMail(email: string, data: { selected: string[]; other: string }): Promise { + await ensureInitialized(); + + const filepath = await findFileByEmail(email); + + if (!filepath) { + await storeMail(email); + const newFilepath = await findFileByEmail(email); + if (!newFilepath) throw new Error('Failed to create mail file'); + await appendUseCases(newFilepath, data); + return; + } + + await appendUseCases(filepath, data); +} + +async function appendUseCases(filepath: string, data: { selected: string[]; other: string }): Promise { + const existing = await fs.readFile(filepath, 'utf-8'); + + let useCasesSection = '\n### Use Cases\n'; + + for (const useCase of data.selected) { + useCasesSection += `- ${useCase}\n`; + } + + if (data.other) { + useCasesSection += `- Other: "${data.other}"\n`; + } + + await fs.writeFile(filepath, existing + useCasesSection, 'utf-8'); +} diff --git a/prod-env/.env b/prod-env/.env index 819eeef..e6c43a3 100644 --- a/prod-env/.env +++ b/prod-env/.env @@ -49,5 +49,11 @@ UPLOADS_DIR=/app/uploads/temp TTI_LOG=logs/tti-log.md ENH_LOG=logs/enhancing.md +# Waitlist Configuration +# NOTE: WAITLIST_LOGS_PATH is set in docker-compose.yml environment section +# The host path for volume mount should be coordinated with VPS project infrastructure +# Default dev path: ../data/waitlist-logs (relative to prod-env/) +# Production path example: /var/banatie/waitlist-logs + # IMPORTANT: Sensitive values should be in secrets.env (not tracked in git) # See secrets.env.example for required variables diff --git a/prod-env/docker-compose.yml b/prod-env/docker-compose.yml index e9f2e4f..3588d3c 100644 --- a/prod-env/docker-compose.yml +++ b/prod-env/docker-compose.yml @@ -35,6 +35,11 @@ services: container_name: banatie-landing ports: - "3001:3000" + volumes: + # Waitlist email logs + # TODO: Actual host path on VPS must be coordinated with VPS project during production deployment + # Example production path: /var/banatie/waitlist-logs:/app/waitlist-logs + - ../data/waitlist-logs:/app/waitlist-logs networks: - banatie-network depends_on: @@ -45,6 +50,7 @@ services: environment: - IS_DOCKER=true - NODE_ENV=production + - WAITLIST_LOGS_PATH=/app/waitlist-logs restart: unless-stopped # PostgreSQL Database