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
This commit is contained in:
Gergő Törcsvári 2026-06-27 07:59:09 +02:00
commit 39cb04b127
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
22 changed files with 830 additions and 258 deletions

View file

@ -4,7 +4,12 @@ import { Resend } from 'resend';
// 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 } from 'astro:env/server';
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.
@ -12,10 +17,36 @@ export const prerender = false;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function json(status: number, body: Record<string, unknown>) {
/**
* 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' },
headers: { 'content-type': 'application/json', ...extra },
});
}
@ -27,7 +58,11 @@ function redirect(status: 'ok' | 'error') {
});
}
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');
@ -37,7 +72,7 @@ export const POST: APIRoute = async ({ request }) => {
? await request.json()
: Object.fromEntries(await request.formData());
} catch {
return wantsJson ? json(400, { ok: false, error: 'bad_request' }) : redirect('error');
return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
}
const email = String(data.email ?? '').trim().toLowerCase();
@ -45,16 +80,16 @@ export const POST: APIRoute = async ({ request }) => {
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 }) : redirect('ok');
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' }) : redirect('error');
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 }) : redirect('ok');
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
}
const resend = new Resend(RESEND_API_KEY);
@ -98,14 +133,14 @@ export const POST: APIRoute = async ({ request }) => {
// 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' }) : redirect('error');
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
}
return wantsJson ? json(200, { ok: true }) : redirect('ok');
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' }) : redirect('error');
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
}
};