- 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
148 lines
5.6 KiB
TypeScript
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' });
|