feat: store emails

This commit is contained in:
Oleg Proskurin 2025-12-14 13:36:34 +07:00
parent 6650c03188
commit 41f00aa352
7 changed files with 295 additions and 27 deletions

View File

@ -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

View File

@ -32,6 +32,10 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# waitlist logs
/waitlist-logs/
# vercel # vercel
.vercel .vercel

View File

@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; 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 GlowEffect from './GlowEffect';
import WaitlistPopup from './WaitlistPopup'; import WaitlistPopup from './WaitlistPopup';
import { submitEmail, submitWaitlistData } from '@/lib/actions/waitlistActions';
export const styles = ` export const styles = `
.gradient-text { .gradient-text {
@ -34,6 +35,44 @@ export const styles = `
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 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 = [ const badges = [
@ -44,21 +83,42 @@ const badges = [
{ icon: Link, text: 'Prompt URLs', variant: 'cyan' }, { icon: Link, text: 'Prompt URLs', variant: 'cyan' },
]; ];
const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
export function HeroSection() { export function HeroSection() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [isInvalid, setIsInvalid] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [showPopup, setShowPopup] = 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(); e.preventDefault();
console.log('Email submitted:', email); if (!isValidEmail(email)) {
setShowPopup(true); 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 }) => { const handleWaitlistSubmit = async (data: { selected: string[]; other: string }) => {
await fetch('/api/brevo', { await submitWaitlistData(email, data);
method: 'POST',
body: JSON.stringify({ email, useCases: data.selected, other: data.other }),
});
}; };
return ( return (
@ -82,25 +142,44 @@ export function HeroSection() {
</p> </p>
<GlowEffect> <GlowEffect>
<form {isSubmitted ? (
onSubmit={handleSubmit} <div
className="flex flex-col sm:flex-row gap-2 rounded-[10px] p-1.5 sm:pl-3" className="flex items-center justify-center gap-3 px-4 py-3 rounded-[10px]"
style={{ background: 'rgba(10, 6, 18, 0.95)' }} style={{ background: 'rgba(10, 6, 18, 0.95)' }}
>
<input
type="email"
value={email}
onChange={(e) => 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]"
/>
<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"
> >
Get Early Access <div className="success-checkmark-ring">
</button> <div className="success-checkmark-inner">
</form> <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"
style={{ background: 'rgba(10, 6, 18, 0.95)' }}
>
<input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="your@email.com"
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"
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> </GlowEffect>
<p className="text-sm text-gray-500 mb-20">Free early access. No credit card required.</p> <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> </div>
<WaitlistPopup <WaitlistPopup
isOpen={showPopup} isOpen={showPopup}
onClose={() => setShowPopup(false)} onClose={handlePopupClose}
onSubmit={handleWaitlistSubmit} onSubmit={handleWaitlistSubmit}
/> />
</section> </section>

View File

@ -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' };
}
}

View File

@ -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');
}

View File

@ -49,5 +49,11 @@ UPLOADS_DIR=/app/uploads/temp
TTI_LOG=logs/tti-log.md TTI_LOG=logs/tti-log.md
ENH_LOG=logs/enhancing.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) # IMPORTANT: Sensitive values should be in secrets.env (not tracked in git)
# See secrets.env.example for required variables # See secrets.env.example for required variables

View File

@ -35,6 +35,11 @@ services:
container_name: banatie-landing container_name: banatie-landing
ports: ports:
- "3001:3000" - "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: networks:
- banatie-network - banatie-network
depends_on: depends_on:
@ -45,6 +50,7 @@ services:
environment: environment:
- IS_DOCKER=true - IS_DOCKER=true
- NODE_ENV=production - NODE_ENV=production
- WAITLIST_LOGS_PATH=/app/waitlist-logs
restart: unless-stopped restart: unless-stopped
# PostgreSQL Database # PostgreSQL Database