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:
9
backend/migrations/002_early_access_signups.sql
Normal file
9
backend/migrations/002_early_access_signups.sql
Normal 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);
|
||||
76
backend/src/api/early_access.rs
Normal file
76
backend/src/api/early_access.rs
Normal 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(),
|
||||
}))
|
||||
}
|
||||
@@ -11,3 +11,4 @@ pub mod team;
|
||||
pub mod notifications;
|
||||
pub mod license;
|
||||
pub mod store;
|
||||
pub mod early_access;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user