feat(waitlist): capture OpenCode Connect signups via beta-signup API (#92)

* feat(waitlist): capture OpenCode Connect signups via beta-signup API (closes #87)

The 'OpenCode Connect — Coming Soon' card only opened a raw mailto: link,
so waitlist signups existed solely as loose emails in the support inbox
with no backend capture.

- POST the signup to https://opencode.agentlabs.cc/api/beta-signup
  (OpenCodeMobileSite route -> Brevo list) tagged with
  source: "opencode-connect-waitlist". The route ignores unknown fields
  today, so the tag is forward-compatible.
- Pure payload/validation/fallback logic lives in src/lib/waitlist.ts
  (no react-native imports, dependency-injected fetch, AbortController
  timeout like diagnostics.ts) with node --test coverage.
- Graceful degradation: transport failures and 5xx fall back to the old
  mailto: path so the signup still reaches the inbox; 4xx asks the user
  to fix their email. Success shows an inline confirmation state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(waitlist): handle mailto fallback failure, name 502 in fallback test

Review findings: Linking.openURL was fire-and-forget, so a device with
no mail app failed the recovery path silently — await it and alert with
a manual instruction instead. Test title now names 502 (Brevo failure)
as an explicit fallback case.

Refs #87

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-16 23:32:41 -07:00
committed by GitHub
parent 6a9bb6d2d5
commit 0fdfb54d9d
3 changed files with 338 additions and 22 deletions

View File

@@ -19,6 +19,7 @@ import { probeConnection, shareReport } from "../../src/lib/diagnostics"
import { captureDiagnostic } from "../../src/lib/sentry" import { captureDiagnostic } from "../../src/lib/sentry"
import { parseUrl } from "../../src/lib/diagnostics-classify" import { parseUrl } from "../../src/lib/diagnostics-classify"
import { AnalyticsEvent, track } from "../../src/lib/analytics" import { AnalyticsEvent, track } from "../../src/lib/analytics"
import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist"
export default function AddConnectionScreen() { export default function AddConnectionScreen() {
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
@@ -37,6 +38,7 @@ export default function AddConnectionScreen() {
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [waitlistEmail, setWaitlistEmail] = useState("") const [waitlistEmail, setWaitlistEmail] = useState("")
const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined">("idle")
const buildUrl = () => { const buildUrl = () => {
if (mode === "advanced") return url.trim() if (mode === "advanced") return url.trim()
@@ -150,11 +152,27 @@ export default function AddConnectionScreen() {
router.back() router.back()
} }
const handleJoinWaitlist = () => { const handleJoinWaitlist = async () => {
const email = waitlistEmail.trim() if (waitlistState === "submitting") return
const subject = encodeURIComponent("OpenCode Connect Waitlist") setWaitlistState("submitting")
const body = encodeURIComponent(email ? `Sign me up!\n\nEmail: ${email}` : "Sign me up!") const result = await submitWaitlistSignup(waitlistEmail)
Linking.openURL(`mailto:support@agentlabs.cc?subject=${subject}&body=${body}`) 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 // Quick connect mode - simplified
@@ -283,6 +301,15 @@ export default function AddConnectionScreen() {
Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from
anywhere. anywhere.
</Text> </Text>
{waitlistState === "joined" ? (
<View style={styles.waitlistSuccess} testID="waitlist-success">
<Ionicons name="checkmark-circle" size={20} color="#22c55e" />
<Text style={[styles.waitlistSuccessText, isDark && styles.textDark]}>
You're on the list — we'll email you when OpenCode Connect is ready.
</Text>
</View>
) : (
<>
<TextInput <TextInput
style={[styles.input, isDark && styles.inputDark, { marginTop: 12 }]} style={[styles.input, isDark && styles.inputDark, { marginTop: 12 }]}
placeholder="your@email.com" placeholder="your@email.com"
@@ -292,14 +319,26 @@ export default function AddConnectionScreen() {
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
keyboardType="email-address" keyboardType="email-address"
editable={waitlistState !== "submitting"}
testID="waitlist-email-input"
/> />
<TouchableOpacity <TouchableOpacity
style={styles.waitlistButton} style={styles.waitlistButton}
onPress={handleJoinWaitlist} onPress={handleJoinWaitlist}
disabled={waitlistState === "submitting"}
testID="waitlist-submit-button"
> >
{waitlistState === "submitting" ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<>
<Ionicons name="mail-outline" size={16} color="#ffffff" /> <Ionicons name="mail-outline" size={16} color="#ffffff" />
<Text style={styles.waitlistButtonText}>Join Waitlist</Text> <Text style={styles.waitlistButtonText}>Join Waitlist</Text>
</>
)}
</TouchableOpacity> </TouchableOpacity>
</>
)}
</View> </View>
{/* Advanced mode link */} {/* Advanced mode link */}
@@ -711,4 +750,16 @@ const styles = StyleSheet.create({
fontWeight: "600", fontWeight: "600",
color: "#ffffff", color: "#ffffff",
}, },
waitlistSuccess: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginTop: 12,
},
waitlistSuccessText: {
flex: 1,
fontSize: 13,
color: "#0a0a0a",
lineHeight: 20,
},
}) })

148
src/lib/waitlist.test.ts Normal file
View File

@@ -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<string, string>; 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)." })
})

117
src/lib/waitlist.ts Normal file
View File

@@ -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<string, string>; body: string; signal: AbortSignal },
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>
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<WaitlistResult> {
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)
}
}