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:
@@ -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,6 +301,15 @@ export default function AddConnectionScreen() {
|
||||
Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from
|
||||
anywhere.
|
||||
</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
|
||||
style={[styles.input, isDark && styles.inputDark, { marginTop: 12 }]}
|
||||
placeholder="your@email.com"
|
||||
@@ -292,14 +319,26 @@ export default function AddConnectionScreen() {
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
editable={waitlistState !== "submitting"}
|
||||
testID="waitlist-email-input"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.waitlistButton}
|
||||
onPress={handleJoinWaitlist}
|
||||
disabled={waitlistState === "submitting"}
|
||||
testID="waitlist-submit-button"
|
||||
>
|
||||
{waitlistState === "submitting" ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="mail-outline" size={16} color="#ffffff" />
|
||||
<Text style={styles.waitlistButtonText}>Join Waitlist</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 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,
|
||||
},
|
||||
})
|
||||
|
||||
148
src/lib/waitlist.test.ts
Normal file
148
src/lib/waitlist.test.ts
Normal 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
117
src/lib/waitlist.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user