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() {
-
+
+ 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