feat: Build Early Access page with backend email capture

Combined page: countdown timer (Feb 28), email capture with server
count segmentation (wired to POST /api/early-access), Founding Admin
Program (25 slots), demo dashboard preview placeholders, roadmap
voting, and launch timeline. Backend: Axum handler, migration for
early_access_signups table with email + server_count + created_at.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-14 23:51:55 -05:00
parent 597f2ec379
commit d91ceb5b24
6 changed files with 458 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
-- Early access email capture with server count segmentation
CREATE TABLE IF NOT EXISTS early_access_signups (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) NOT NULL UNIQUE,
server_count VARCHAR(10) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_early_access_email ON early_access_signups(email);

View File

@@ -0,0 +1,76 @@
use std::sync::Arc;
use axum::{
extract::State,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::models::error::{ApiError, ApiResult};
use crate::AppState;
#[derive(Deserialize)]
pub struct EarlyAccessRequest {
pub email: String,
pub server_count: String,
}
#[derive(Serialize)]
pub struct EarlyAccessResponse {
pub success: bool,
pub message: String,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new().route("/", post(submit_early_access))
}
async fn submit_early_access(
State(state): State<Arc<AppState>>,
Json(payload): Json<EarlyAccessRequest>,
) -> ApiResult<Json<EarlyAccessResponse>> {
// Basic validation
let email = payload.email.trim().to_lowercase();
if email.is_empty() || !email.contains('@') {
return Err(ApiError::BadRequest("Invalid email address".to_string()));
}
let valid_counts = ["1", "2-3", "4+"];
if !valid_counts.contains(&payload.server_count.as_str()) {
return Err(ApiError::BadRequest("Invalid server count".to_string()));
}
// Check for duplicate
let existing: Option<(i64,)> = sqlx::query_as(
"SELECT COUNT(*) FROM early_access_signups WHERE email = $1"
)
.bind(&email)
.fetch_optional(&state.db)
.await?;
if let Some((count,)) = existing {
if count > 0 {
return Ok(Json(EarlyAccessResponse {
success: true,
message: "You're already on the list!".to_string(),
}));
}
}
// Insert signup
sqlx::query(
"INSERT INTO early_access_signups (email, server_count) VALUES ($1, $2)"
)
.bind(&email)
.bind(&payload.server_count)
.execute(&state.db)
.await?;
tracing::info!("Early access signup: {} (servers: {})", email, payload.server_count);
Ok(Json(EarlyAccessResponse {
success: true,
message: "You're on the list!".to_string(),
}))
}

View File

@@ -11,3 +11,4 @@ pub mod team;
pub mod notifications;
pub mod license;
pub mod store;
pub mod early_access;

View File

@@ -88,6 +88,7 @@ async fn main() -> anyhow::Result<()> {
.nest("/api/notifications", api::notifications::router())
.nest("/api/license", api::license::router())
.nest("/api/store", api::store::router())
.nest("/api/early-access", api::early_access::router())
.layer(cors)
.layer(TraceLayer::new_for_http())
.with_state(state);