Skip to content

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 (systemd wl-web.service behind Apache)
  • GitHub repo: tga-v3-wl-web (private)

Related docs


1. Stack & multi-tenant model

ConcernChoice
FrameworkNext.js ^14.2.35, App Router, force-dynamic root layout (no static generation — every request is event-specific)
UIReact 18.3.1, TypeScript 5.4, Tailwind CSS 3.4, lucide-react icons
Sanitizationdompurify (custom HTML blocks, client-side only)
Miscqrcode (invite/profile QR), react-easy-crop (avatar cropping)
Nodev20

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:

  1. Browser hits https://sg61.togoparts.com/… — DNS/Apache routes it to the one Next.js process.
  2. middleware.ts resolves the Host header to an event ID via the WL API and forwards it as an internal header.
  3. Server components fetch that event's config and pages from the WL API and render them.
  4. 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_URL is 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.tsgetEventId()

Server components resolve the current event in strict priority order:

  1. EVENT_ID env — pins the whole deployment to one event. Used for local dev and single-event deploys.
  2. x-resolved-event-id header — set by middleware.ts, so pages don't re-resolve.
  3. Host header → WL API /resolve-event — via lib/domain.ts (normalizeHost strips the port and a leading www.); backed by the WL API's event_domains table (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:

  1. Event resolution, once per request. Resolves Host → event ID and forwards it as x-resolved-event-id. Keeps its own 60s in-memory host cache — it runs on the Edge runtime and cannot import lib/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.

  2. Custom URL-pattern routing. Admins can give system pages custom URL patterns (e.g. a user_detail page answering at /fundraiser/[id]). The middleware only engages for two-segment numeric paths that Next.js would otherwise 404 (cheap common path), skipping known SYSTEM_ROUTE_PREFIXES. It asks the WL API's page-by-pattern endpoint, then maps the page type to the real route via TYPE_TO_ROUTE:

    Page typeRendered 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); redirect301 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)

RouteFileNotes
/app/page.tsxLanding 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.tsxCatch-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.
/participantsapp/participants/page.tsxParticipants directory (participants-family blocks).
/teamsapp/teams/page.tsxTeams directory (TeamsDiscoveryPage).

Where is /leaderboard? In the database, as a builder page with a leaderboard block. If a route "doesn't exist", check the event's pages in the admin before assuming missing code.

Profile & donation pages

RouteFileNotes
/profileapp/profile/page.tsxLogged-in user's own profile; getSystemPage("my_profile"), defaults to my_profile_form.
/fundraiserapp/fundraiser/page.tsxFundraiser dashboard (fundraiser_section); has its own auth gate.
/donateapp/donate/page.tsxDonation signpost (pick who to support). The one donation-related page that is indexable.
/user/{id}/[[...slug]]app/user/[id]/[[...slug]]/page.tsxPublic participant profile (user_detail page type) + MobileStickyDonateBlock + JsonLd. Optional trailing slug for share-friendly URLs.
/team/{id}/[[...slug]]app/team/[id]/[[...slug]]/page.tsxPublic team profile (team_detail).
/team/{id}/inviteapp/team/[id]/invite/page.tsxTeam 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

RouteFileNotes
/registrationapp/registration/page.tsxMulti-step RegistrationFlow (see §7). Chrome hidden.
/registration/success/{token}SuccessPageClientPost-registration summary; ?upgrade=1 renders the upgrade variant.
/upgradeapp/upgrade/page.tsxPost-registration "Buy Merch" flow → UpgradeFlow. Chrome hidden.
/payment/process/{token}/{sessionId}PaymentProcessClientReturn-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

RouteFileNotes
/loginapp/login/…LoginForm; chrome hidden.
/registerapp/register/…Redirects to /login?mode=register.
/reset-passwordapp/reset-password/…Password reset.
/user/auth/requestLegacy OAuth handler (Togoparts-era).
/user/auth/v2/callbackGoogle sign-in v2 (JWT) callback.

Admin preview iframes (see §9)

RouteProtocol
/builder-previewpb-* postMessage (Page Builder live preview)
/registration-previewfb-* postMessage (Form Builder live preview, renders the real FieldRenderer)
/upgrade-previewUpgrade-tab preview

Route handlers & specials

