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 notifications;
pub mod license; pub mod license;
pub mod store; 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/notifications", api::notifications::router())
.nest("/api/license", api::license::router()) .nest("/api/license", api::license::router())
.nest("/api/store", api::store::router()) .nest("/api/store", api::store::router())
.nest("/api/early-access", api::early_access::router())
.layer(cors) .layer(cors)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);

View File

@@ -164,6 +164,11 @@ const routes: RouteRecordRaw[] = [
name: 'roadmap', name: 'roadmap',
component: () => import('@/views/marketing/RoadmapView.vue'), component: () => import('@/views/marketing/RoadmapView.vue'),
}, },
{
path: 'early-access',
name: 'early-access',
component: () => import('@/views/marketing/EarlyAccessView.vue'),
},
], ],
}, },

View File

@@ -0,0 +1,366 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Shield, Users, Star, MessageCircle, Clock, ChevronRight, Check, Zap, Terminal, RefreshCw, LayoutDashboard } from 'lucide-vue-next'
// ---------- Countdown ----------
const targetDate = new Date('2026-02-28T12:00:00-05:00')
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval>
const countdown = computed(() => {
const diff = Math.max(0, targetDate.getTime() - now.value)
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
return { days, hours, minutes, seconds }
})
onMounted(() => {
timer = setInterval(() => { now.value = Date.now() }, 1000)
})
onUnmounted(() => clearInterval(timer))
// ---------- Email capture ----------
const email = ref('')
const serverCount = ref('')
const submitting = ref(false)
const submitted = ref(false)
const errorMsg = ref('')
async function handleSubmit() {
if (!email.value || !serverCount.value) return
errorMsg.value = ''
submitting.value = true
try {
const res = await fetch('/api/early-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.value,
server_count: serverCount.value,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({ message: 'Something went wrong' }))
throw new Error(data.message || `HTTP ${res.status}`)
}
submitted.value = true
} catch (err: unknown) {
errorMsg.value = err instanceof Error ? err.message : 'Something went wrong'
} finally {
submitting.value = false
}
}
// ---------- Demo panels ----------
const panels = [
{ label: 'Dashboard', icon: LayoutDashboard, desc: 'Server overview, player count, uptime, and alerts at a glance.' },
{ label: 'Wipe Scheduler', icon: RefreshCw, desc: 'Visual wipe timeline with pre-wipe backup, map rotation, and health verification.' },
{ label: 'Plugin Config', icon: Zap, desc: 'Edit plugin settings from your browser. No JSON. No SFTP.' },
{ label: 'Player Management', icon: Users, desc: 'Online players, session tracking, kick/ban controls, and playtime history.' },
{ label: 'Console', icon: Terminal, desc: 'Real-time RCON console with timestamped, color-coded output.' },
]
// ---------- Roadmap voting ----------
interface VoteItem {
id: string
label: string
votes: number
voted: boolean
}
const voteItems = ref<VoteItem[]>([
{ id: 'analytics', label: 'Analytics & Retention Insights', votes: 47, voted: false },
{ id: 'webstore', label: 'Integrated Webstore', votes: 38, voted: false },
{ id: 'modules', label: 'Module Marketplace', votes: 31, voted: false },
{ id: 'discord', label: 'Discord Bot Integration', votes: 28, voted: false },
{ id: 'hosting', label: 'Hosting Provider API', votes: 19, voted: false },
])
function vote(item: VoteItem) {
if (item.voted) return
item.votes++
item.voted = true
}
const totalVotes = computed(() => voteItems.value.reduce((sum, i) => sum + i.votes, 0))
</script>
<template>
<div>
<!-- Hero -->
<section class="relative overflow-hidden">
<div class="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
<span class="inline-block px-4 py-1.5 bg-oxide-500/10 border border-oxide-500/20 rounded-full text-oxide-400 text-sm font-medium mb-6">
Early Access Opening Soon
</span>
<h1 class="text-4xl md:text-5xl font-bold text-neutral-100 mb-4 tracking-tight">
Wipe Night Is About to<br />
<span class="text-oxide-500">Get Easier.</span>
</h1>
<p class="text-lg text-neutral-400 max-w-xl mx-auto mb-10">
Corrosion is entering limited early access. Install once. Automate everything. Never SSH again.
</p>
<div class="flex items-center justify-center gap-4">
<a href="#join" class="px-8 py-3.5 bg-oxide-600 hover:bg-oxide-700 text-white font-semibold rounded-lg transition-colors">
Join Early Access
</a>
<a href="#demo" class="px-8 py-3.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-semibold rounded-lg border border-neutral-700 transition-colors">
View Demo Architecture
</a>
</div>
</div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-oxide-500/8 rounded-full blur-3xl pointer-events-none" />
</section>
<!-- Countdown -->
<section class="py-12 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<p class="text-sm text-neutral-500 uppercase tracking-wider mb-6">Early Access Opens In</p>
<div class="flex items-center justify-center gap-4 md:gap-6">
<div class="text-center">
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
<span class="text-3xl font-bold text-oxide-400 tabular-nums">{{ countdown.days }}</span>
</div>
<p class="text-xs text-neutral-500 mt-2">Days</p>
</div>
<span class="text-2xl text-neutral-700 font-light">:</span>
<div class="text-center">
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
<span class="text-3xl font-bold text-oxide-400 tabular-nums">{{ String(countdown.hours).padStart(2, '0') }}</span>
</div>
<p class="text-xs text-neutral-500 mt-2">Hours</p>
</div>
<span class="text-2xl text-neutral-700 font-light">:</span>
<div class="text-center">
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
<span class="text-3xl font-bold text-neutral-200 tabular-nums">{{ String(countdown.minutes).padStart(2, '0') }}</span>
</div>
<p class="text-xs text-neutral-500 mt-2">Minutes</p>
</div>
<span class="text-2xl text-neutral-700 font-light">:</span>
<div class="text-center">
<div class="w-20 h-20 bg-neutral-900 border border-neutral-800 rounded-xl flex items-center justify-center">
<span class="text-3xl font-bold text-neutral-200 tabular-nums">{{ String(countdown.seconds).padStart(2, '0') }}</span>
</div>
<p class="text-xs text-neutral-500 mt-2">Seconds</p>
</div>
</div>
</div>
</section>
<!-- What Early Access Means -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-4xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">What Early Access Means</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Shield class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Limited Founding Licenses</p>
<p class="text-xs text-neutral-500 mt-1">2550 spots</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<MessageCircle class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Direct Founder Discord</p>
<p class="text-xs text-neutral-500 mt-1">Private channel access</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Star class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Influence the Roadmap</p>
<p class="text-xs text-neutral-500 mt-1">Vote on features</p>
</div>
<div class="p-5 bg-neutral-900 border border-neutral-800 rounded-xl text-center">
<Clock class="w-6 h-6 text-oxide-500 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-200">Lifetime Pricing Lock</p>
<p class="text-xs text-neutral-500 mt-1">Never pay more</p>
</div>
</div>
</div>
</section>
<!-- Email Capture -->
<section id="join" class="py-16 border-t border-neutral-800">
<div class="max-w-md mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Get on the List</h2>
<p class="text-neutral-400 text-center mb-8">Be first to know when early access opens.</p>
<div v-if="submitted" class="bg-green-500/10 border border-green-500/20 rounded-xl p-8 text-center">
<Check class="w-10 h-10 text-green-400 mx-auto mb-3" />
<h3 class="text-lg font-semibold text-neutral-100 mb-1">You're on the list.</h3>
<p class="text-sm text-neutral-400">We'll reach out when early access opens.</p>
</div>
<form v-else @submit.prevent="handleSubmit" class="space-y-4">
<div v-if="errorMsg" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{{ errorMsg }}
</div>
<div>
<label for="ea-email" class="block text-sm font-medium text-neutral-400 mb-1.5">Email</label>
<input
id="ea-email"
v-model="email"
type="email"
required
placeholder="admin@example.com"
class="w-full px-3 py-2.5 bg-neutral-900 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label for="ea-servers" class="block text-sm font-medium text-neutral-400 mb-1.5">How many servers do you run?</label>
<div class="grid grid-cols-3 gap-3">
<button
v-for="option in ['1', '2-3', '4+']"
:key="option"
type="button"
@click="serverCount = option"
class="py-2.5 text-sm font-medium rounded-lg border transition-colors"
:class="serverCount === option
? 'bg-oxide-500/15 border-oxide-500/40 text-oxide-400'
: 'bg-neutral-900 border-neutral-700 text-neutral-400 hover:border-neutral-600'"
>
{{ option }}
</button>
</div>
</div>
<button
type="submit"
:disabled="submitting || !email || !serverCount"
class="w-full py-3 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
>
{{ submitting ? 'Submitting...' : 'Join Early Access' }}
</button>
</form>
</div>
</section>
<!-- Founding Admin Program -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-3xl mx-auto px-6 text-center">
<span class="inline-block px-3 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-full text-oxide-400 text-xs font-semibold uppercase tracking-wider mb-4">
Limited to 25 Servers
</span>
<h2 class="text-3xl font-bold text-neutral-100 mb-4">Founding Admin Program</h2>
<p class="text-neutral-400 mb-8">
The first 25 servers to run Corrosion receive:
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Star class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Founding Admin Role</p>
<p class="text-xs text-neutral-500 mt-1">Discord badge</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Clock class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Permanent Early Pricing</p>
<p class="text-xs text-neutral-500 mt-1">Locked forever</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<Users class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Public Recognition</p>
<p class="text-xs text-neutral-500 mt-1">Featured server</p>
</div>
<div class="p-5 bg-neutral-900 border border-oxide-500/20 rounded-xl">
<MessageCircle class="w-5 h-5 text-oxide-500 mx-auto mb-2" />
<p class="text-sm font-medium text-neutral-200">Direct Feature Input</p>
<p class="text-xs text-neutral-500 mt-1">Private channel</p>
</div>
</div>
</div>
</section>
<!-- Demo Dashboard Preview -->
<section id="demo" class="py-16 border-t border-neutral-800">
<div class="max-w-5xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">Preview of Corrosion 1.0 Dashboard</h2>
<p class="text-neutral-500 text-center mb-10">Screenshots will replace these frames at launch.</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="panel in panels"
:key="panel.label"
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-neutral-700 transition-colors"
>
<div class="h-36 bg-neutral-800/50 flex items-center justify-center border-b border-neutral-800">
<component :is="panel.icon" class="w-10 h-10 text-neutral-700" />
</div>
<div class="p-4">
<h3 class="text-sm font-semibold text-neutral-200 mb-1">{{ panel.label }}</h3>
<p class="text-xs text-neutral-500 leading-relaxed">{{ panel.desc }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- Roadmap Voting -->
<section class="py-16 bg-neutral-900/50 border-t border-neutral-800">
<div class="max-w-2xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-2 text-center">What Should We Build Next?</h2>
<p class="text-neutral-500 text-center mb-8">Vote on the features that matter most to you.</p>
<div class="space-y-3">
<button
v-for="item in voteItems"
:key="item.id"
@click="vote(item)"
class="w-full flex items-center gap-4 p-4 bg-neutral-900 border rounded-xl transition-colors text-left"
:class="item.voted ? 'border-oxide-500/30' : 'border-neutral-800 hover:border-neutral-700'"
>
<div class="flex-1">
<p class="text-sm font-medium" :class="item.voted ? 'text-oxide-400' : 'text-neutral-200'">{{ item.label }}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<div class="w-24 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="item.voted ? 'bg-oxide-500' : 'bg-neutral-600'"
:style="{ width: `${totalVotes ? (item.votes / totalVotes) * 100 : 0}%` }"
/>
</div>
<span class="text-xs font-medium tabular-nums w-8 text-right" :class="item.voted ? 'text-oxide-400' : 'text-neutral-500'">
{{ item.votes }}
</span>
</div>
</button>
</div>
</div>
</section>
<!-- Timeline -->
<section class="py-16 border-t border-neutral-800">
<div class="max-w-2xl mx-auto px-6">
<h2 class="text-2xl font-bold text-neutral-100 mb-8 text-center">Launch Timeline</h2>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-green-500/10 border border-green-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<Check class="w-4 h-4 text-green-400" />
</div>
<div>
<p class="text-sm font-medium text-neutral-200">Week 1 Closed Beta Stabilization</p>
<p class="text-xs text-neutral-500 mt-0.5">Core platform hardening and testing.</p>
</div>
</div>
<div class="ml-4 w-px h-4 bg-neutral-800" />
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-oxide-500/10 border border-oxide-500/20 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<ChevronRight class="w-4 h-4 text-oxide-400" />
</div>
<div>
<p class="text-sm font-medium text-oxide-400">Week 2 Early Access Opens</p>
<p class="text-xs text-neutral-500 mt-0.5">Founding Admin licenses go live.</p>
</div>
</div>
<div class="ml-4 w-px h-4 bg-neutral-800" />
<div class="flex items-start gap-4">
<div class="w-8 h-8 bg-neutral-800 border border-neutral-700 rounded-full flex items-center justify-center shrink-0 mt-0.5">
<ChevronRight class="w-4 h-4 text-neutral-500" />
</div>
<div>
<p class="text-sm font-medium text-neutral-400">Public Release</p>
<p class="text-xs text-neutral-500 mt-0.5">Shortly after early access stabilization.</p>
</div>
</div>
</div>
</div>
</section>
</div>
</template>