Appearance
WL Frontend (White-Label Public Site)
The WL frontend is the participant-facing website for every TogoActive event: landing pages, leaderboards, participant/team profiles, registration, merchandise upgrades, donations, and fundraiser dashboards. It is a single Next.js 14.2 (App Router) + React 18.3 + TypeScript + Tailwind CSS application that serves many events from one deployment — the event is resolved per request from the incoming Host header via the WL API, and every page, menu, theme, and block tree is loaded from that event's admin-authored configuration.
- Source:
/var/www/togoactive-development/wl-event/frontend-wl-development - Production checkout:
/var/www/wl_site/v3/tga-v3-wl-web(systemdwl-web.servicebehind Apache) - GitHub repo:
tga-v3-wl-web(private)
Related docs
- 01-architecture-overview.md — how the five systems and two servers fit together
- 04-wl-api.md — the Laravel WL API this frontend consumes (
/resolve-event,/config,/pages, auth, payments) - 02-admin-frontend.md — the admin that authors everything this site renders (Page Builder, Form Builder, Menu Setup, Integrations)
- 08-event-setup-guide.md — operator-level guide to configuring an event end to end
1. Stack & multi-tenant model
| Concern | Choice |
|---|---|
| Framework | Next.js ^14.2.35, App Router, force-dynamic root layout (no static generation — every request is event-specific) |
| UI | React 18.3.1, TypeScript 5.4, Tailwind CSS 3.4, lucide-react icons |
| Sanitization | dompurify (custom HTML blocks, client-side only) |
| Misc | qrcode (invite/profile QR), react-easy-crop (avatar cropping) |
| Node | v20 |
One deployment, many events
There are no per-event builds. A single next start process answers for every event domain (e.g. sg61.togoparts.com, custom domains, subdomains). The request lifecycle is:
- Browser hits
https://sg61.togoparts.com/…— DNS/Apache routes it to the one Next.js process. middleware.tsresolves theHostheader to an event ID via the WL API and forwards it as an internal header.- Server components fetch that event's config and pages from the WL API and render them.
- Everything the visitor sees — theme colors, fonts, logo, menu, page blocks, copy, analytics IDs — comes from admin-managed data, not code.
Consequences worth internalizing:
- Never hardcode an event. No event names, IDs, colors, or domains in code. If a block needs copy, it must come from block content or event config, with tokens for dynamic values.
NEXT_PUBLIC_APP_URLis intentionally unset. There is no single "app URL" — canonical/absolute URLs are derived from the request host (SEO, sitemap) or built server-side by the WL API (FrontendUrl::forEvent(), custom-domain aware).- A code deploy affects every event at once. Changes must be additive/backward-compatible with existing saved page data (see Conventions & gotchas).
2. Event resolution & middleware
lib/event.ts — getEventId()
Server components resolve the current event in strict priority order:
EVENT_IDenv — pins the whole deployment to one event. Used for local dev and single-event deploys.x-resolved-event-idheader — set bymiddleware.ts, so pages don't re-resolve.Hostheader → WL API/resolve-event— vialib/domain.ts(normalizeHoststrips the port and a leadingwww.); backed by the WL API'sevent_domainstable (status='active').
If none resolve, DomainNotConnectedError is thrown; the root layout catches it and renders components/DomainNotConnected.tsx ("event not connected with this domain") instead of crashing.
middleware.ts (Edge runtime)
Two responsibilities, in order:
Event resolution, once per request. Resolves
Host→ event ID and forwards it asx-resolved-event-id. Keeps its own 60s in-memory host cache — it runs on the Edge runtime and cannot importlib/serverCache.ts— and serves a known-good stale value if the API blips (dropping the header would incorrectly show the not-connected page). Fetches use an abort timeout so a stalled API can't hang the middleware. A host with no active domain simply carries no header; the layout handles the rest.Custom URL-pattern routing. Admins can give system pages custom URL patterns (e.g. a
user_detailpage answering at/fundraiser/[id]). The middleware only engages for two-segment numeric paths that Next.js would otherwise 404 (cheap common path), skipping knownSYSTEM_ROUTE_PREFIXES. It asks the WL API's page-by-pattern endpoint, then maps the page type to the real route viaTYPE_TO_ROUTE:Page type Rendered by user_detail/user/{id}team_detail/team/{id}host_detail/user/{id}donation/individuals/donate/{id}donate/team/donate/{id}Match mode
current→ internal rewrite (URL stays pretty);redirect→ 301 to the canonical route; no match → continue to Next's normal 404.
3. Routing / pages reference
All routes live under app/. The root app/layout.tsx is force-dynamic, implements generateMetadata (event name/tagline/favicon), loads the event config, and mounts the global chrome (Header, Footer, Analytics, EventConfigProvider, MobileBottomNav, DesktopEventBar, GetHelpButton, RouteProgress, ReferralCapture).
Content pages (admin-authored block trees)
| Route | File | Notes |
|---|---|---|
/ | app/page.tsx | Landing page: getEventPage("home") → BlockRenderer; falls back to legacy app/LandingClient.tsx if the event has no builder home page. Content constrained to max-w 1920px. |
/{slug} | app/[slug]/page.tsx | Catch-all for every admin-built page. There are no dedicated route dirs for /leaderboard, /faq, or /achievements — those are builder pages served by this route. Detects participants_section in the tree to widen the layout. Unknown slug → 404. |
/participants | app/participants/page.tsx | Participants directory (participants-family blocks). |
/teams | app/teams/page.tsx | Teams directory (TeamsDiscoveryPage). |
Where is
/leaderboard? In the database, as a builder page with aleaderboardblock. If a route "doesn't exist", check the event's pages in the admin before assuming missing code.
Profile & donation pages
| Route | File | Notes |
|---|---|---|
/profile | app/profile/page.tsx | Logged-in user's own profile; getSystemPage("my_profile"), defaults to my_profile_form. |
/fundraiser | app/fundraiser/page.tsx | Fundraiser dashboard (fundraiser_section); has its own auth gate. |
/donate | app/donate/page.tsx | Donation signpost (pick who to support). The one donation-related page that is indexable. |
/user/{id}/[[...slug]] | app/user/[id]/[[...slug]]/page.tsx | Public participant profile (user_detail page type) + MobileStickyDonateBlock + JsonLd. Optional trailing slug for share-friendly URLs. |
/team/{id}/[[...slug]] | app/team/[id]/[[...slug]]/page.tsx | Public team profile (team_detail). |
/team/{id}/invite | app/team/[id]/invite/page.tsx | Team invite: QR code + ?ref= join link; auth-gated. |
/individuals/donate/{id} | app/individuals/donate/[id]/… | Individual donation checkout → DonationPageClient (site chrome hidden). |
/team/donate/{id} | app/team/donate/[id]/… | Team donation checkout → DonationPageClient (chrome hidden). |
Registration / payment / upgrade
| Route | File | Notes |
|---|---|---|
/registration | app/registration/page.tsx | Multi-step RegistrationFlow (see §7). Chrome hidden. |
/registration/success/{token} | SuccessPageClient | Post-registration summary; ?upgrade=1 renders the upgrade variant. |
/upgrade | app/upgrade/page.tsx | Post-registration "Buy Merch" flow → UpgradeFlow. Chrome hidden. |
/payment/process/{token}/{sessionId} | PaymentProcessClient | Return-from-Stripe verifier; polls verify every 2s, up to 15 attempts, then branches (see §7). |
/donation/success/{token} | — | Donation success page (incl. tax-deduction details when applicable). |
Auth
| Route | File | Notes |
|---|---|---|
/login | app/login/… | LoginForm; chrome hidden. |
/register | app/register/… | Redirects to /login?mode=register. |
/reset-password | app/reset-password/… | Password reset. |
/user/auth/request | — | Legacy OAuth handler (Togoparts-era). |
/user/auth/v2/callback | — | Google sign-in v2 (JWT) callback. |
Admin preview iframes (see §9)
| Route | Protocol |
|---|---|
/builder-preview | pb-* postMessage (Page Builder live preview) |
/registration-preview | fb-* postMessage (Form Builder live preview, renders the real FieldRenderer) |
/upgrade-preview | Upgrade-tab preview |
Route handlers & specials
| Route | Notes |
|---|---|
POST /api/revalidate | Requires x-revalidate-secret header; clearByEvent or clearAll on the server TTL cache. Called by the admin/WL API after saves. |
GET /clear-cache?all=1&secret=… | Manual cache clear. |
/robots.txt | Route handler; consistent with lib/seo.ts noindex list. |
/sitemap.xml | Route handler; host-aware; walks participants/teams up to 40 pages × 100 each; 1h cache. |
error.tsx / global-error.tsx / not-found.tsx | Error boundaries and 404. |
Registration sub-components (under app/registration/): RegistrationStep, MerchandiseStep, QualificationStep, DonationStep, SummaryStep, Stepper, FieldRenderer, AmountPicker, AddressPickerModal, CreateTeamModal, TermsModal, SignInBanner, MerchStickyBar.
4. The block system
Admin-authored pages are trees of typed blocks. components/BlockRenderer.tsx walks the tree and dispatches each block via its RENDERERS map (type string → component). Implementations live in components/blocks/*.tsx.
Renderer pipeline
For each block:
block.visible === false→ render nothing (admin visibility toggle).- Unknown
type→ rendernull(forward-compatible: old deployments silently skip new block types). - Container types short-circuit:
columns,group,flex,grid,participants_section,user_profile_section,donation_section,donation_signpost_section,team_profile_section,fundraiser_section,my_profile_layout,faq_sectionrender their own wrapper, recurse into children, and (for the*_sectiontypes) provide a React context with fetched data that child blocks consume. - Leaf types render via their component, wrapped with width classes (
boxed→max-w-4xl,header→max-w-7xl, full-bleed otherwise) anddata-pb-block-id(used by the builder preview for click-to-select). - Styling comes from
components/blocks/styleUtils.ts—getBlockStyles/getBorderStyles/ANIMATION_CLASSEStranslate the block's saved style object (padding, background, border, radius, animation) into inline styles + classes.flex/gridcontainers emit a scoped<style>tag with media queries for responsive columns.
Block catalog
[C] = client component ("use client" — required for any block using hooks).
Primitivesheading, text, image, button, spacer, divider, video, icon, list, table (cells support {{tokens}}), quote, stats_counter, social_icons, accordion [C], tabs [C], countdown [C], progress_bar, sponsor_grid, testimonial [C], highlight_carousel [C], whiteboard [C], custom_html [C].
custom_html: DOMPurify-sanitized client-side only (no SSR of raw HTML); its CSS is scoped undercb_<blockId>; supports live{{total_raised}}/{{total_distance}}tokens. Scripts are stripped by DOMPurify — anything needing JS must be a native block (this is exactly whyearly_bird_sliderexists).
Landing / promotionalearly_bird_slider [C] (native JS carousel — built because Custom HTML can't run scripts), funds_raised_bar (live "TOTAL FUNDS RAISED" banner via /stats), image_slider [C] (reusable responsive carousel: arrows/dots/swipe/autoplay), landing_faq [C] (short home FAQ, distinct from the FAQ Manager page), leaderboard [C].
FAQ family (children of faq_section, consume FaqContext from lib/faqContext.tsx) faq_title, faq_search, faq_nav, faq_groups.
Participants family (children of participants_section, consume ParticipantsContext) participants_title, participants_tabs, participants_filter, participants_grid, participants_pagination; plus TeamsDiscoveryPage for /teams.
Profile / teamprofile_header [C], participant_stats, user_profile_tabs [C] (Donors / Activities / Gallery / Trophy), achievements_grid [C], supporters_list [C], event_host_card [C], recent_donors [C], rewards_grid [C], mobile_sticky_donate, team_profile_header [C], team_profile_tabs [C], GalleryLightbox [C].
Donationsdonation_recipient_header, donation_form [C], donation_signpost_card [C], DonationPageHeader.
Fundraiser dashboard (components/blocks/FundraiserBlocks.tsx) fundraiser_section (provides context + fetches the dashboard payload), fundraiser_goal_banner, fundraiser_pledge_card, fundraiser_social_share, fundraiser_share_link, fundraiser_epledge.
My-Profile (components/blocks/ProfilePageBlocks.tsx, routed by child.content.tab) my_profile_form, my_account_security, my_team_panel, my_strava, my_achievements, my_ebib_download, my_ecert_download.
Container / context pattern
Section containers own the data. Example: participants_section fetches the list, holds filter/tab/page state, and exposes it via context; participants_grid and friends are dumb consumers, so admins can rearrange, restyle, or omit them freely in the Page Builder without breaking data flow. The same pattern applies to FAQ (FaqContext), fundraiser, donation, and profile sections. When adding a block that needs shared data, prefer extending the section's context over fetching inside the leaf block.
5. Data fetching & caching
lib/api.ts — apiFetch
Single fetch wrapper for the WL API. Base URL is NEXT_PUBLIC_API_URL (fallback http://localhost:8000); server-side reads prefer API_BASE_URL when set. Attaches Bearer from localStorage.auth_token unless the call opts out (token: null); handles FormData bodies (drops the JSON content-type); failed responses throw errors decorated with .status and .errors (Laravel validation shape).
Server-side reads (SSR)
Per-request server reads in lib/event.ts: getEventConfig, getEventPage(slug), getEventPageByType(type), getSystemPage(key), getResolvedMeta. Two cache layers:
- React
cache()— dedupes within a single request render. lib/serverCache.ts— cross-request TTL cache (60s for host/config/page/meta) in aglobalThisMap, so it survives module reloads within one process. Keys are namespaced (config:<eventId>,page:<eventId>:<slug>, …) which is what makesclearByEventpossible. Serves stale on error — if the WL API blips, visitors get the last good payload instead of an error page.
Freshness paths, in order of preference:
- Push invalidation: admin saves → WL API calls
POST /api/revalidatewithx-revalidate-secret→clearByEvent(eventId)(orclearAll). Edits show on the next request. - Hard reload bypass: a browser hard reload (Ctrl/Cmd+Shift+R) sends
Cache-Control: no-cache;isHardReload()inlib/event.tsdetects it and bypasses the TTL cache — the support answer for "admin saved but the page looks old" is hard reload, not restart. - TTL expiry: worst case, 60 seconds.
Client-side reads
SSR delivers config, block trees, and SEO; the client fetches what must be live or personal:
useEventStats—GET /events/{id}/stats; powers the header totals,funds_raised_bar,stats_counter, and custom-HTML{{total_raised}}/{{total_distance}}.viewerContext—/auth/me; current user for header auth controls, gated menu items, profile ownership checks.- Participants / FAQ / fundraiser contexts — interactive lists, search, pagination.
Rule of thumb: page config and blocks are server-fetched; live numbers, auth state, and paginated lists are client-fetched.
6. Header / Footer & site chrome
Header.tsx [C]
Two rows:
- Row 1: event logo ·
HeaderStats· auth controls.HeaderStatsshows "$X raised" + "Y KM by N participants" once the leaderboard has started, else just "N participants" (live viauseEventStats). - Row 2: navigation from
event.menuItems(admin Menu Setup).filterMenuByConditionmaps internal targets (e.g.events.leaderboards→/leaderboard) and applies condition gating —authentication_based,user_type, andevent_period(viacomputeEventPeriods). Then action buttons: DONATE / SHARE GOAL / BUY MERCH (when upgrade is enabled) and JOIN NOW / EVENT ENDED.
DEFAULT_HEADER mirrors the admin AppearanceController DEFAULTS, so an unconfigured event still renders a sane header. Also in the header: dismissible PromoBar, and ProfileDropdown (QR codes, Strava link, Join/Create Team modal, Help modal, Invite modal).
Chrome is hidden (no header/footer) on /builder-preview, the donate checkout pages, /registration*, /upgrade*, and /login — focused funnels and iframes.
Footer.tsx [C]
Order: footer logo → social icons → social handle → "Virtual fundraising campaign powered by togoparts.com" (accent #F6861F). Social rules:
TOGOPARTS_DEFAULTSare used only when the admin configured no socials at all; one configured link suppresses all defaults.- A per-link
social.toggles[key] === falsehides that icon even if a URL exists. - Icon assets are served from
static.togoactive.com.
Other layout chrome
MobileBottomNav, DesktopEventBar, GetHelpButton, RouteProgress (top progress bar on navigation), and ReferralCapture (persists ?ref= into sessionStorage so a referral survives browsing before registration).
7. Registration, upgrade & payment flows
Step machine
RegistrationFlow derives step order from form.meta flags (authored in the admin Form Builder):
registration → (merchandise, if merchandise_enabled)
→ (qualification + donation, if qualification_enabled)
→ summarybuildStepperSteps is shared with the success page so the stepper is consistent end to end.
Gates
Before the form renders: allow_re_registration (QA flag — bypasses gates), allow_early_registration (soft launch — only before event start), and date gates that distinguish not started / open / ended with friendly messages. EventEndedGuard blocks the flow on ended events.
Partial registration
Step 1's Continue immediately POST /auth/register — creating the user, event_user, and a $0 payment — and stores the result as partialReg. This is deliberate: abandoners are still counted and can be followed up. Later steps mutate this partial registration rather than creating anything new.
MerchandiseStep (shared with UpgradeFlow)
Catalog of core/addon items, sizes, quantities, customizations, country and coupon inputs, AddressPickerModal, MerchStickyBar (running total). Continue → finalize-merchandise; paid total → Stripe (payment_type: upgrade); free → advance in place.
Qualification & donation
QualificationStep offers qualify now vs later; when donate now is chosen, donation fields render inline in the same step. The final PUT /auth/registration/finalize-donation closes the flow: with donation_now and amount > 0 it creates a Stripe Checkout session (payment_type: donation) and redirects; otherwise it goes straight to success.
Stripe roundtrip & success branching
Stripe redirects back to /payment/process/{token}/{sessionId} → PaymentProcessClient polls the verify endpoint every 2s, up to 15 attempts (webhook race tolerance), then branches by payment type:
payment_type | Destination |
|---|---|
donation | /donation/success/{token} |
upgrade | /registration/success/{token}?upgrade=1 |
| otherwise | /registration/success/{token} |
SuccessPageClient decrypts the token server-side and uses has_merch / merch_items / with_donation to build the summary rows (merch vs donation vs plain registration content).
UpgradeFlow (/upgrade, post-registration Buy Merch)
useAuthGate requires login → POST /upgrade/start → renders the same MerchandiseStep with catalogOverride, lockSize, and lockedAddress (existing registration constrains options) → /upgrade/finalize → Stripe or straight to success?upgrade=1. Handles 409/401/403 and can_upgrade=false with explicit states. The header's BUY MERCH button and step_count.enable (admin Upgrade tab) gate access.
Instrumentation & referral
Analytics events fired: registration_started, registration_step, registration_submitted, upgrade_started. A captured ?ref= seeds the referral field, switches to team mode, and auto-selects the referrer's team.
8. Auth on the public site
- Token:
localStorage.auth_token;apiFetchattaches it as Bearer automatically.viewerContextresolves the current user via/auth/me. - Login (
/login): email/password against the WL API — which verifies against the legacy Togoparts credential store (a layeredcrypt()verify covering bcrypt and legacy hashes, strictly read-only — no hash rewriting; see 04-wl-api.md)./registeris just a redirect to/login?mode=register. - Google sign-in: v2 JWT flow returns via
/user/auth/v2/callback;/user/auth/requestremains for the legacy OAuth path. - Gated areas:
/fundraiserand/team/{id}/invitehave their own auth gates;UpgradeFlowusesuseAuthGate;/profilerequires a session. Public profiles (/user/{id},/team/{id}) are open. - Registration signups provision a backing Togoparts account server-side, so
tgp_useridis always set (address saves and TGP leaderboard integration depend on it).
9. Preview iframes for the admin
Three routes exist solely to be embedded in admin-frontend iframes, communicating via postMessage with namespaced message types. All hide the site chrome.
| Route | Protocol | Used by |
|---|---|---|
/builder-preview | pb-* | Page Builder: admin posts draft block trees, preview renders via the real BlockRenderer; data-pb-block-id attributes enable click-to-select back into the editor. |
/registration-preview | fb-* | Form Builder: renders the real registration FieldRenderer (regFieldPreview.js on the admin side), so what admins see is exactly what participants get. |
/upgrade-preview | — | Form Builder's Upgrade tab preview. |
EventConfigProvider supports ?preview=1, merging config posted from the parent frame over the server-loaded config (theme experiments without saving). Because the admin preview iframe points at the deployed WL app (VITE_WL_API_URL / remote server), a stale deploy there produces preview-only bugs — check deploy state before debugging "works on site, broken in preview".
10. Analytics injection
components/Analytics.tsx [C], mounted in the root layout with event.analytics — { ga4MeasurementId, gtmId, metaPixelId, contentsquareId }, resolved server-side by the WL API from the admin's Setup → Integrations (event_integrations, whitelisted keys). Per-provider behavior (all strategy="afterInteractive"):
- GA4:
send_page_view: falseat init; SPApage_viewevents fired manually onpathnamechange (App Router navigations don't reload). - GTM: standard
dataLayerbootstrap. - Meta Pixel: init + SPA
PageViewon navigation. - Contentsquare: tag injection.
Nothing renders when a provider is unconfigured — events without analytics load zero tracking bytes. lib/analytics.ts exposes track(name, params) which fans out to gtag, dataLayer.push, and fbq('trackCustom', …), used by the registration/upgrade funnels.
11. SEO
Meta cascade
getResolvedMeta (backed by the WL API's MetaResolver) resolves per page: page-level SEO override → per-type SEO template → event defaults. toNextMetadata converts the payload to Next.js Metadata: canonical URL, robots, Open Graph (1200×630 epledge image), Twitter card.
Per the admin's "Per-Page Meta" feature: it is wired to registration and upgrade only. Landing, participants, and leaderboard use SEO Templates — do not map them to per-page meta.
Indexing rules (lib/seo.ts)
NOINDEX_PATHS: /login, /register, /registration*, /reset-password, /payment*, /profile, /donation*, /individuals/donate*, /team/donate*, /upgrade*, the three preview routes, /clear-cache, /api*. /donate (the signpost) is deliberately indexable — it's the SEO entry point for donations.
Sitemap & robots
Both are route handlers and consistent with NOINDEX_PATHS. /sitemap.xml is host-aware (URLs for the requesting event's domain only), walks participant and team listings up to 40 pages × 100 entries, and caches for 1h. Profile pages emit JsonLd structured data.
12. Env, build & deploy
Environment variables
| Var | Purpose |
|---|---|
EVENT_ID | Optional. Pins the deployment to one event (local dev). Unset in prod → Host-based resolution. |
NEXT_PUBLIC_API_URL | WL API base for the browser. |
API_BASE_URL | WL API base for server-side fetches (falls back to the public one). |
REVALIDATE_SECRET | Shared secret for /api/revalidate and /clear-cache. |
NEXT_DIST_DIR | Set by safe-build.sh to .next-build (staging build dir). |
NEXT_PUBLIC_APP_URL | Intentionally unset — multi-domain deployment. |
next.config.js: distDir from NEXT_DIST_DIR (default .next), images.remotePatterns allows static.togoactive.com and www.togoparts.com.
Scripts
dev (next dev) · build (next build) · deploy (safe-build.sh — the only correct prod rebuild) · start · lint · type-check (tsc --noEmit).
Production deploy — safe-build.sh
Prod runs next start via systemd wl-web.service at /var/www/wl_site/v3/tga-v3-wl-web, behind Apache. Never run next build in place while the service is up: next build wipes .next while the running process holds the old manifest, producing intermittent entryCSSFiles TypeErrors and CSS-hash 404s until restart. safe-build.sh instead:
- Builds into staging
.next-build(NEXT_DIST_DIR) — live.nextuntouched, zero downtime during the ~30s build. - Atomic swap:
mv .next → .next-old,mv .next-build → .next. - One fast
systemctl restart(Next ready in ~350ms; the sub-second blip is absorbed by Apache's proxy retry). - Health-checks
/and/teamswithHost: sg61.togoparts.com; on failure, auto-rollback to.next-old.
A failed build never touches the live .next. Node v20 (nvm-managed npm path is baked into the script).
13. Conventions & gotchas
"use client"on hook-using blocks. Anycomponents/blocks/*.tsxfile using hooks (state, effects, context) must start with"use client";. Server-rendered by default; forgetting this fails the build or silently breaks interactivity.- No backticks inside
dangerouslySetInnerHTMLCSS. Scoped-CSS blocks use template literals (dangerouslySetInnerHTML={{ __html: \...` }}`); a backtick anywhere inside — including comments — terminates the literal and breaks the build. - Stale saved block state after schema changes. Saved pages store the block shape at save time. Changing a block's content schema leaves old pages carrying the old shape; the first debugging step for a misbehaving block after a schema change is delete + re-add the block in the Page Builder. Prefer additive schema changes with defaults.
- Avatar/image CDN routing.
profile_imgpaths starting withuploads/belong to the TogoActive DO bucket (static.togoactive.com), not the Togoparts CDN. Any new image resolver must include theuploads/branch or avatars break for WL-registered users. - Custom HTML can't run scripts. DOMPurify strips
<script>; interactive embeds must become native blocks (precedent:early_bird_slider). - Unknown block types render
null. Safe by design — but it means a typo'd type string fails silently. Check theRENDERERSmap key when a new block "doesn't show up". - Tokens over static copy. Use
lib/dynamicContent.tsresolveDynamicContent({{event.name}},{{event.description}},{{date.today}}; unresolved tokens are left literal) andlib/registrationForm.tsresolveTemplate({{user_email}},{{username}},{{full_name}},{{event_name}}). Blocks add their own ({{share_text}},{{teamname}},{{leaderboard_start_date}}, FAQ{{count}}/{{groups}}/{{hashtag}}, custom-HTML{{total_raised}}/{{total_distance}}). Tokens must resolve from real config; gate whole blocks on data presence rather than rendering empty shells. - Theme is injected, not compiled.
EventConfigProviderinjects theme CSS variables and the event's Google font at runtime; Tailwind utility classes reference the vars. Don't hardcode brand colors in block components. - "Admin saved but site is stale" → hard reload (bypasses the 60s server cache), then check
/api/revalidatewiring — before suspecting a code bug. - Intermittent "event not connected" in dev historically came from single-threaded
php artisan serve+ rate limiting on the WL API; the middleware host cache mitigates it, nginx+php-fpm is the real fix.