feat: store emails
This commit is contained in:
parent
6650c03188
commit
41f00aa352
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
setEmail(e.target.value);
|
||||
if (isInvalid) setIsInvalid(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log('Email submitted:', email);
|
||||
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,6 +142,19 @@ export function HeroSection() {
|
|||
</p>
|
||||
|
||||
<GlowEffect>
|
||||
{isSubmitted ? (
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 px-4 py-3 rounded-[10px]"
|
||||
style={{ background: 'rgba(10, 6, 18, 0.95)' }}
|
||||
>
|
||||
<div className="success-checkmark-ring">
|
||||
<div className="success-checkmark-inner">
|
||||
<Check className="w-3 h-3 text-white" strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-white font-medium">Done! You're in the list</span>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col sm:flex-row gap-2 rounded-[10px] p-1.5 sm:pl-3"
|
||||
|
|
@ -90,17 +163,23 @@ export function HeroSection() {
|
|||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onChange={handleEmailChange}
|
||||
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]"
|
||||
className={`flex-1 px-4 py-3 bg-transparent border-none rounded-md outline-none placeholder:text-white/40 focus:bg-white/[0.03] ${
|
||||
isInvalid ? 'text-red-400' : 'text-white'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 rounded-md text-white font-semibold cursor-pointer transition-all whitespace-nowrap"
|
||||
disabled={!isEnabled}
|
||||
className={`hero-btn px-6 py-3 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-md text-white font-semibold transition-all whitespace-nowrap ${
|
||||
isEnabled ? 'enabled hover:from-indigo-600 hover:to-purple-600' : 'disabled'
|
||||
}`}
|
||||
>
|
||||
Get Early Access
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</GlowEffect>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-20">Free early access. No credit card required.</p>
|
||||
|
|
@ -125,7 +204,7 @@ export function HeroSection() {
|
|||
</div>
|
||||
<WaitlistPopup
|
||||
isOpen={showPopup}
|
||||
onClose={() => setShowPopup(false)}
|
||||
onClose={handlePopupClose}
|
||||
onSubmit={handleWaitlistSubmit}
|
||||
/>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
'use server';
|
||||
|
||||
import { storeMail, updateMail } from '@/lib/logger/waitlistLogger';
|
||||
|
||||
const submissionTimestamps = new Map<string, number[]>();
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue