diff --git a/app/api/waitlist/route.ts b/app/api/waitlist/route.ts index 9664051..4879048 100644 --- a/app/api/waitlist/route.ts +++ b/app/api/waitlist/route.ts @@ -45,7 +45,7 @@ export async function POST(req: NextRequest) { // Double opt-in: e-mail a confirmation link via Resend. If Resend isn't // configured (local dev / self-host), auto-confirm so the flow still works. if (result.token && isEmailConfigured()) { - const sent = await sendWaitlistVerification({ email, token: result.token, brand }); + const sent = await sendWaitlistVerification({ email, token: result.token, brand, site: domain }); if (!sent.ok) { // Never block a signup because email is misconfigured (bad key, unverified // domain, Resend down). Save them and auto-confirm; log loudly so the diff --git a/bun.lock b/bun.lock index f2136a8..d834e29 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@profullstack/affiliates": "0.3.0", "@profullstack/coinpay-onboard": "^0.5.0", + "@profullstack/cross-promo": "^0.1.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/player": "0.17.0", @@ -143,6 +144,8 @@ "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.5.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-yqSx3pgJLP521Km+XNNn9fyjbbHppgozuGTtzTln9yy+2I0h0bjqXSpsFPwoSgVb5QcyBx+Wzv5170Qb7va1iQ=="], + "@profullstack/cross-promo": ["@profullstack/cross-promo@0.1.0", "", { "bin": { "cross-promo": "bin/cross-promo.js" } }, "sha512-LQMxZSvApH1T+8WmMu/Nj1LqojXeTw6wAtpLfhuKFwx9eR36hQG/7vVIDkKpmU5zKw7UwARe20MOfFCak2+ejA=="], + "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], "@profullstack/footer": ["@profullstack/footer@0.4.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-EJZnMUQmWDi9Gzx6BAjogloeaPFFSSCHtHJuC9dsXiOz8M87hfn6Z+Zx9AMYWk8s6xemPrPlkXKGB3PZugkcEA=="], diff --git a/lib/email.ts b/lib/email.ts index 962aaf0..409edf8 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -4,6 +4,8 @@ // RESEND_API_KEY is unset — e.g. local dev — isEmailConfigured() is false and // callers fall back to auto-confirming so the flow still works offline. +import { promoBlock } from "@profullstack/cross-promo"; + const RESEND_ENDPOINT = "https://api.resend.com/emails"; /** @@ -87,18 +89,31 @@ async function send(opts: { } /** Sends the "confirm your spot" email with a one-click verification link. */ -export function sendWaitlistVerification(opts: { +export async function sendWaitlistVerification(opts: { email: string; token: string; brand?: string; + /** The tenant domain the signup came from (?dn=); defaults to moshcoding.com. */ + site?: string; }): Promise { const brand = opts.brand || "moshcoding"; + // "More from the network" for developers, below the confirm button so it + // never competes with it. Never the sending site or moshcoding itself. + // Never throws; falls back to the bundled catalog if the CDN is down. + const promo = await promoBlock({ + site: opts.site || "moshcoding.com", + exclude: ["moshcoding.com"], + audience: "developers", + seed: opts.email, + theme: { accent: "#9EF01A", text: "#c9c9c9", muted: "#8a8a8a", bg: "#141416", border: "#26262a", heading: "#e7e7e7" }, + }); const url = `${appBaseUrl()}/api/waitlist/verify?token=${encodeURIComponent(opts.token)}`; const subject = `Confirm your spot in the ${brand} pit 🤘`; const text = `You're almost in. Confirm your email to lock in your spot on the ${brand} waitlist:\n\n` + `${url}\n\n` + - `If you didn't request this, just ignore it — no account is created.`; + `If you didn't request this, just ignore it — no account is created.` + + (promo.text ? `\n\n${promo.text}` : ""); const safeBrand = escapeHtml(brand); const safeUrl = escapeHtml(url); const html = ` @@ -124,6 +139,9 @@ export function sendWaitlistVerification(opts: {

+ + +
${promo.html}
`; diff --git a/package.json b/package.json index beb1a4f..eca2df4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moshcoding", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Code hard. Mosh harder. Next.js + Bun. Landing + one-line metal coming-soon pages with email waitlist and CoinPayPortal login.", "scripts": { @@ -14,6 +14,7 @@ "dependencies": { "@profullstack/affiliates": "0.3.0", "@profullstack/coinpay-onboard": "^0.5.0", + "@profullstack/cross-promo": "^0.1.0", "@profullstack/footer": "latest", "@profullstack/libsql-pg": "^0.1.4", "@profullstack/player": "0.17.0", diff --git a/tests/waitlist-promo.test.mjs b/tests/waitlist-promo.test.mjs new file mode 100644 index 0000000..9d09793 --- /dev/null +++ b/tests/waitlist-promo.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sendWaitlistVerification } from "../lib/email.ts"; + +// The waitlist confirm email carries the shared "more from the network" block +// (@profullstack/cross-promo) below the confirm button, never promoting the +// sender. Capture the outgoing Resend payload; use the bundled catalog. +async function capture(opts) { + const prev = { key: process.env.RESEND_API_KEY, promo: process.env.CROSS_PROMO, fetch: globalThis.fetch }; + process.env.RESEND_API_KEY = "test_key"; + process.env.CROSS_PROMO = "bundled"; + let captured = null; + globalThis.fetch = async (_url, init) => { + captured = JSON.parse(init.body); + return { ok: true, json: async () => ({ id: "x" }), text: async () => "" }; + }; + try { + await sendWaitlistVerification(opts); + } finally { + globalThis.fetch = prev.fetch; + for (const [k, v] of [["RESEND_API_KEY", prev.key], ["CROSS_PROMO", prev.promo]]) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + return captured; +} + +const promoLinks = (html) => + [...html.matchAll(/href="(https?:\/\/[^"]*utm_campaign=cross-promo[^"]*)"/g)].map((m) => new URL(m[1].replace(/&/g, "&"))); + +test("waitlist email shows the network block below the confirm button", async () => { + const p = await capture({ email: "u@example.com", token: "tok" }); + const links = promoLinks(p.html); + assert.ok(links.length > 0, "html carries the promo block"); + assert.ok(p.html.indexOf("utm_campaign=cross-promo") > p.html.indexOf("Confirm my spot"), "below the confirm button"); + assert.ok(p.html.indexOf("utm_campaign=cross-promo") > p.html.indexOf("/api/waitlist/verify?token=tok"), "below the confirm link"); + assert.match(p.text, /utm_campaign=cross-promo/); + assert.ok(p.text.indexOf("utm_campaign") > p.text.indexOf("/api/waitlist/verify"), "text: confirm link first"); + for (const u of links) { + assert.notEqual(u.hostname, "moshcoding.com", "never promotes moshcoding.com"); + assert.equal(u.searchParams.get("utm_source"), "moshcoding.com"); + } +}); + +test("a tenant on a catalog property never promotes itself", async () => { + const p = await capture({ email: "u@example.com", token: "tok", brand: "hqterm", site: "hqterm.sh" }); + const links = promoLinks(p.html); + assert.ok(links.length > 0); + for (const u of links) assert.ok(!["hqterm.sh", "moshcoding.com"].includes(u.hostname)); + assert.doesNotMatch(p.text, /https:\/\/hqterm\.sh/); +});