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.ts → landings array |
| Spanish content | data/landings.es.ts → landingsEs array (same slugs) |
| Lookup | getLanding(slug, locale) selects the right array |
| Page data / SEO | data/landingData.ts → buildLandingData() |
| 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.canonicalPathor the locale path); - absolute
hreflangalternate<link>tags forenandes; - 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.
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.exampleto.envand setGTM_ID(and optionallyGTAG_ID). - Production: set
GTM_ID/GTAG_IDin 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.