Initial commit.
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
*~
|
||||||
|
*/venv
|
||||||
|
devbox-back/devbox_emails.db
|
||||||
|
devbox-back/__pycache__
|
3
README.md
Normal file
3
README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# DevBox Landing Page
|
||||||
|
|
||||||
|
Git repository for frontend and backend for the DevBox landing page and email collection service.
|
16
devbox-back/README.md
Normal file
16
devbox-back/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# DevBox Email Collection API
|
||||||
|
|
||||||
|
A simple and efficient email collection backend for the DevBox landing page.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📧 Email collection and validation
|
||||||
|
- 📊 Statistics and analytics
|
||||||
|
- 💾 SQLite database storage
|
||||||
|
- 🔍 Search and export functionality
|
||||||
|
- 🚀 FastAPI with automatic documentation
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh
|
541
devbox-back/database_manager.py
Executable file
541
devbox-back/database_manager.py
Executable file
@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
DevBox Email Database Management Utilities
|
||||||
|
|
||||||
|
This script provides database management utilities for the DevBox email collection system.
|
||||||
|
Offers tools for database maintenance, backups, and data analysis.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python database_manager.py --help
|
||||||
|
python database_manager.py --export
|
||||||
|
python database_manager.py --backup
|
||||||
|
python database_manager.py --stats
|
||||||
|
python database_manager.py --cleanup-duplicates
|
||||||
|
python database_manager.py --validate-emails
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import csv
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class DevBoxEmailManager:
|
||||||
|
"""
|
||||||
|
Database manager for DevBox email subscriptions
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_file: str = "devbox_emails.db"):
|
||||||
|
self.db_file = db_file
|
||||||
|
self.ensure_database_exists()
|
||||||
|
|
||||||
|
def ensure_database_exists(self):
|
||||||
|
"""Ensure database exists and is properly initialized"""
|
||||||
|
if not os.path.exists(self.db_file):
|
||||||
|
logger.warning(f"Database {self.db_file} not found. Creating new database...")
|
||||||
|
self.init_database()
|
||||||
|
else:
|
||||||
|
logger.info(f"Using existing database: {self.db_file}")
|
||||||
|
# Verify table structure
|
||||||
|
self._verify_schema()
|
||||||
|
|
||||||
|
def get_connection(self):
|
||||||
|
"""Get database connection with error handling"""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(self.db_file)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to database: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _verify_schema(self):
|
||||||
|
"""Verify database schema is correct"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
# Check if table exists and has correct columns
|
||||||
|
cursor = conn.execute("PRAGMA table_info(email_signups)")
|
||||||
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
expected_columns = {'id', 'email', 'source', 'created_at', 'user_agent', 'ip_address'}
|
||||||
|
if not expected_columns.issubset(columns):
|
||||||
|
logger.warning("Database schema incomplete. Updating...")
|
||||||
|
self._update_schema(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _update_schema(self, conn):
|
||||||
|
"""Update database schema if needed"""
|
||||||
|
try:
|
||||||
|
# Add missing columns if they don't exist
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE email_signups ADD COLUMN user_agent TEXT")
|
||||||
|
logger.info("Added user_agent column")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE email_signups ADD COLUMN ip_address TEXT")
|
||||||
|
logger.info("Added ip_address column")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update schema: {e}")
|
||||||
|
|
||||||
|
def init_database(self):
|
||||||
|
"""Initialize database with required tables"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_signups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
source TEXT DEFAULT 'devbox-landing',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_email ON email_signups(email)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_created_at ON email_signups(created_at)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_source ON email_signups(source)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize database: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def export_to_csv(self, filename: Optional[str] = None, source_filter: Optional[str] = None) -> str:
|
||||||
|
"""Export emails to CSV file"""
|
||||||
|
if filename is None:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
source_suffix = f"_{source_filter}" if source_filter else ""
|
||||||
|
filename = f"devbox_emails{source_suffix}_{timestamp}.csv"
|
||||||
|
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT id, email, source, created_at, user_agent, ip_address
|
||||||
|
FROM email_signups
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if source_filter:
|
||||||
|
query += " WHERE source = ?"
|
||||||
|
params.append(source_filter)
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
|
||||||
|
emails = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
|
||||||
|
writer = csv.writer(csvfile)
|
||||||
|
writer.writerow(['ID', 'Email', 'Source', 'Created At', 'User Agent', 'IP Address'])
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
writer.writerow([
|
||||||
|
email['id'],
|
||||||
|
email['email'],
|
||||||
|
email['source'] or 'devbox-landing',
|
||||||
|
email['created_at'],
|
||||||
|
email['user_agent'] or '',
|
||||||
|
email['ip_address'] or ''
|
||||||
|
])
|
||||||
|
|
||||||
|
logger.info(f"Exported {len(emails)} emails to {filename}")
|
||||||
|
print(f"✅ Exported {len(emails)} emails to {filename}")
|
||||||
|
return filename
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Export failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_detailed_stats(self) -> Dict:
|
||||||
|
"""Get comprehensive statistics"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
# Basic stats
|
||||||
|
basic_stats = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_signups,
|
||||||
|
COUNT(CASE WHEN DATE(created_at) = DATE('now') THEN 1 END) as today_signups,
|
||||||
|
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-7 days') THEN 1 END) as week_signups,
|
||||||
|
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-30 days') THEN 1 END) as month_signups,
|
||||||
|
MIN(created_at) as first_signup,
|
||||||
|
MAX(created_at) as latest_signup
|
||||||
|
FROM email_signups
|
||||||
|
""").fetchone()
|
||||||
|
|
||||||
|
if not basic_stats or basic_stats['total_signups'] == 0:
|
||||||
|
return {
|
||||||
|
'message': 'No email signups found in database',
|
||||||
|
'basic_stats': {
|
||||||
|
'total_signups': 0,
|
||||||
|
'today_signups': 0,
|
||||||
|
'week_signups': 0,
|
||||||
|
'month_signups': 0,
|
||||||
|
'first_signup': None,
|
||||||
|
'latest_signup': None
|
||||||
|
},
|
||||||
|
'sources': {},
|
||||||
|
'daily_signups': [],
|
||||||
|
'top_domains': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Source breakdown
|
||||||
|
sources = conn.execute("""
|
||||||
|
SELECT COALESCE(source, 'devbox-landing') as source, COUNT(*) as count
|
||||||
|
FROM email_signups
|
||||||
|
GROUP BY COALESCE(source, 'devbox-landing')
|
||||||
|
ORDER BY count DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
# Daily signups for last 30 days
|
||||||
|
daily_signups = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) as signup_date,
|
||||||
|
COUNT(*) as daily_count
|
||||||
|
FROM email_signups
|
||||||
|
WHERE DATE(created_at) >= DATE('now', '-30 days')
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY signup_date DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
# Top domains
|
||||||
|
top_domains = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN INSTR(email, '@') > 0
|
||||||
|
THEN SUBSTR(email, INSTR(email, '@') + 1)
|
||||||
|
ELSE 'invalid'
|
||||||
|
END as domain,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM email_signups
|
||||||
|
GROUP BY domain
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'basic_stats': dict(basic_stats),
|
||||||
|
'sources': {row['source']: row['count'] for row in sources},
|
||||||
|
'daily_signups': [dict(row) for row in daily_signups],
|
||||||
|
'top_domains': [dict(row) for row in top_domains]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get stats: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def cleanup_duplicates(self) -> int:
|
||||||
|
"""Remove duplicate emails (keep the earliest)"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
# Find duplicates
|
||||||
|
duplicates = conn.execute("""
|
||||||
|
SELECT email, COUNT(*) as count, MIN(id) as keep_id
|
||||||
|
FROM email_signups
|
||||||
|
GROUP BY LOWER(email)
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
if not duplicates:
|
||||||
|
print("✅ No duplicates found")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
removed_count = 0
|
||||||
|
for dup in duplicates:
|
||||||
|
# Remove all but the earliest (smallest ID)
|
||||||
|
result = conn.execute("""
|
||||||
|
DELETE FROM email_signups
|
||||||
|
WHERE LOWER(email) = LOWER(?) AND id != ?
|
||||||
|
""", (dup['email'], dup['keep_id']))
|
||||||
|
removed_count += result.rowcount
|
||||||
|
logger.info(f"Removed {result.rowcount} duplicate(s) for {dup['email']}")
|
||||||
|
print(f"🧹 Removed {result.rowcount} duplicate(s) for {dup['email']}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Cleanup complete. Removed {removed_count} duplicate records.")
|
||||||
|
print(f"✅ Cleanup complete. Removed {removed_count} duplicate records.")
|
||||||
|
return removed_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cleanup failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def backup_database(self, backup_file: Optional[str] = None) -> str:
|
||||||
|
"""Create a backup of the database"""
|
||||||
|
if backup_file is None:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_file = f"devbox_emails_backup_{timestamp}.db"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not os.path.exists(self.db_file):
|
||||||
|
raise FileNotFoundError(f"Source database {self.db_file} does not exist")
|
||||||
|
|
||||||
|
shutil.copy2(self.db_file, backup_file)
|
||||||
|
file_size = os.path.getsize(backup_file)
|
||||||
|
logger.info(f"Database backed up to {backup_file} ({file_size:,} bytes)")
|
||||||
|
print(f"✅ Database backed up to {backup_file} ({file_size:,} bytes)")
|
||||||
|
return backup_file
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Backup failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def analyze_growth(self, days: int = 30) -> Dict:
|
||||||
|
"""Analyze signup growth trends"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
growth_data = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) as date,
|
||||||
|
COUNT(*) as signups,
|
||||||
|
SUM(COUNT(*)) OVER (ORDER BY DATE(created_at)) as cumulative
|
||||||
|
FROM email_signups
|
||||||
|
WHERE DATE(created_at) >= DATE('now', '-' || ? || ' days')
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date
|
||||||
|
""", (days,)).fetchall()
|
||||||
|
|
||||||
|
if not growth_data:
|
||||||
|
return {
|
||||||
|
'message': f'No data available for the last {days} days',
|
||||||
|
'period_days': days,
|
||||||
|
'total_signups_in_period': 0,
|
||||||
|
'growth_rate_percent': 0,
|
||||||
|
'daily_data': [],
|
||||||
|
'average_daily_signups': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate growth rate
|
||||||
|
total_signups = growth_data[-1]['cumulative'] if growth_data else 0
|
||||||
|
first_day_cumulative = growth_data[0]['cumulative'] - growth_data[0]['signups'] if growth_data else 0
|
||||||
|
signups_in_period = total_signups - first_day_cumulative
|
||||||
|
|
||||||
|
growth_rate = 0
|
||||||
|
if first_day_cumulative > 0:
|
||||||
|
growth_rate = (signups_in_period / first_day_cumulative) * 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
'period_days': days,
|
||||||
|
'total_signups_in_period': signups_in_period,
|
||||||
|
'growth_rate_percent': round(growth_rate, 2),
|
||||||
|
'daily_data': [dict(row) for row in growth_data],
|
||||||
|
'average_daily_signups': round(signups_in_period / days, 2) if days > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Growth analysis failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def validate_emails(self) -> Dict:
|
||||||
|
"""Validate email formats in the database"""
|
||||||
|
email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||||
|
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
all_emails = conn.execute("SELECT id, email FROM email_signups").fetchall()
|
||||||
|
|
||||||
|
if not all_emails:
|
||||||
|
return {
|
||||||
|
'total_emails': 0,
|
||||||
|
'valid_emails': 0,
|
||||||
|
'invalid_emails': 0,
|
||||||
|
'invalid_list': []
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_emails = []
|
||||||
|
invalid_emails = []
|
||||||
|
|
||||||
|
for row in all_emails:
|
||||||
|
email = row['email'].strip() if row['email'] else ''
|
||||||
|
if email and email_pattern.match(email):
|
||||||
|
valid_emails.append(email)
|
||||||
|
else:
|
||||||
|
invalid_emails.append({'id': row['id'], 'email': email})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_emails': len(all_emails),
|
||||||
|
'valid_emails': len(valid_emails),
|
||||||
|
'invalid_emails': len(invalid_emails),
|
||||||
|
'invalid_list': invalid_emails
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Email validation failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_email_count(self) -> int:
|
||||||
|
"""Get total number of subscribed emails"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
result = conn.execute("SELECT COUNT(*) as count FROM email_signups").fetchone()
|
||||||
|
return result['count'] if result else 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get email count: {e}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def print_stats_table(stats: Dict):
|
||||||
|
"""Print statistics in a nice table format"""
|
||||||
|
print("\n📊 DevBox Email Statistics")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
basic = stats.get('basic_stats', {})
|
||||||
|
print(f"Total Signups: {basic.get('total_signups', 0):,}")
|
||||||
|
print(f"Today: {basic.get('today_signups', 0):,}")
|
||||||
|
print(f"This Week: {basic.get('week_signups', 0):,}")
|
||||||
|
print(f"This Month: {basic.get('month_signups', 0):,}")
|
||||||
|
print(f"First Signup: {basic.get('first_signup', 'N/A')}")
|
||||||
|
print(f"Latest Signup: {basic.get('latest_signup', 'N/A')}")
|
||||||
|
|
||||||
|
sources = stats.get('sources', {})
|
||||||
|
if sources:
|
||||||
|
print(f"\n📈 Sources:")
|
||||||
|
for source, count in sources.items():
|
||||||
|
print(f" {source}: {count:,}")
|
||||||
|
|
||||||
|
domains = stats.get('top_domains', [])
|
||||||
|
if domains:
|
||||||
|
print(f"\n🌐 Top Domains:")
|
||||||
|
for domain in domains[:5]:
|
||||||
|
print(f" {domain['domain']}: {domain['count']:,}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Command line interface for database management"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='DevBox Email Database Manager',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python database_manager.py --stats
|
||||||
|
python database_manager.py --export
|
||||||
|
python database_manager.py --export --source-filter devbox-landing
|
||||||
|
python database_manager.py --backup
|
||||||
|
python database_manager.py --cleanup-duplicates
|
||||||
|
python database_manager.py --validate-emails
|
||||||
|
python database_manager.py --analyze-growth 14
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument('--export', action='store_true',
|
||||||
|
help='Export emails to CSV')
|
||||||
|
parser.add_argument('--backup', action='store_true',
|
||||||
|
help='Create database backup')
|
||||||
|
parser.add_argument('--stats', action='store_true',
|
||||||
|
help='Show detailed statistics')
|
||||||
|
parser.add_argument('--cleanup-duplicates', action='store_true',
|
||||||
|
help='Remove duplicate emails')
|
||||||
|
parser.add_argument('--analyze-growth', type=int, metavar='DAYS',
|
||||||
|
help='Analyze growth over N days')
|
||||||
|
parser.add_argument('--validate-emails', action='store_true',
|
||||||
|
help='Validate email formats')
|
||||||
|
parser.add_argument('--source-filter', type=str,
|
||||||
|
help='Filter by source (for export)')
|
||||||
|
parser.add_argument('--db-file', type=str, default='devbox_emails.db',
|
||||||
|
help='Database file path')
|
||||||
|
parser.add_argument('--count', action='store_true',
|
||||||
|
help='Show total email count')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# If no arguments provided, show help
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
parser.print_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager = DevBoxEmailManager(args.db_file)
|
||||||
|
|
||||||
|
if args.count:
|
||||||
|
count = manager.get_email_count()
|
||||||
|
print(f"📧 Total emails in database: {count:,}")
|
||||||
|
|
||||||
|
if args.export:
|
||||||
|
filename = manager.export_to_csv(source_filter=args.source_filter)
|
||||||
|
# filename is already printed by the method
|
||||||
|
|
||||||
|
if args.backup:
|
||||||
|
backup_file = manager.backup_database()
|
||||||
|
# backup_file is already printed by the method
|
||||||
|
|
||||||
|
if args.stats:
|
||||||
|
stats = manager.get_detailed_stats()
|
||||||
|
if 'message' in stats:
|
||||||
|
print(f"ℹ️ {stats['message']}")
|
||||||
|
else:
|
||||||
|
print_stats_table(stats)
|
||||||
|
|
||||||
|
if args.cleanup_duplicates:
|
||||||
|
removed = manager.cleanup_duplicates()
|
||||||
|
# results are already printed by the method
|
||||||
|
|
||||||
|
if args.analyze_growth:
|
||||||
|
growth = manager.analyze_growth(args.analyze_growth)
|
||||||
|
print(f"\n📈 Growth Analysis ({args.analyze_growth} days):")
|
||||||
|
print("=" * 40)
|
||||||
|
if 'message' in growth:
|
||||||
|
print(f"ℹ️ {growth['message']}")
|
||||||
|
else:
|
||||||
|
print(f"Total signups in period: {growth['total_signups_in_period']:,}")
|
||||||
|
print(f"Growth rate: {growth['growth_rate_percent']}%")
|
||||||
|
print(f"Average daily signups: {growth['average_daily_signups']}")
|
||||||
|
|
||||||
|
if args.validate_emails:
|
||||||
|
validation = manager.validate_emails()
|
||||||
|
print(f"\n✉️ Email Validation Results:")
|
||||||
|
print("=" * 35)
|
||||||
|
print(f"Total emails: {validation['total_emails']:,}")
|
||||||
|
print(f"Valid emails: {validation['valid_emails']:,}")
|
||||||
|
print(f"Invalid emails: {validation['invalid_emails']:,}")
|
||||||
|
|
||||||
|
if validation['invalid_emails'] > 0:
|
||||||
|
print(f"\n⚠️ Invalid emails found:")
|
||||||
|
for invalid in validation['invalid_list'][:10]: # Show first 10
|
||||||
|
print(f" ID {invalid['id']}: {invalid['email']}")
|
||||||
|
if len(validation['invalid_list']) > 10:
|
||||||
|
print(f" ... and {len(validation['invalid_list']) - 10} more")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n🛑 Operation cancelled by user")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Operation failed: {e}")
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
465
devbox-back/main.py
Normal file
465
devbox-back/main.py
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Depends, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
import uvicorn
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
DATABASE_FILE = "devbox_emails.db"
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Create and return a database connection"""
|
||||||
|
conn = sqlite3.connect(DATABASE_FILE)
|
||||||
|
conn.row_factory = sqlite3.Row # This allows dict-like access to rows
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Initialize the database with required tables"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS email_signups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
source TEXT DEFAULT 'devbox-landing',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_email ON email_signups(email)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_created_at ON email_signups(created_at)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_source ON email_signups(source)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initializing database: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Handle application startup and shutdown events"""
|
||||||
|
# Startup
|
||||||
|
logger.info("DevBox Email Collection API starting up...")
|
||||||
|
init_database()
|
||||||
|
logger.info("DevBox Email Collection API started successfully")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("DevBox Email Collection API shutting down...")
|
||||||
|
|
||||||
|
# Create FastAPI app with lifespan
|
||||||
|
app = FastAPI(
|
||||||
|
title="DevBox Email Collection API",
|
||||||
|
description="Backend API for collecting email signups for DevBox landing page",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware to allow requests from your frontend
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # In production, specify your domain
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pydantic models
|
||||||
|
class EmailSignup(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
source: Optional[str] = "devbox-landing"
|
||||||
|
|
||||||
|
class EmailSignupResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
email: str
|
||||||
|
id: Optional[int] = None
|
||||||
|
|
||||||
|
class EmailListResponse(BaseModel):
|
||||||
|
total_count: int
|
||||||
|
emails: List[dict]
|
||||||
|
|
||||||
|
class StatsResponse(BaseModel):
|
||||||
|
total_signups: int
|
||||||
|
today_signups: int
|
||||||
|
week_signups: int
|
||||||
|
month_signups: int
|
||||||
|
first_signup: Optional[str]
|
||||||
|
latest_signup: Optional[str]
|
||||||
|
sources: dict
|
||||||
|
|
||||||
|
class UnsubscribeResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
# Helper function to get client IP
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract client IP address from request"""
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
# API endpoints
|
||||||
|
@app.post("/api/subscribe", response_model=EmailSignupResponse)
|
||||||
|
async def subscribe_email(signup: EmailSignup, request: Request):
|
||||||
|
"""
|
||||||
|
Subscribe an email address to the DevBox mailing list
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
# Check if email already exists
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM email_signups WHERE email = ?",
|
||||||
|
(signup.email,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return EmailSignupResponse(
|
||||||
|
success=False,
|
||||||
|
message="Email already subscribed",
|
||||||
|
email=signup.email
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get client information
|
||||||
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
|
||||||
|
# Insert new email
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO email_signups (email, source, created_at, user_agent, ip_address)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(signup.email, signup.source, datetime.utcnow(), user_agent, ip_address)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"New email subscription: {signup.email} from {ip_address}")
|
||||||
|
|
||||||
|
return EmailSignupResponse(
|
||||||
|
success=True,
|
||||||
|
message="Successfully subscribed to DevBox updates!",
|
||||||
|
email=signup.email,
|
||||||
|
id=cursor.lastrowid
|
||||||
|
)
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return EmailSignupResponse(
|
||||||
|
success=False,
|
||||||
|
message="Email already subscribed",
|
||||||
|
email=signup.email
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error subscribing email {signup.email}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/emails", response_model=EmailListResponse)
|
||||||
|
async def get_emails(limit: int = 100, offset: int = 0, source: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Get list of subscribed emails (for admin purposes)
|
||||||
|
Note: In production, add authentication/authorization
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
# Build query with optional source filter
|
||||||
|
base_query = "SELECT COUNT(*) as count FROM email_signups"
|
||||||
|
emails_query = """
|
||||||
|
SELECT id, email, source, created_at, ip_address
|
||||||
|
FROM email_signups
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = []
|
||||||
|
if source:
|
||||||
|
base_query += " WHERE source = ?"
|
||||||
|
emails_query += " WHERE source = ?"
|
||||||
|
params.append(source)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
total_count = conn.execute(base_query, params).fetchone()["count"]
|
||||||
|
|
||||||
|
# Get emails with pagination
|
||||||
|
emails_query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
emails = conn.execute(emails_query, params).fetchall()
|
||||||
|
|
||||||
|
# Convert to list of dicts
|
||||||
|
email_list = [dict(email) for email in emails]
|
||||||
|
|
||||||
|
return EmailListResponse(
|
||||||
|
total_count=total_count,
|
||||||
|
emails=email_list
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching emails: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/stats", response_model=StatsResponse)
|
||||||
|
async def get_stats():
|
||||||
|
"""
|
||||||
|
Get detailed statistics about email signups
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
# Get basic stats
|
||||||
|
stats = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_signups,
|
||||||
|
COUNT(CASE WHEN DATE(created_at) = DATE('now') THEN 1 END) as today_signups,
|
||||||
|
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-7 days') THEN 1 END) as week_signups,
|
||||||
|
COUNT(CASE WHEN DATE(created_at) >= DATE('now', '-30 days') THEN 1 END) as month_signups,
|
||||||
|
MIN(created_at) as first_signup,
|
||||||
|
MAX(created_at) as latest_signup
|
||||||
|
FROM email_signups
|
||||||
|
""").fetchone()
|
||||||
|
|
||||||
|
# Get source breakdown
|
||||||
|
sources = conn.execute("""
|
||||||
|
SELECT source, COUNT(*) as count
|
||||||
|
FROM email_signups
|
||||||
|
GROUP BY source
|
||||||
|
ORDER BY count DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
sources_dict = {row["source"]: row["count"] for row in sources}
|
||||||
|
|
||||||
|
return StatsResponse(
|
||||||
|
total_signups=stats["total_signups"],
|
||||||
|
today_signups=stats["today_signups"],
|
||||||
|
week_signups=stats["week_signups"],
|
||||||
|
month_signups=stats["month_signups"],
|
||||||
|
first_signup=stats["first_signup"],
|
||||||
|
latest_signup=stats["latest_signup"],
|
||||||
|
sources=sources_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching stats: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.delete("/api/unsubscribe/{email}", response_model=UnsubscribeResponse)
|
||||||
|
async def unsubscribe_email(email: str):
|
||||||
|
"""
|
||||||
|
Remove an email from the subscription list
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("DELETE FROM email_signups WHERE email = ?", (email,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
|
logger.info(f"Email unsubscribed: {email}")
|
||||||
|
|
||||||
|
return UnsubscribeResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"Email {email} successfully unsubscribed"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error unsubscribing email {email}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/export")
|
||||||
|
async def export_emails(source: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Export emails to CSV format
|
||||||
|
Note: In production, add authentication/authorization
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT email, source, created_at, ip_address
|
||||||
|
FROM email_signups
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if source:
|
||||||
|
query += " WHERE source = ?"
|
||||||
|
params.append(source)
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
|
||||||
|
emails = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
# Convert to CSV format
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# Write header
|
||||||
|
writer.writerow(['Email', 'Source', 'Created At', 'IP Address'])
|
||||||
|
|
||||||
|
# Write data
|
||||||
|
for email in emails:
|
||||||
|
writer.writerow([
|
||||||
|
email['email'],
|
||||||
|
email['source'],
|
||||||
|
email['created_at'],
|
||||||
|
email['ip_address']
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Create filename with timestamp
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"devbox_emails_{timestamp}.csv"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error exporting emails: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/api/search")
|
||||||
|
async def search_emails(q: str, limit: int = 50):
|
||||||
|
"""
|
||||||
|
Search emails by email address or domain
|
||||||
|
"""
|
||||||
|
if len(q) < 2:
|
||||||
|
raise HTTPException(status_code=400, detail="Search query must be at least 2 characters")
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
emails = conn.execute("""
|
||||||
|
SELECT id, email, source, created_at
|
||||||
|
FROM email_signups
|
||||||
|
WHERE email LIKE ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (f"%{q}%", limit)).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": q,
|
||||||
|
"count": len(emails),
|
||||||
|
"emails": [dict(email) for email in emails]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching emails: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""
|
||||||
|
Root endpoint with API information
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"message": "DevBox Email Collection API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"endpoints": {
|
||||||
|
"subscribe": "POST /api/subscribe",
|
||||||
|
"list_emails": "GET /api/emails",
|
||||||
|
"stats": "GET /api/stats",
|
||||||
|
"search": "GET /api/search?q=query",
|
||||||
|
"export": "GET /api/export",
|
||||||
|
"unsubscribe": "DELETE /api/unsubscribe/{email}",
|
||||||
|
"health": "GET /health",
|
||||||
|
"docs": "GET /docs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""
|
||||||
|
Health check endpoint
|
||||||
|
"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
# Test database connection
|
||||||
|
conn.execute("SELECT 1").fetchone()
|
||||||
|
db_status = "healthy"
|
||||||
|
except Exception as e:
|
||||||
|
db_status = f"unhealthy: {str(e)}"
|
||||||
|
logger.error(f"Database health check failed: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy" if db_status == "healthy" else "unhealthy",
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"database": db_status,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom exception handler
|
||||||
|
@app.exception_handler(404)
|
||||||
|
async def not_found_handler(request: Request, exc):
|
||||||
|
return {
|
||||||
|
"error": "Not Found",
|
||||||
|
"message": "The requested endpoint was not found",
|
||||||
|
"available_endpoints": [
|
||||||
|
"/",
|
||||||
|
"/health",
|
||||||
|
"/docs",
|
||||||
|
"/api/subscribe",
|
||||||
|
"/api/emails",
|
||||||
|
"/api/stats",
|
||||||
|
"/api/search",
|
||||||
|
"/api/export"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Check if running in development mode
|
||||||
|
import sys
|
||||||
|
dev_mode = "--dev" in sys.argv or os.getenv("DEV_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
if dev_mode:
|
||||||
|
# Development settings
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8080,
|
||||||
|
reload=True,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Production settings
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8080,
|
||||||
|
log_level="info"
|
||||||
|
)
|
46
devbox-back/pyproject.toml
Normal file
46
devbox-back/pyproject.toml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "devbox-email-api"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Email collection API for DevBox landing page"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "DevBox Team"},
|
||||||
|
]
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.104.0",
|
||||||
|
"uvicorn[standard]>=0.24.0",
|
||||||
|
"pydantic[email]>=2.5.0",
|
||||||
|
"python-multipart>=0.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"isort>=5.12.0",
|
||||||
|
"flake8>=6.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/yourusername/devbox-email-api"
|
||||||
|
Repository = "https://github.com/yourusername/devbox-email-api"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ['py38']
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 88
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
devbox-api = "main:main"
|
41
devbox-back/run.sh
Executable file
41
devbox-back/run.sh
Executable file
@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# DevBox Email Collection API - Simple Startup Script
|
||||||
|
|
||||||
|
echo "🚀 Starting DevBox Email Collection API..."
|
||||||
|
|
||||||
|
# Check if Python 3 is available
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "❌ Error: python3 not found"
|
||||||
|
echo "Please install Python 3"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Found Python: $(python3 --version)"
|
||||||
|
|
||||||
|
# Create virtual environment if it doesn't exist
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "📦 Creating virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
echo "🔧 Activating virtual environment..."
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies directly (simpler approach)
|
||||||
|
echo "📚 Installing dependencies..."
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install fastapi uvicorn[standard] pydantic[email] python-multipart
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Setup complete!"
|
||||||
|
echo "📍 API will be available at: http://localhost:8000"
|
||||||
|
echo "📖 API documentation at: http://localhost:8000/docs"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Starting server..."
|
||||||
|
echo "💡 Press Ctrl+C to stop"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
python main.py --dev
|
110
devbox-front/index.html
Normal file
110
devbox-front/index.html
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DevBox - DevOps Made Simple</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>DevBox</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="hero-section">
|
||||||
|
<h2 class="hero-title">DevOps Without the Headaches</h2>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
Focus on building great products while we handle HTTPS, monitoring, CI/CD, security,
|
||||||
|
logs, and backups. Complete DevOps infrastructure for indie developers and small businesses.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="email-form" id="emailForm">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="Enter your email for early access"
|
||||||
|
required
|
||||||
|
class="email-input"
|
||||||
|
>
|
||||||
|
<button type="submit" class="submit-btn">
|
||||||
|
Join the Waitlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">
|
||||||
|
Be the first to experience effortless DevOps. No spam, just launch updates.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="success-message" id="successMessage">
|
||||||
|
<div class="success-content">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h3>Welcome aboard!</h3>
|
||||||
|
<p>We'll notify you as soon as DevBox is ready to simplify your deployments.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features-preview">
|
||||||
|
<div class="feature-grid">
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">🔒</div>
|
||||||
|
<h3>Auto HTTPS & Security</h3>
|
||||||
|
<p>Automatic SSL certificates, security headers, and vulnerability scanning out of the box</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<h3>Monitoring & Logs</h3>
|
||||||
|
<p>Real-time metrics, centralized logging, and intelligent alerting without configuration</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">🚀</div>
|
||||||
|
<h3>One-Click CI/CD</h3>
|
||||||
|
<p>Push to deploy with automated testing, staging environments, and rollback capabilities</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">💾</div>
|
||||||
|
<h3>Automated Backups</h3>
|
||||||
|
<p>Daily encrypted backups with point-in-time recovery and cross-region replication</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<h3>Scale on Demand</h3>
|
||||||
|
<p>Auto-scaling infrastructure that grows with your business without manual intervention</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<div class="feature-icon">🎯</div>
|
||||||
|
<h3>Developer First</h3>
|
||||||
|
<p>Simple APIs, intuitive dashboard, and integrations with your favorite tools</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="value-proposition">
|
||||||
|
<h3 class="value-title">Stop Fighting Infrastructure</h3>
|
||||||
|
<p class="value-text">
|
||||||
|
Spend your time building features your customers love, not wrestling with
|
||||||
|
Kubernetes configs, SSL certificates, and monitoring dashboards.
|
||||||
|
DevBox handles the complexity so you don't have to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="social-links">
|
||||||
|
<a href="#" class="social-link">Twitter</a>
|
||||||
|
<a href="#" class="social-link">GitHub</a>
|
||||||
|
<a href="#" class="social-link">LinkedIn</a>
|
||||||
|
</div>
|
||||||
|
<p class="footer-text">© 2025 DevBox. Built for developers, by developers.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
270
devbox-front/script.js
Normal file
270
devbox-front/script.js
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const emailForm = document.getElementById('emailForm');
|
||||||
|
const successMessage = document.getElementById('successMessage');
|
||||||
|
const API_BASE_URL = 'http://localhost:8080'; // Backend API URL
|
||||||
|
|
||||||
|
emailForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const emailInput = document.getElementById('email');
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
showError('Please enter a valid email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit email to backend
|
||||||
|
submitEmail(email);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isValidEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitEmail(email) {
|
||||||
|
// Show loading state
|
||||||
|
const submitBtn = document.querySelector('.submit-btn');
|
||||||
|
const originalText = submitBtn.textContent;
|
||||||
|
submitBtn.textContent = 'Joining...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.classList.add('loading');
|
||||||
|
|
||||||
|
// Send to backend
|
||||||
|
fetch(`${API_BASE_URL}/api/subscribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
source: 'devbox-landing'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Hide form and show success message
|
||||||
|
emailForm.style.display = 'none';
|
||||||
|
successMessage.classList.add('show');
|
||||||
|
console.log('DevBox waitlist email submitted:', email);
|
||||||
|
|
||||||
|
// Optional: Track the successful signup
|
||||||
|
trackEmailSignup(email);
|
||||||
|
} else {
|
||||||
|
// Show error message
|
||||||
|
showError(data.message || 'Email already subscribed or invalid.');
|
||||||
|
// Reset button
|
||||||
|
resetSubmitButton(submitBtn, originalText);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showError('Network error. Please check your connection and try again.');
|
||||||
|
// Reset button
|
||||||
|
resetSubmitButton(submitBtn, originalText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSubmitButton(submitBtn, originalText) {
|
||||||
|
submitBtn.textContent = originalText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.classList.remove('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
// Remove existing error messages
|
||||||
|
const existingError = document.querySelector('.error-message');
|
||||||
|
if (existingError) {
|
||||||
|
existingError.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and show error message
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'error-message';
|
||||||
|
errorDiv.style.cssText = `
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
`;
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
|
||||||
|
emailForm.appendChild(errorDiv);
|
||||||
|
|
||||||
|
// Remove error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (errorDiv.parentNode) {
|
||||||
|
errorDiv.style.animation = 'slideUp 0.3s ease';
|
||||||
|
setTimeout(() => {
|
||||||
|
errorDiv.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackEmailSignup(email) {
|
||||||
|
// Optional: Add analytics tracking here
|
||||||
|
// Example: Google Analytics, Mixpanel, etc.
|
||||||
|
console.log(`📧 Email signup tracked: ${email} at ${new Date().toISOString()}`);
|
||||||
|
|
||||||
|
// You could also send additional tracking data to your backend
|
||||||
|
// or third-party analytics services here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add smooth scrolling for any anchor links
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||||
|
anchor.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.querySelector(this.getAttribute('href'));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add subtle animation to feature cards on scroll (Intersection Observer)
|
||||||
|
const observerOptions = {
|
||||||
|
threshold: 0.1,
|
||||||
|
rootMargin: '0px 0px -50px 0px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.style.opacity = '1';
|
||||||
|
entry.target.style.transform = 'translateY(0)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
// Observe feature items for animation
|
||||||
|
document.querySelectorAll('.feature-item').forEach(item => {
|
||||||
|
// Set initial state
|
||||||
|
item.style.opacity = '0';
|
||||||
|
item.style.transform = 'translateY(20px)';
|
||||||
|
item.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
||||||
|
|
||||||
|
// Start observing
|
||||||
|
observer.observe(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add keyboard accessibility
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
// Close error messages with Escape key
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const errorMessage = document.querySelector('.error-message');
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form validation enhancements
|
||||||
|
const emailInput = document.getElementById('email');
|
||||||
|
|
||||||
|
emailInput.addEventListener('input', function() {
|
||||||
|
// Remove error styling when user starts typing
|
||||||
|
const errorMessage = document.querySelector('.error-message');
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time validation feedback
|
||||||
|
if (this.value.length > 0) {
|
||||||
|
if (isValidEmail(this.value)) {
|
||||||
|
this.style.borderColor = '#10b981';
|
||||||
|
this.style.boxShadow = '0 0 0 1px rgba(16, 185, 129, 0.2)';
|
||||||
|
} else {
|
||||||
|
this.style.borderColor = '#ef4444';
|
||||||
|
this.style.boxShadow = '0 0 0 1px rgba(239, 68, 68, 0.2)';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset to default styling
|
||||||
|
this.style.borderColor = '';
|
||||||
|
this.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add focus states for better accessibility
|
||||||
|
emailInput.addEventListener('focus', function() {
|
||||||
|
this.parentElement.style.boxShadow = '0 0 0 3px rgba(50, 108, 229, 0.1)';
|
||||||
|
});
|
||||||
|
|
||||||
|
emailInput.addEventListener('blur', function() {
|
||||||
|
this.parentElement.style.boxShadow = '';
|
||||||
|
// Reset border styling
|
||||||
|
this.style.borderColor = '';
|
||||||
|
this.style.boxShadow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple analytics: track page engagement
|
||||||
|
let startTime = Date.now();
|
||||||
|
let maxScroll = 0;
|
||||||
|
|
||||||
|
window.addEventListener('scroll', function() {
|
||||||
|
const scrollPercent = (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
|
||||||
|
maxScroll = Math.max(maxScroll, scrollPercent);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
const timeOnPage = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
console.log(`📊 Session stats: ${timeOnPage}s on page, ${Math.round(maxScroll)}% max scroll`);
|
||||||
|
|
||||||
|
// You could send this data to your analytics endpoint
|
||||||
|
// fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ timeOnPage, maxScroll }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add some visual polish: animate elements on load
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.add('loaded');
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
console.log('🚀 DevBox landing page initialized');
|
||||||
|
console.log(`📡 API endpoint: ${API_BASE_URL}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add CSS animations via JavaScript (if not in CSS file)
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
362
devbox-front/styles.css
Normal file
362
devbox-front/styles.css
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Kubernetes-inspired blue color palette */
|
||||||
|
--primary-blue: #326ce5;
|
||||||
|
--dark-blue: #1e3a8a;
|
||||||
|
--light-blue: #60a5fa;
|
||||||
|
--navy: #1e293b;
|
||||||
|
--slate: #334155;
|
||||||
|
--gray-light: #f8fafc;
|
||||||
|
--gray-medium: #64748b;
|
||||||
|
--success-green: #10b981;
|
||||||
|
--text-dark: #0f172a;
|
||||||
|
--text-light: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-dark);
|
||||||
|
background: linear-gradient(135deg, var(--primary-blue) 0%, var(--dark-blue) 50%, var(--navy) 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
max-width: 700px;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Email Form */
|
||||||
|
.email-form {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-input::placeholder {
|
||||||
|
color: var(--gray-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
background: linear-gradient(135deg, var(--primary-blue) 0%, var(--dark-blue) 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1.2rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 12px rgba(50, 108, 229, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(50, 108, 229, 0.6);
|
||||||
|
background: linear-gradient(135deg, var(--light-blue) 0%, var(--primary-blue) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success Message */
|
||||||
|
.success-message {
|
||||||
|
display: none;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 450px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message.show {
|
||||||
|
display: block;
|
||||||
|
animation: slideUp 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--success-green);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-content h3 {
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-content p {
|
||||||
|
color: var(--text-light);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features Preview */
|
||||||
|
.features-preview {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item h3 {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item p {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Value Proposition */
|
||||||
|
.value-proposition {
|
||||||
|
max-width: 600px;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-text {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-links {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-proposition {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-proposition {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state for button */
|
||||||
|
.submit-btn.loading {
|
||||||
|
background: var(--gray-medium);
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn.loading:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
Reference in New Issue
Block a user