RouteNotes
POST /api/revalidateRequires 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.txtRoute handler; consistent with lib/seo.ts noindex list.
/sitemap.xmlRoute handler; host-aware; walks participants/teams up to 40 pages × 100 each; 1h cache.
error.tsx / global-error.tsx / not-found.tsxError 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:

  1. block.visible === false → render nothing (admin visibility toggle).
  2. Unknown type → render null (forward-compatible: old deployments silently skip new block types).
  3. 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_section render their own wrapper, recurse into children, and (for the *_section types) provide a React context with fetched data that child blocks consume.
  4. Leaf types render via their component, wrapped with width classes (boxedmax-w-4xl, headermax-w-7xl, full-bleed otherwise) and data-pb-block-id (used by the builder preview for click-to-select).
  5. Styling comes from components/blocks/styleUtils.tsgetBlockStyles / getBorderStyles / ANIMATION_CLASSES translate the block's saved style object (padding, background, border, radius, animation) into inline styles + classes. flex/grid containers 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 under cb_<blockId>; supports live {{total_raised}} / {{total_distance}} tokens. Scripts are stripped by DOMPurify — anything needing JS must be a native block (this is exactly why early_bird_slider exists).

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.tsapiFetch

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:

  1. React cache() — dedupes within a single request render.
  2. lib/serverCache.ts — cross-request TTL cache (60s for host/config/page/meta) in a globalThis Map, so it survives module reloads within one process. Keys are namespaced (config:<eventId>, page:<eventId>:<slug>, …) which is what makes clearByEvent possible. 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/revalidate with x-revalidate-secretclearByEvent(eventId) (or clearAll). Edits show on the next request.
  • Hard reload bypass: a browser hard reload (Ctrl/Cmd+Shift+R) sends Cache-Control: no-cache; isHardReload() in lib/event.ts detects 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:

  • useEventStatsGET /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.


Header.tsx [C]

Two rows:

  • Row 1: event logo · HeaderStats · auth controls. HeaderStats shows "$X raised" + "Y KM by N participants" once the leaderboard has started, else just "N participants" (live via useEventStats).
  • Row 2: navigation from event.menuItems (admin Menu Setup). filterMenuByCondition maps internal targets (e.g. events.leaderboards/leaderboard) and applies condition gating — authentication_based, user_type, and event_period (via computeEventPeriods). 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.

Order: footer logo → social icons → social handle → "Virtual fundraising campaign powered by togoparts.com" (accent #F6861F). Social rules:

  • TOGOPARTS_DEFAULTS are used only when the admin configured no socials at all; one configured link suppresses all defaults.
  • A per-link social.toggles[key] === false hides 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)
             → summary

buildStepperSteps 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_typeDestination
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; apiFetch attaches it as Bearer automatically. viewerContext resolves the current user via /auth/me.
  • Login (/login): email/password against the WL API — which verifies against the legacy Togoparts credential store (a layered crypt() verify covering bcrypt and legacy hashes, strictly read-only — no hash rewriting; see 04-wl-api.md). /register is just a redirect to /login?mode=register.
  • Google sign-in: v2 JWT flow returns via /user/auth/v2/callback; /user/auth/request remains for the legacy OAuth path.
  • Gated areas: /fundraiser and /team/{id}/invite have their own auth gates; UpgradeFlow uses useAuthGate; /profile requires a session. Public profiles (/user/{id}, /team/{id}) are open.
  • Registration signups provision a backing Togoparts account server-side, so tgp_userid is 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.

RouteProtocolUsed by
/builder-previewpb-*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-previewfb-*Form Builder: renders the real registration FieldRenderer (regFieldPreview.js on the admin side), so what admins see is exactly what participants get.
/upgrade-previewForm 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: false at init; SPA page_view events fired manually on pathname change (App Router navigations don't reload).
  • GTM: standard dataLayer bootstrap.
  • Meta Pixel: init + SPA PageView on 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

VarPurpose
EVENT_IDOptional. Pins the deployment to one event (local dev). Unset in prod → Host-based resolution.
NEXT_PUBLIC_API_URLWL API base for the browser.
API_BASE_URLWL API base for server-side fetches (falls back to the public one).
REVALIDATE_SECRETShared secret for /api/revalidate and /clear-cache.
NEXT_DIST_DIRSet by safe-build.sh to .next-build (staging build dir).
NEXT_PUBLIC_APP_URLIntentionally 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:

  1. Builds into staging .next-build (NEXT_DIST_DIR) — live .next untouched, zero downtime during the ~30s build.
  2. Atomic swap: mv .next → .next-old, mv .next-build → .next.
  3. One fast systemctl restart (Next ready in ~350ms; the sub-second blip is absorbed by Apache's proxy retry).
  4. Health-checks / and /teams with Host: 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. Any components/blocks/*.tsx file 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 dangerouslySetInnerHTML CSS. 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_img paths starting with uploads/ belong to the TogoActive DO bucket (static.togoactive.com), not the Togoparts CDN. Any new image resolver must include the uploads/ 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 the RENDERERS map key when a new block "doesn't show up".
  • Tokens over static copy. Use lib/dynamicContent.ts resolveDynamicContent ({{event.name}}, {{event.description}}, {{date.today}}; unresolved tokens are left literal) and lib/registrationForm.ts resolveTemplate ({{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. EventConfigProvider injects 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/revalidate wiring — 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.

Organiser guide and developer documentation for the TogoActive platform.