From d91ceb5b246e8f3cc66e1ea13aa0fed1787c4717 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 14 Feb 2026 23:51:55 -0500 Subject: [PATCH] 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 --- .../migrations/002_early_access_signups.sql | 9 + backend/src/api/early_access.rs | 76 ++++ backend/src/api/mod.rs | 1 + backend/src/main.rs | 1 + frontend/src/router/index.ts | 5 + .../src/views/marketing/EarlyAccessView.vue | 366 ++++++++++++++++++ 6 files changed, 458 insertions(+) create mode 100644 backend/migrations/002_early_access_signups.sql create mode 100644 backend/src/api/early_access.rs create mode 100644 frontend/src/views/marketing/EarlyAccessView.vue diff --git a/backend/migrations/002_early_access_signups.sql b/backend/migrations/002_early_access_signups.sql new file mode 100644 index 0000000..8e4196e --- /dev/null +++ b/backend/migrations/002_early_access_signups.sql @@ -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); diff --git a/backend/src/api/early_access.rs b/backend/src/api/early_access.rs new file mode 100644 index 0000000..79aed70 --- /dev/null +++ b/backend/src/api/early_access.rs @@ -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> { + Router::new().route("/", post(submit_early_access)) +} + +async fn submit_early_access( + State(state): State>, + Json(payload): Json, +) -> ApiResult> { + // 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(), + })) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 9646619..bf801ce 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -11,3 +11,4 @@ pub mod team; pub mod notifications; pub mod license; pub mod store; +pub mod early_access; diff --git a/backend/src/main.rs b/backend/src/main.rs index 9343fd7..abb3d0d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 50f1fdd..f34dd26 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -164,6 +164,11 @@ const routes: RouteRecordRaw[] = [ name: 'roadmap', component: () => import('@/views/marketing/RoadmapView.vue'), }, + { + path: 'early-access', + name: 'early-access', + component: () => import('@/views/marketing/EarlyAccessView.vue'), + }, ], }, diff --git a/frontend/src/views/marketing/EarlyAccessView.vue b/frontend/src/views/marketing/EarlyAccessView.vue new file mode 100644 index 0000000..5ccdcca --- /dev/null +++ b/frontend/src/views/marketing/EarlyAccessView.vue @@ -0,0 +1,366 @@ + + +