diff --git a/app/connection/add.tsx b/app/connection/add.tsx index b44bd04..ba53492 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -19,6 +19,7 @@ import { probeConnection, shareReport } from "../../src/lib/diagnostics" import { captureDiagnostic } from "../../src/lib/sentry" import { parseUrl } from "../../src/lib/diagnostics-classify" import { AnalyticsEvent, track } from "../../src/lib/analytics" +import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist" export default function AddConnectionScreen() { const colorScheme = useColorScheme() @@ -37,6 +38,7 @@ export default function AddConnectionScreen() { const [password, setPassword] = useState("") const [isConnecting, setIsConnecting] = useState(false) const [waitlistEmail, setWaitlistEmail] = useState("") + const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined">("idle") const buildUrl = () => { if (mode === "advanced") return url.trim() @@ -150,11 +152,27 @@ export default function AddConnectionScreen() { router.back() } - const handleJoinWaitlist = () => { - const email = waitlistEmail.trim() - const subject = encodeURIComponent("OpenCode Connect Waitlist") - const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!") - Linking.openURL(`mailto:support@agentlabs.cc?subject=${subject}&body=${body}`) + const handleJoinWaitlist = async () => { + if (waitlistState === "submitting") return + setWaitlistState("submitting") + const result = await submitWaitlistSignup(waitlistEmail) + if (result.ok) { + setWaitlistState("joined") + return + } + setWaitlistState("idle") + if (result.fallback) { + // API unreachable/broken: fall back to the pre-#87 mailto path so the + // signup still reaches the support inbox instead of being lost. + try { + await Linking.openURL(buildWaitlistMailtoUrl(result.email)) + } catch { + // No mail app either — tell the user instead of failing silently. + Alert.alert("Join Waitlist", "Could not reach the signup service or open an email app. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\".") + } + } else { + Alert.alert("Join Waitlist", result.error) + } } // Quick connect mode - simplified @@ -283,23 +301,44 @@ export default function AddConnectionScreen() { Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from anywhere. - - - - Join Waitlist - + {waitlistState === "joined" ? ( + + + + You're on the list — we'll email you when OpenCode Connect is ready. + + + ) : ( + <> + + + {waitlistState === "submitting" ? ( + + ) : ( + <> + + Join Waitlist + + )} + + + )} {/* Advanced mode link */} @@ -711,4 +750,16 @@ const styles = StyleSheet.create({ fontWeight: "600", color: "#ffffff", }, + waitlistSuccess: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginTop: 12, + }, + waitlistSuccessText: { + flex: 1, + fontSize: 13, + color: "#0a0a0a", + lineHeight: 20, + }, }) diff --git a/src/lib/waitlist.test.ts b/src/lib/waitlist.test.ts new file mode 100644 index 0000000..5cd4ec6 --- /dev/null +++ b/src/lib/waitlist.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + WAITLIST_ENDPOINT, + WAITLIST_SOURCE, + normalizeWaitlistEmail, + buildWaitlistPayload, + buildWaitlistMailtoUrl, + shouldFallbackToMailto, + submitWaitlistSignup, +} from "./waitlist.ts" + +// --- normalizeWaitlistEmail: mirror of the server's 400 rule --- + +test("normalize trims and lowercases like the server", () => { + assert.equal(normalizeWaitlistEmail(" Dev@Example.COM "), "dev@example.com") +}) + +test("normalize rejects what the server would 400 on", () => { + assert.equal(normalizeWaitlistEmail(""), null) + assert.equal(normalizeWaitlistEmail(" "), null) + assert.equal(normalizeWaitlistEmail("not-an-email"), null) + assert.equal(normalizeWaitlistEmail("a b@example.com"), null) + assert.equal(normalizeWaitlistEmail("no-tld@host"), null) + assert.equal(normalizeWaitlistEmail(`${"x".repeat(250)}@a.com`), null) // > 254 chars +}) + +// --- payload: the source tag is the whole point of #87 --- + +test("payload tags the signup with the opencode-connect source", () => { + assert.deepEqual(buildWaitlistPayload("dev@example.com"), { + email: "dev@example.com", + source: WAITLIST_SOURCE, + }) +}) + +// --- mailto fallback URL: byte-compatible with the pre-#87 behavior --- + +test("mailto fallback preserves subject and embeds the email", () => { + const url = buildWaitlistMailtoUrl("dev@example.com") + assert.ok(url.startsWith("mailto:support@agentlabs.cc?")) + assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist")) + assert.ok(url.includes(encodeURIComponent("Email: dev@example.com"))) +}) + +test("mailto fallback works without an email", () => { + const url = buildWaitlistMailtoUrl("") + assert.ok(url.includes("body=Sign%20me%20up!")) + assert.ok(!url.includes("Email")) +}) + +// --- fallback decision --- + +// 502 named explicitly: the server returns it when Brevo itself fails, and the +// signup must survive that via mailto. +test("fallback: transport failures and 5xx (incl. 502 Brevo failure) -> mailto, 4xx -> fix input", () => { + assert.equal(shouldFallbackToMailto({ kind: "network-error" }), true) + assert.equal(shouldFallbackToMailto({ kind: "http", status: 500 }), true) + assert.equal(shouldFallbackToMailto({ kind: "http", status: 502 }), true) + assert.equal(shouldFallbackToMailto({ kind: "http", status: 503 }), true) + assert.equal(shouldFallbackToMailto({ kind: "http", status: 400 }), false) + assert.equal(shouldFallbackToMailto({ kind: "http", status: 429 }), false) +}) + +// --- submitWaitlistSignup with injected fetch --- + +type FetchCall = { url: string; init: { method: string; headers: Record; body: string; signal: AbortSignal } } + +function fakeFetch(response: { ok: boolean; status: number; body?: unknown }, calls: FetchCall[] = []) { + return async (url: string, init: FetchCall["init"]) => { + calls.push({ url, init }) + return { ok: response.ok, status: response.status, json: async () => response.body ?? null } + } +} + +test("submit posts the tagged payload to the beta-signup endpoint", async () => { + const calls: FetchCall[] = [] + const result = await submitWaitlistSignup(" Dev@Example.com ", { fetchFn: fakeFetch({ ok: true, status: 200, body: { ok: true } }, calls) }) + assert.deepEqual(result, { ok: true, email: "dev@example.com" }) + assert.equal(calls.length, 1) + assert.equal(calls[0].url, WAITLIST_ENDPOINT) + assert.equal(calls[0].init.method, "POST") + assert.equal(calls[0].init.headers["content-type"], "application/json") + assert.deepEqual(JSON.parse(calls[0].init.body), { email: "dev@example.com", source: WAITLIST_SOURCE }) + assert.ok(calls[0].init.signal instanceof AbortSignal) +}) + +test("submit rejects an invalid email locally without hitting the network", async () => { + const calls: FetchCall[] = [] + const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) }) + assert.equal(calls.length, 0) + assert.deepEqual(result, { ok: false, email: "nope", fallback: false, error: "Enter a valid email address." }) +}) + +test("submit surfaces the server's 400 message without falling back to mailto", async () => { + const result = await submitWaitlistSignup("dev@example.com", { + fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }), + }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: false, error: "Enter a valid email address." }) +}) + +test("submit falls back to mailto on 5xx (server broken, keep the signup alive)", async () => { + const result = await submitWaitlistSignup("dev@example.com", { + fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }), + }) + assert.equal(result.ok, false) + if (!result.ok) { + assert.equal(result.fallback, true) + assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.") + } +}) + +test("submit falls back to mailto when fetch rejects (offline)", async () => { + const result = await submitWaitlistSignup("dev@example.com", { + fetchFn: async () => { + throw new TypeError("Network request failed") + }, + }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Network request failed" }) +}) + +test("submit aborts after timeoutMs and falls back to mailto", async () => { + const result = await submitWaitlistSignup("dev@example.com", { + timeoutMs: 20, + fetchFn: (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => { + const err = new Error("Aborted") + err.name = "AbortError" + reject(err) + }) + }), + }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "timeout after 20ms" }) +}) + +test("submit tolerates a non-JSON error body", async () => { + const result = await submitWaitlistSignup("dev@example.com", { + fetchFn: async () => ({ + ok: false, + status: 502, + json: async () => { + throw new SyntaxError("Unexpected token") + }, + }), + }) + assert.deepEqual(result, { ok: false, email: "dev@example.com", fallback: true, error: "Signup failed (HTTP 502)." }) +}) diff --git a/src/lib/waitlist.ts b/src/lib/waitlist.ts new file mode 100644 index 0000000..61a75f5 --- /dev/null +++ b/src/lib/waitlist.ts @@ -0,0 +1,117 @@ +// OpenCode Connect waitlist signup: pure payload/fallback logic. +// +// Kept free of react-native imports (Linking, Alert) so it's unit-testable +// with plain `node --test`, the same split used for buildAuth() in auth.ts +// and shouldRequestReview() in store-review-policy.ts. The screen injects +// nothing in production (global fetch is used); tests inject a fake fetch. +// +// Backend: the OpenCodeMobileSite beta-signup route (VibeBrowserProductPage +// repo, OpenCodeMobileSite/app/api/beta-signup/route.ts) validates `email` +// and adds it to a Brevo list. It ignores unknown body fields today, so the +// `source` tag we send is forward-compatible: harmless now, attributable as +// soon as the route starts reading it. + +export const WAITLIST_ENDPOINT = "https://opencode.agentlabs.cc/api/beta-signup" +export const WAITLIST_SOURCE = "opencode-connect-waitlist" +export const WAITLIST_TIMEOUT_MS = 8_000 +export const WAITLIST_FALLBACK_EMAIL = "support@agentlabs.cc" + +// Mirrors the server-side pattern in brevo-contact.ts so we reject locally +// exactly what the server would 400 on, instead of burning a round trip. +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +/** Trim/lowercase like the server does; null when the server would 400. */ +export function normalizeWaitlistEmail(raw: string): string | null { + const email = raw.trim().toLowerCase() + if (!email || email.length > 254 || !emailPattern.test(email)) return null + return email +} + +export function buildWaitlistPayload(email: string): { email: string; source: string } { + return { email, source: WAITLIST_SOURCE } +} + +/** The pre-#87 mailto path, kept as the fallback when the API is unreachable. */ +export function buildWaitlistMailtoUrl(email: string): string { + const subject = encodeURIComponent("OpenCode Connect Waitlist") + const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!") + return `mailto:${WAITLIST_FALLBACK_EMAIL}?subject=${subject}&body=${body}` +} + +export type WaitlistResult = + /** Signup persisted server-side. */ + | { ok: true; email: string } + /** Signup not persisted. `fallback` decides the UX: true -> open the + * mailto fallback so the signup still reaches the support inbox; + * false -> the input (or server validation) is wrong, ask the user to fix + * their email instead of mailing garbage. */ + | { ok: false; email: string; fallback: boolean; error: string } + +/** + * Fallback decision, isolated for testability: + * - transport failure (offline, DNS, timeout) -> mailto keeps the signup alive + * - 5xx -> server broken through no fault of the user's -> mailto + * - 4xx -> the server rejected this email; mailing it wouldn't help + */ +export function shouldFallbackToMailto(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean { + if (outcome.kind === "network-error") return true + return outcome.status >= 500 +} + +type FetchLike = ( + url: string, + init: { method: string; headers: Record; body: string; signal: AbortSignal }, +) => Promise<{ ok: boolean; status: number; json(): Promise }> + +export interface WaitlistDeps { + fetchFn?: FetchLike + timeoutMs?: number +} + +function serverError(body: unknown): string | undefined { + if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") { + return body.error + } + return undefined +} + +export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise { + const email = normalizeWaitlistEmail(rawEmail) + if (email === null) { + return { ok: false, email: rawEmail.trim(), fallback: false, error: "Enter a valid email address." } + } + + const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike) + const timeoutMs = deps.timeoutMs ?? WAITLIST_TIMEOUT_MS + + // Same timeout pattern as timedFetch() in diagnostics.ts. + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await fetchFn(WAITLIST_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(buildWaitlistPayload(email)), + signal: controller.signal, + }) + if (res.ok) return { ok: true, email } + const message = serverError(await res.json().catch(() => null)) + return { + ok: false, + email, + fallback: shouldFallbackToMailto({ kind: "http", status: res.status }), + error: message || `Signup failed (HTTP ${res.status}).`, + } + } catch (error: unknown) { + const err = error as { name?: string; message?: string } + const aborted = err?.name === "AbortError" + return { + ok: false, + email, + fallback: shouldFallbackToMailto({ kind: "network-error" }), + error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error), + } + } finally { + clearTimeout(timer) + } +}