pcbjam/site/src/pages/api/waitlist.ts
Gergő Törcsvári 39cb04b127
feat(standalone): demo follow-ups — waitlist, analytics, move-to-local, file tree, load progress
- Waitlist signup form on the home page (between Projects and Tools),
  cross-posting to the landing site's /api/waitlist (CORS + OPTIONS added there,
  gated by WAITLIST_ALLOWED_ORIGINS).
- Version badge gains a pcbjam.com landing-page link (VITE_LANDING_URL).
- Optional Plausible analytics from VITE_PLAUSIBLE_DOMAIN (off by default);
  wired into build-demo + both deploy workflows via vars.PLAUSIBLE_DOMAIN.
- Opening a read-only gallery project auto-"moves to local": it forks into a
  writable browser-local project at the same slug, shadowing the gallery row.
- Project + local-folder views use the same iconed tool launcher (ToolGrid)
  and a navigable directory FileTree instead of a flat file list.
- Boot overlay shows real wasm download progress (Module.instantiateWasm +
  streaming byte counter) and a "taking too long" state after 60s.
- Home Libraries section lists the active libs source (the read-only R2/CDN
  set in the demo) with a name filter; useLibs now reads libsSourceConfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPJYx4urFVhdMjEmYfMCMD
2026-06-27 07:59:09 +02:00

148 lines
5.6 KiB
TypeScript

import type { APIRoute } from 'astro';
import { Resend } from 'resend';
// Typed server secrets (schema in astro.config.mjs). Optional, so RESEND_API_KEY
// and RESEND_SEGMENT_ID are `string | undefined`; WAITLIST_FROM_EMAIL has a
// schema default so it's always a `string`. The Vercel adapter reads these from
// process.env at runtime — never inlined.
import {
RESEND_API_KEY,
RESEND_SEGMENT_ID,
WAITLIST_FROM_EMAIL,
WAITLIST_ALLOWED_ORIGINS,
} from 'astro:env/server';
// Opt this single route into on-demand (serverless) rendering. The rest of the
// site stays static; Vercel emits exactly one function for /api/waitlist.
export const prerender = false;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
/**
* CORS headers when the request Origin is in the configured allowlist — lets the
* static demo (demo.pcbjam.com, no backend of its own) cross-post the form. A
* same-origin submit sends no Origin and gets no CORS headers (it doesn't need
* them). The JSON content-type triggers a preflight, hence the OPTIONS handler.
*/
function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('origin');
if (!origin) return {};
const allowed = WAITLIST_ALLOWED_ORIGINS.split(',')
.map((o) => o.trim())
.filter(Boolean);
if (!allowed.includes(origin)) return {};
return {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'POST, OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
vary: 'Origin',
};
}
function json(
status: number,
body: Record<string, unknown>,
extra: Record<string, string> = {},
) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...extra },
});
}
function redirect(status: 'ok' | 'error') {
// No-JS native submit: bounce back to the page with a status flag.
return new Response(null, {
status: 303,
headers: { Location: `/?waitlist=${status}#waitlist` },
});
}
export const OPTIONS: APIRoute = ({ request }) =>
new Response(null, { status: 204, headers: corsHeaders(request) });
export const POST: APIRoute = async ({ request }) => {
const cors = corsHeaders(request);
const ct = request.headers.get('content-type') ?? '';
const wantsJson = ct.includes('application/json');
let data: Record<string, unknown> = {};
try {
data = wantsJson
? await request.json()
: Object.fromEntries(await request.formData());
} catch {
return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
}
const email = String(data.email ?? '').trim().toLowerCase();
const honeypot = String(data.company_url ?? ''); // hidden field — must stay empty
const source = String(data.source ?? 'unknown');
// Bot caught by honeypot: silently "succeed" so we don't tip them off.
if (honeypot) return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
if (!EMAIL_RE.test(email) || email.length > 254) {
return wantsJson ? json(400, { ok: false, error: 'invalid_email' }, cors) : redirect('error');
}
// No key configured (e.g. local dev without secrets): accept + log, don't 500.
if (!RESEND_API_KEY) {
console.warn(`[waitlist] RESEND_API_KEY not set — skipping send. email=${email} source=${source}`);
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
}
const resend = new Resend(RESEND_API_KEY);
try {
// Add to the segment (the modern name for an "audience"). The SDK returns
// { data, error } and does NOT throw on API errors. A duplicate contact has
// no stable error code (it surfaces as a validation_error), so we treat the
// contact step as best-effort: log any error but never fail the request on
// it — the user-facing promise is the confirmation email below.
if (RESEND_SEGMENT_ID) {
const { error: contactError } = await resend.contacts.create({
email,
unsubscribed: false,
segments: [{ id: RESEND_SEGMENT_ID }],
});
if (contactError) {
console.error('[waitlist] contacts.create failed (non-fatal)', contactError);
}
}
const { error: sendError } = await resend.emails.send({
from: WAITLIST_FROM_EMAIL,
to: email,
subject: "You're on the PCBJam waitlist",
text: [
"You're on the list. 🎉",
'',
"We'll send your early-access invite as seats open in waves, plus the occasional",
'product update. Every update includes an unsubscribe link — and you can reply',
'to this email at any time to be taken off the list.',
'',
"Didn't sign up? Just reply and we'll remove this address.",
'',
'— The PCBJam team, built by Emergence Engineering',
'https://pcbjam.com/privacy',
].join('\n'),
});
// The confirmation send IS the user-facing promise — fail loudly if Resend
// rejected it (bad key, unverified domain, invalid from-address, …).
if (sendError) {
console.error('[waitlist] emails.send failed', sendError);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
}
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
} catch (err) {
// Defensive: unexpected throw (network error, bad construction).
console.error('[waitlist] unexpected error', err);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
}
};
// A bare GET (e.g. someone visiting the URL) shouldn't 500.
export const GET: APIRoute = () => json(405, { ok: false, error: 'method_not_allowed' });