i18n and Content

How the Landing (swapps-campaigns) site handles bilingual (EN/ES) content and the analytics/tracking layer. The site is config-driven — there is no i18n library; each locale is its own LandingConfig.

Bilingual landings (EN/ES)

English lives at the root (/{slug}/); Spanish lives under /es/{slug}/. The same @slug page component renders both — only the content source differs by locale.

Concern Where
English content data/landings.tslandings array
Spanish content data/landings.es.tslandingsEs array (same slugs)
Lookup getLanding(slug, locale) selects the right array
Page data / SEO data/landingData.tsbuildLandingData()
EN routing pages/@slug/
ES routing pages/es/@slug/ (only prerenders slugs with an ES entry, via hasLocale)
Language selector src/components/LanguageSwitcher.tsx

Adding a Spanish version

Add a matching LandingConfig with the same slug to the landingsEs array in data/landings.es.ts, translating only the display text. Keep field names, select values, hrefs, icons, googleAdsId and googleAdsConversion identical to the English entry — lead submission and conversion tracking depend on them. Set seo.canonicalPath: "/es/{slug}/".

The page is then served at go.swapps.com/es/{slug}/, the EN/ES selector appears automatically, and hreflang alternates are emitted.

Selector and alternates

buildLandingData() computes, per page:

  • the canonical URL (seo.canonicalPath or the locale path);
  • absolute hreflang alternate <link> tags for en and es;
  • root-relative selector paths that drive LanguageSwitcher.

The LanguageSwitcher links to the same page in the other locale. When a slug has no counterpart, Spanish alternates point back at the English URL, so the toggle never dead-ends — it renders nothing rather than linking to a missing page.

No browser-language auto-redirect

Visitors land on the exact URL the ad/link points to and switch manually. This keeps paid-campaign URLs and conversion attribution deterministic.

Bespoke-landing copy

The custom digital-operations and construction-app-development layouts carry hardcoded copy kept as { en, es } maps in src/components/digitalOperationsCopy.ts and src/components/aecConstructionCopy.ts. Form/UI strings shared across forms live in src/components/formStrings.ts.

flowchart LR REQ["Request /{slug}/ or /es/{slug}/"] --> GL["getLanding(slug, locale)"] GL --> EN["landings (EN)"] GL --> ES["landingsEs (ES)"] EN --> BLD["buildLandingData()"] ES --> BLD BLD --> OUT["LandingConfig + canonical + hreflang + selector paths"]

Tracking (GTM)

GTM is on by default: GTM_ID falls back to the GTM-WBC6V37 container in vite.config.ts (inlined at build time). To point at a different container or disable it, override the env var:

  • Local: copy .env.example to .env and set GTM_ID (and optionally GTAG_ID).
  • Production: set GTM_ID / GTAG_ID in the Cloudflare Pages dashboard to override the default.

When GTM_ID resolves to a value, the GTM snippet is injected into the SSR <head> (pages/+onRenderHtml.tsx) and the <noscript> fallback into <body>. Set it to an empty string to inject nothing — analytics calls then just queue on window.dataLayer with no network hit.

Identifiers only — never secrets

GTM_ID, GTAG_ID, RECAPTCHA_SITE_KEY and friends are public identifiers inlined into the client bundle. Never put secrets or API tokens in env vars here.

Analytics events

src/lib/analytics.ts pushes these events to window.dataLayer (safe whether or not GTM has loaded yet):

Event Fired when
landing_view A landing mounts.
cta_click Any CTA button is clicked.
lead_form_start The user first interacts with the form.
lead_form_submit The form is submitted.
lead_form_success submitLead() resolves successfully.
lead_form_error Validation fails or submitLead() errors.

UTMs (utm_source/medium/campaign/term/content) and ad click IDs (gclid/fbclid/msclkid) are captured from the URL by src/lib/utm.ts, persisted for the session, sent as hidden form fields, and included in the lead payload.