Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
103 changes: 103 additions & 0 deletions src/components/landing/WaitlistSection.tsx
Original file line number Diff line number Diff line change
@@ -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<Status>("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 (
<section
id="waitlist"
className="mx-auto max-w-(--max-width) px-5 pt-24 pb-8 md:px-8 md:pt-32"
>
<div className="mx-auto max-w-[1104px] rounded-[26px] border border-[#ffffff14] bg-[#0e0f1a] px-6 py-16 text-center sm:px-10">
<h2 className="font-heading text-[clamp(1.8rem,4vw,2.4rem)] font-bold tracking-tight text-[#eef0f7]">
Be first when we launch
</h2>
<p className="mx-auto mt-3 max-w-[460px] font-body text-[15px] leading-relaxed text-subtle">
Drop your email and we’ll let you know the moment new features and
mainnet go live.
</p>

{status === "done" ? (
<p className="mx-auto mt-8 inline-flex items-center gap-2 rounded-xl border border-cyan/30 bg-cyan/10 px-5 py-3 font-body text-[14.5px] font-semibold text-cyan">
You’re on the list. We’ll be in touch. ✓
</p>
) : (
<form
onSubmit={(e) => {
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. */}
<input
type="text"
name="company"
tabIndex={-1}
autoComplete="off"
aria-hidden
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
className="hidden"
/>
<input
type="email"
inputMode="email"
autoComplete="email"
required
value={email}
onChange={(e) => {
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"
/>
<button
type="submit"
disabled={status === "submitting"}
className="inline-flex h-[47px] shrink-0 cursor-pointer items-center justify-center rounded-xl bg-cyan px-6 font-body text-[14.5px] font-bold whitespace-nowrap text-[#05060a] transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{status === "submitting" ? "Joining…" : "Join waitlist"}
</button>
</form>
)}

{status === "error" && (
<p className="mt-3 font-body text-[13px] text-red-300">
Couldn’t add you just now — check your email and try again.
</p>
)}
</div>
</section>
);
}
40 changes: 40 additions & 0 deletions src/lib/waitlist.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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(),
});
}
4 changes: 4 additions & 0 deletions src/pages/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -30,6 +31,9 @@ export default function Landing() {
<Reveal variant="scale">
<CtaSection />
</Reveal>
<Reveal variant="up">
<WaitlistSection />
</Reveal>
<Footer />
</div>
);
Expand Down
Loading