feat: add pixel terminal modal for email capture
Fake Claude Code console that boots up and prompts for email. Triggers after 15s or 60% scroll. Esc/click-outside to dismiss. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { EmailReveal } from "./email-reveal";
|
import { EmailReveal } from "./email-reveal";
|
||||||
import { NewsletterForm } from "./newsletter-form";
|
import { NewsletterForm } from "./newsletter-form";
|
||||||
|
import { SubscribeModal } from "./subscribe-modal";
|
||||||
import { TerminalCascade } from "./terminal-cascade";
|
import { TerminalCascade } from "./terminal-cascade";
|
||||||
import {
|
import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -92,6 +93,7 @@ function FeatureBlock({
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg selection:bg-accent/30">
|
<div className="min-h-screen bg-bg selection:bg-accent/30">
|
||||||
|
<SubscribeModal />
|
||||||
{/* ══════ HERO ══════ */}
|
{/* ══════ HERO ══════ */}
|
||||||
<section className="relative overflow-hidden scanlines">
|
<section className="relative overflow-hidden scanlines">
|
||||||
{/* Grid background */}
|
{/* Grid background */}
|
||||||
|
|||||||
196
site/app/subscribe-modal.tsx
Normal file
196
site/app/subscribe-modal.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
const API_URL = "https://alezmad-nuc.tail58f5ad.ts.net";
|
||||||
|
|
||||||
|
type Phase = "boot" | "prompt" | "sending" | "done";
|
||||||
|
|
||||||
|
const BOOT_LINES = [
|
||||||
|
{ text: "$ cladm --subscribe", delay: 0 },
|
||||||
|
{ text: "Scanning newsletters...", delay: 400, dim: true },
|
||||||
|
{ text: "Found: cladm releases, new tools, project updates", delay: 800, dim: true },
|
||||||
|
{ text: "", delay: 1100 },
|
||||||
|
{ text: "Ready. Enter your email to subscribe.", delay: 1200, accent: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SubscribeModal() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [phase, setPhase] = useState<Phase>("boot");
|
||||||
|
const [bootIndex, setBotIndex] = useState(0);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Show modal after 15s or on scroll past 60%
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionStorage.getItem("cladm-subscribed")) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setOpen(true), 15000);
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const pct = window.scrollY / (document.body.scrollHeight - window.innerHeight);
|
||||||
|
if (pct > 0.6) {
|
||||||
|
setOpen(true);
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Boot sequence
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || phase !== "boot") return;
|
||||||
|
if (bootIndex >= BOOT_LINES.length) {
|
||||||
|
setPhase("prompt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = setTimeout(
|
||||||
|
() => setBotIndex((i) => i + 1),
|
||||||
|
(BOOT_LINES[bootIndex]?.delay ?? 0) - (bootIndex > 0 ? BOOT_LINES[bootIndex - 1]?.delay ?? 0 : 0) || 300
|
||||||
|
);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [open, phase, bootIndex]);
|
||||||
|
|
||||||
|
// Focus input when prompt phase starts
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "prompt") inputRef.current?.focus();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setPhase("boot");
|
||||||
|
setBotIndex(0);
|
||||||
|
setEmail("");
|
||||||
|
setError("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Esc to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") dismiss();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, dismiss]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email.trim()) return;
|
||||||
|
setPhase("sending");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/subscribe`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: email.trim(), project: "cladm" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
setPhase("done");
|
||||||
|
sessionStorage.setItem("cladm-subscribed", "1");
|
||||||
|
} else {
|
||||||
|
setError(data.error || "failed");
|
||||||
|
setPhase("prompt");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("network error");
|
||||||
|
setPhase("prompt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && dismiss()}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Terminal */}
|
||||||
|
<div className="relative w-full max-w-lg pixel-border bg-surface overflow-hidden cascade-in">
|
||||||
|
{/* Title bar */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-surface-2 border-b-2 border-border">
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
className="w-3 h-3 bg-[#ff5f56] hover:brightness-125 cursor-pointer"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
<div className="w-3 h-3 bg-[#ffbd2e]" />
|
||||||
|
<div className="w-3 h-3 bg-[#27c93f]" />
|
||||||
|
<span className="ml-3 font-[family-name:var(--font-mono)] text-dim text-xs">
|
||||||
|
cladm — subscribe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal body */}
|
||||||
|
<div className="p-5 font-[family-name:var(--font-mono)] text-xs leading-relaxed min-h-[180px]">
|
||||||
|
{/* Boot lines */}
|
||||||
|
{BOOT_LINES.slice(0, bootIndex).map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={
|
||||||
|
line.accent ? "text-accent" : line.dim ? "text-dim" : "text-green"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{line.text || "\u00A0"}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
{phase === "prompt" && (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-0 mt-2">
|
||||||
|
<span className="text-accent font-[family-name:var(--font-pixel)] text-[10px] mr-2">
|
||||||
|
email>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="flex-1 bg-transparent text-text outline-none placeholder:text-dim/40 caret-accent font-[family-name:var(--font-mono)] text-xs"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className="cursor-blink text-accent">_</span>
|
||||||
|
</form>
|
||||||
|
{error && <div className="text-[#ff5f56] mt-1">Error: {error}</div>}
|
||||||
|
<div className="text-dim text-[10px] mt-3">
|
||||||
|
press <span className="text-text">Enter</span> to subscribe{" "}
|
||||||
|
· <span className="text-text">Esc</span> to dismiss
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sending */}
|
||||||
|
{phase === "sending" && (
|
||||||
|
<div className="text-yellow mt-2">Subscribing...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Done */}
|
||||||
|
{phase === "done" && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="text-green">Subscribed successfully.</div>
|
||||||
|
<div className="text-dim">
|
||||||
|
You'll hear about new releases and tools.
|
||||||
|
</div>
|
||||||
|
<div className="text-dim text-[10px] mt-3">
|
||||||
|
press <span className="text-text">Esc</span> or click outside to close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user