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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# waitlist logs
|
||||||
|
/waitlist-logs/
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue