diff --git a/.env.example b/.env.example index 738279d..7b4a032 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ PUBLIC_STELLAR_HORIZON_URL="https://horizon-testnet.stellar.org" PUBLIC_SUPABASE_URL="https://your-project-ref.supabase.co" PUBLIC_SUPABASE_ANON_KEY="sb_publishable_..." +# ── Waitlist ───────────────────────────────────────────────────── +PUBLIC_WAITLIST_URL="https://script.google.com/macros/s/…/exec" +PUBLIC_WAITLIST_TOKEN="a-random-string-you-also-put-in-the-script" + # PUBLIC_STELLAR_NETWORK="MAINNET" # PUBLIC_STELLAR_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" # PUBLIC_STELLAR_RPC_URL= diff --git a/src/components/landing/WaitlistSection.tsx b/src/components/landing/WaitlistSection.tsx new file mode 100644 index 0000000..c000fec --- /dev/null +++ b/src/components/landing/WaitlistSection.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { isValidEmail, joinWaitlist } from "../../lib/waitlist"; + +type Status = "idle" | "submitting" | "done" | "error"; + +/** + * Waitlist capture. Email → a Google Sheet (via the Apps Script web app in + * `lib/waitlist`). Includes a hidden honeypot field so naive bots that fill + * every input are dropped silently. + */ +export default function WaitlistSection() { + const [email, setEmail] = useState(""); + const [honeypot, setHoneypot] = useState(""); + const [status, setStatus] = useState("idle"); + + const submit = async () => { + // A filled honeypot means a bot — pretend success, submit nothing. + if (honeypot) { + setStatus("done"); + return; + } + if (!isValidEmail(email)) { + setStatus("error"); + return; + } + setStatus("submitting"); + try { + await joinWaitlist(email); + setStatus("done"); + } catch { + setStatus("error"); + } + }; + + return ( +
+
+

+ Be first when we launch +

+

+ Drop your email and we’ll let you know the moment new features and + mainnet go live. +

+ + {status === "done" ? ( +

+ You’re on the list. We’ll be in touch. ✓ +

+ ) : ( +
{ + e.preventDefault(); + void submit(); + }} + className="mx-auto mt-8 flex max-w-[520px] flex-col gap-3 sm:flex-row" + > + {/* Honeypot: hidden from people, tempting to bots. */} + setHoneypot(e.target.value)} + className="hidden" + /> + { + setEmail(e.target.value); + if (status === "error") setStatus("idle"); + }} + placeholder="you@example.com" + className="h-[47px] flex-1 rounded-xl border border-[#ffffff1f] bg-[#05060a] px-4 font-body text-[14.5px] text-white placeholder:text-muted focus:border-cyan/60 focus:outline-none" + /> + +
+ )} + + {status === "error" && ( +

+ Couldn’t add you just now — check your email and try again. +

+ )} +
+
+ ); +} diff --git a/src/lib/waitlist.ts b/src/lib/waitlist.ts new file mode 100644 index 0000000..ab93787 --- /dev/null +++ b/src/lib/waitlist.ts @@ -0,0 +1,40 @@ +// Waitlist submission → a Google Apps Script web app that appends the email to a +// Google Sheet. The URL + optional token are public (they ship in the client +// bundle); the endpoint only ever *appends*, never reads, so the collected list +// is never exposed. See planning notes for the Apps Script + deploy steps. +const WAITLIST_URL = import.meta.env.PUBLIC_WAITLIST_URL as string | undefined; +const WAITLIST_TOKEN = + (import.meta.env.PUBLIC_WAITLIST_TOKEN as string | undefined) ?? ""; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function isValidEmail(email: string): boolean { + return EMAIL_RE.test(email.trim()); +} + +/** + * Append `email` to the waitlist sheet. + * + * Apps Script web apps don't return CORS headers, so this is a deliberate + * fire-and-forget: `mode: "no-cors"` lets the POST land but leaves the response + * opaque. A resolved promise therefore means "sent", not "server-confirmed" — + * which is the right trade-off for a waitlist (the row still appears in the + * sheet). Only an outright network failure rejects. + */ +export async function joinWaitlist(email: string): Promise { + if (!WAITLIST_URL) { + throw new Error("Waitlist isn’t configured yet."); + } + const body = new URLSearchParams({ + email: email.trim(), + token: WAITLIST_TOKEN, + }); + await fetch(WAITLIST_URL, { + method: "POST", + mode: "no-cors", + // Form-encoded keeps it a CORS "simple request" (no preflight) and lands in + // the Apps Script `e.parameter`. + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); +} diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index c8ea33b..77d8c3f 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -3,6 +3,7 @@ import HeroSection from "../components/landing/HeroSection"; import ProductSection from "../components/landing/ProductSection"; import PrimitivesSection from "../components/landing/PrimitivesSection"; import FaqSection from "../components/landing/FaqSection"; +import WaitlistSection from "../components/landing/WaitlistSection"; import CtaSection from "../components/landing/CtaSection"; import Footer from "../components/landing/Footer"; import Reveal from "../components/landing/Reveal"; @@ -30,6 +31,9 @@ export default function Landing() { + + +