Stack
- Next.js (App Router) + TypeScript on Vercel.
- Repo: github.com/ClownAntics/webinar-onetap. Full build notes in SETUP.md / README-build-v3.md.
- Supabase (project rilhgeshkypbcckedaoh) — Google auth + the webinar_* tables + the td_order sales mirror.
- Zoom Server-to-Server OAuth ("Webinar Data Collector" app). Omnisend for lifecycle emails.
Auth
Supabase Auth + Google, gated to allowed email domains (ADMIN_ALLOWED_DOMAINS, default clownantics.com/facepaint.com/careerlearning.com). See lib/auth.ts + middleware.ts.
Key routes
- /w/[webinarId] — public one-tap page. POST /api/register registers via Zoom, returns the personal join_url; on failure it redirects to Zoom's native registration.
- /admin — dashboard, detail (setup + status lifecycle + stats + answers), /admin/trends (revenue charts + CSV).
- /api/attendance-sync ({webinarId} or {all:true}), /api/cron — schedule refresh (Zoom's start_time is the source of truth; reschedules propagate to webinar_config within a day), attendance, Omnisend sweep/events, summary cache. Cron runs daily 05:00 UTC (Vercel Hobby cap) and requires Authorization: Bearer CRON_SECRET. /api/admin/webinar/* — save, status, banner upload.
- /api/visit — landing-page visit beacon (conversion denominator). /api/omnisend-test — seed event types / probe keys. /api/zoom-history — Dashboard-API history scan (CRON_SECRET too; dead on the current Zoom plan — Dashboard API needs Business+).
Omnisend (SPEC-omnisend-sms.md)
Per-brand keys (OMNISEND_API_KEY_FACEPAINT / _CLOWNANTICS; CareerLearning none — no-ops). Events webinar registered / attended / starting — ⚠️ "starting" is gated OFF (STARTING_ENABLED=falsein the cron route; daily-only cron can't hit a T-15 window — do not build flows on it), rolling contact properties (lastWebinarRegistered/Attended, webinarsAttendedCount), single tag webinar-audience. Idempotency via webinar_send_log; registration never blocks on Omnisend — the cron sweep is the retry.
Reporting (revenue)
7-day attribution: match attendee/no-show emails to td_order (sum TotalCostCalced for orders within 7 days of the webinar). New/Reactivated/Active segmentation. See lib/reporting.ts. History back to June 2024 is imported (backfill done; re-runnable via scripts/backfill.mjs). ⚠️ PostgREST caps responses at 1000 rows — paginate full-table reads with fetchAllRows (lib/supabase.ts). Sales lookups use the webinar_orders_for_emails SQL function (case-insensitive, indexed) — call it plain, never with .order()/.range().
Registration stats
Registration counts + by-source come from Zoom GET /webinars/{id}/tracking_sources, blended with the app's own reg events (Zoom can't see API registrations — no source_id). Needs the webinar:read:list_tracking_sources scope. The landing page also sets a 1-year onetap_identity cookie on successful registration — read server-side when the URL has no ?e=, so returning registrants one-tap from plain links.
⚠️ Zoom scope gotchas (cost days)
- Webinar scopes are filed under the "Meetings" product in Add Scopes — there is no "Webinar" category. If a scope looks "missing," click Meetings.
- Registration needs webinar:write:registrant:admin; stats need webinar:read:list_tracking_sources:admin. After adding scopes, redeploy (the S2S token is cached ~55 min).
- The webinar's custom question must be not required, or POST registrants returns code 300.
- Schedule truth: a webinar rescheduled in Zoom used to keep its stale date here forever (save preferred the stored snapshot). Since 2026-08-26, save + the daily cron both take Zoom's start_time; the stored value is only a fallback when Zoom is unreachable.
Env vars (Vercel)
ZOOM_ACCOUNT_ID / ZOOM_CLIENT_ID / ZOOM_CLIENT_SECRET / ZOOM_HOST_USER_ID
SUPABASE_URL / SUPABASE_SERVICE_KEY · SALES_SUPABASE_URL / SALES_SUPABASE_KEY
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY · ADMIN_ALLOWED_DOMAINS
OMNISEND_API_KEY_FACEPAINT / OMNISEND_API_KEY_CLOWNANTICS · CRON_SECRET · NEXT_PUBLIC_SITE_URL
Non-technical guide: How to use.