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
14 changes: 14 additions & 0 deletions apps/nextjs/src/app/_lib/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Whether a request came from an Android device.
*
* Shared so the Instagram landing page and the mailing-list API agree on who
* is an Android visitor — they answer the same question ("can this person
* install the app?") and would otherwise drift into telling one person two
* different things.
*
* Deliberately coarse. It decides only which of two true sentences to show, so
* a miss costs a slightly-off CTA, never a broken flow.
*/
export function isAndroidUserAgent(userAgent: string | null | undefined) {
return /android/i.test(userAgent ?? "");
}
32 changes: 22 additions & 10 deletions apps/nextjs/src/app/api/waitlist/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { NextResponse } from "next/server";
import { z } from "zod/v4";

import type { MailingListSignupPlatform } from "./waitlist-confirmation-email";
import { env } from "~/env";
import {
MAILING_LIST_CONFIRMATION_HTML,
MAILING_LIST_CONFIRMATION_SUBJECT,
MAILING_LIST_CONFIRMATION_TEXT,
} from "./waitlist-confirmation-email";
import { isAndroidUserAgent } from "../../_lib/platform";
import { mailingListConfirmationEmail } from "./waitlist-confirmation-email";

export const runtime = "nodejs";

Expand Down Expand Up @@ -46,18 +44,32 @@ export async function POST(req: Request) {
// is first created. A delivery problem should never prevent someone from
// joining the mailing list.
if (result === "joined") {
await sendMailingListConfirmation(email).catch((err: unknown) => {
// Read from the signup request rather than passed by the form: the header
// is what actually says which device is in someone's hand, and it covers
// every form on the site without each one having to declare itself.
const platform: MailingListSignupPlatform = isAndroidUserAgent(
req.headers.get("user-agent"),
)
? "android"
: "default";

await sendMailingListConfirmation(email, platform).catch((err: unknown) => {
console.error("mailing list confirmation email failed", err);
});
}

return NextResponse.json({ ok: true, result });
}

async function sendMailingListConfirmation(email: string) {
async function sendMailingListConfirmation(
email: string,
platform: MailingListSignupPlatform,
) {
const from = env.RESEND_MAILING_LIST_CONFIRMATION_FROM_EMAIL;
if (!from) return;

const { subject, text, html } = mailingListConfirmationEmail(platform);

const response = await fetch(`${RESEND_API_BASE_URL}/emails`, {
method: "POST",
headers: {
Expand All @@ -67,9 +79,9 @@ async function sendMailingListConfirmation(email: string) {
body: JSON.stringify({
from,
to: [email],
subject: MAILING_LIST_CONFIRMATION_SUBJECT,
text: MAILING_LIST_CONFIRMATION_TEXT,
html: MAILING_LIST_CONFIRMATION_HTML,
subject,
text,
html,
}),
});

Expand Down
92 changes: 69 additions & 23 deletions apps/nextjs/src/app/api/waitlist/waitlist-confirmation-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,77 @@ const BILLION_SITE_URL = "https://www.billion-news.app";
const BILLION_APP_STORE_URL =
"https://apps.apple.com/us/app/billion-news/id6761675243";

/**
* Where someone signed up from, as far as it changes what we can tell them.
*
* Only Android is called out: they came from a device with nothing to install,
* so "Download on the App Store" would be an instruction they cannot follow.
* Everyone else — the site, an iPhone, a desktop — gets the store link.
*/
export type MailingListSignupPlatform = "android" | "default";

export const MAILING_LIST_CONFIRMATION_SUBJECT =
"You're subscribed to Billion updates";

export const MAILING_LIST_CONFIRMATION_TEXT = [
"You're subscribed to Billion updates.",
"",
"Thanks for subscribing to Billion—the simpler way to see what your government is actually doing.",
"",
"We'll send occasional updates about feature releases, Android availability, and what's happening in civic life.",
"",
"Billion is available now on the App Store:",
BILLION_APP_STORE_URL,
"",
"Thanks for following along.",
"— The Billion team",
].join("\n");
export function mailingListConfirmationEmail(
platform: MailingListSignupPlatform,
) {
return {
subject: MAILING_LIST_CONFIRMATION_SUBJECT,
text: confirmationText(platform),
html: confirmationHtml(platform),
};
}

function confirmationText(platform: MailingListSignupPlatform) {
const closing =
platform === "android"
? [
"We'll email you the day the Android app is ready.",
"",
"If you have an iPhone nearby, Billion is on the App Store today:",
BILLION_APP_STORE_URL,
]
: ["Billion is available now on the App Store:", BILLION_APP_STORE_URL];

return [
"You're subscribed to Billion updates.",
"",
"Thanks for subscribing to Billion—the simpler way to see what your government is actually doing.",
"",
"We'll send occasional updates about feature releases, Android availability, and what's happening in civic life.",
"",
...closing,
"",
"Thanks for following along.",
"— The Billion team",
].join("\n");
}

/**
* The call-to-action row. Android gets a sentence where the button would be:
* a button promising a download they can't make is worse than no button.
*/
function confirmationCallToAction(platform: MailingListSignupPlatform) {
if (platform === "android") {
return `<td class="content" style="padding:24px 48px 0;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:26px;color:#E7E9F0;">
<p style="margin:0;">We’ll email you the day the Android app is ready. If you have an iPhone nearby, Billion is <a href="${BILLION_APP_STORE_URL}" target="_blank" rel="noopener noreferrer" style="color:#9DB8FF;text-decoration:underline;">on the App Store</a> today.</p>
</td>`;
}

return `<td class="content" style="padding:28px 48px 0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="background-color:#4A7CFF;border-radius:999px;">
<a href="${BILLION_APP_STORE_URL}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:13px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:20px;font-weight:700;color:#FFFFFF;">Download on the App Store</a>
</td>
</tr>
</table>
</td>`;
}

export const MAILING_LIST_CONFIRMATION_HTML = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
function confirmationHtml(platform: MailingListSignupPlatform) {
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
Expand Down Expand Up @@ -118,15 +171,7 @@ export const MAILING_LIST_CONFIRMATION_HTML = `<!DOCTYPE html PUBLIC "-//W3C//DT
</tr>

<tr>
<td class="content" style="padding:28px 48px 0;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="background-color:#4A7CFF;border-radius:999px;">
<a href="${BILLION_APP_STORE_URL}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:13px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:20px;font-weight:700;color:#FFFFFF;">Download on the App Store</a>
</td>
</tr>
</table>
</td>
${confirmationCallToAction(platform)}
</tr>

<tr>
Expand Down Expand Up @@ -163,3 +208,4 @@ export const MAILING_LIST_CONFIRMATION_HTML = `<!DOCTYPE html PUBLIC "-//W3C//DT
</table>
</body>
</html>`;
}
113 changes: 113 additions & 0 deletions apps/nextjs/src/app/ig/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";

import { env } from "~/env";
import { AndroidIcon } from "../_components/icons";
import { WaitlistForm } from "../_components/waitlist-form";
import { isAndroidUserAgent } from "../_lib/platform";
import { campaignFor } from "../r/campaigns";

const APP_STORE_URL = "https://apps.apple.com/us/app/billion-news/id6761675243";

const INSTAGRAM_CAMPAIGN = campaignFor("instagram_bio");

export const metadata: Metadata = {
title: "Billion for Android — Coming Soon",
description:
"Billion is on iOS today and coming to Android. Join the mailing list to hear when it lands.",
robots: { index: false, follow: false },
};

/**
* Stable Instagram-bio entry point.
*
* The bio link is printed in one place and read on whatever phone someone
* happens to be holding, so the branch has to happen here: iPhone (and
* desktop, where the App Store page still works) goes straight to the store,
* and Android — where there is nothing to install yet — gets told so and is
* offered the mailing list instead of a dead end.
*/
export default async function InstagramLandingPage() {
const isAndroid = isAndroidUserAgent((await headers()).get("user-agent"));

await captureVisit(isAndroid ? "android" : "app_store");

if (!isAndroid) redirect(APP_STORE_URL);

const homeHref = `/?${new URLSearchParams(INSTAGRAM_CAMPAIGN).toString()}`;

return (
<main className="mx-auto flex min-h-screen max-w-[560px] flex-col items-center justify-center px-6 py-16 text-center">
<Image
src="/billion-logo.png"
alt=""
width={64}
height={64}
className="mb-8 h-16 w-16 rounded-[14px]"
/>

<div className="text-muted-foreground mb-6 flex items-center gap-2.5 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-sans text-[13px] font-medium">
<AndroidIcon className="h-4 w-4 shrink-0 text-emerald-400" />
<span>Coming Soon to Android</span>
</div>

<h1
className="text-foreground font-display mb-4 leading-[1.15] font-bold tracking-[-0.02em]"
style={{ fontSize: "clamp(2rem, 7vw, 2.75rem)" }}
>
Billion isn&apos;t on Android yet.
</h1>
<p className="text-muted-foreground mb-8 font-sans text-[17px] leading-[1.6]">
We&apos;re building it. Leave your email and we&apos;ll tell you the day
it&apos;s ready, and you can leave any time.
</p>

<div className="w-full">
<WaitlistForm buttonText="Notify me" />
</div>

<Link
href={homeHref}
className="text-muted-foreground hover:text-accent mt-10 font-sans text-[15px] font-medium no-underline transition-colors duration-200"
>
See what Billion does →
</Link>
</main>
);
}

/**
* Instagram referrers do not survive its in-app browser, and the App Store hop
* leaves no UTMs behind on our side, so the visit is counted here or nowhere.
* Never allowed to fail the page: a broken analytics host must not cost us the
* install.
*/
async function captureVisit(outcome: "android" | "app_store") {
const key: string | undefined = env.NEXT_PUBLIC_POSTHOG_KEY;
const host: string | undefined = env.NEXT_PUBLIC_POSTHOG_HOST;
if (!key || !host) return;

try {
await fetch(`${host.replace(/\/$/, "")}/capture/`, {
method: "POST",
headers: { "content-type": "application/json" },
signal: AbortSignal.timeout(1500),
body: JSON.stringify({
api_key: key,
event: "instagram_bio_visit",
properties: {
// Counted, not followed — same reasoning as the /r redirect.
distinct_id: `ig-${crypto.randomUUID()}`,
$process_person_profile: false,
...INSTAGRAM_CAMPAIGN,
outcome,
},
}),
});
} catch {
// Swallowed on purpose — see above.
}
}
32 changes: 0 additions & 32 deletions apps/nextjs/src/app/ig/route.ts

This file was deleted.

Loading