);
}
// ---------- CLUB DETAIL PAGE (full page, own URL) ----------
// Renders at #/partners/{clubId}. Pulls language-neutral facts plus history
// and per-player role notes from window.PARTNER_DETAILS, indexed by club
// display name (window.PARTNERS provides the id → name mapping).
function ClubDetailPage({ lang, clubId, navigate }) {
const labels = (window.PARTNER_DETAIL_LABELS && window.PARTNER_DETAIL_LABELS[lang])
|| (window.PARTNER_DETAIL_LABELS && window.PARTNER_DETAIL_LABELS.en)
|| {};
const club = (window.PARTNERS || []).find((p) => p.id === clubId);
const details = club && window.PARTNER_DETAILS && window.PARTNER_DETAILS[club.name];
useEffectP(() => { window.scrollTo(0, 0); }, [clubId]);
// Unknown slug — send the user back to the partner-clubs index.
if (!club || !details) {
return (
);
}
// ---------- COLLABORATION ----------
function CollaborationPage({ lang, navigate }) {
const t = window.I18N[lang].collabPage;
useEffectP(() => { window.scrollTo(0, 0); }, []);
// Both "Apply" buttons on this page route to the single, canonical form
// URL (#/apply/form) so every form on the site shares the same address.
// The form type ("collab") travels in sessionStorage — see app.jsx.
const openCollabForm = () => navigate({ name: "apply", type: "collab", returnTo: "collaboration" });
return (
{t.eyebrow}
{t.title}
{t.lead}
{t.bullets.map((b, i) => (
0{i + 1}
{b.h}
{b.b}
))}
{t.ctaTitle}
{t.ctaBody}
);
}
// World globe with country silhouettes + connection lines spreading from Spain.
// Uses D3.geoOrthographic + topojson world-atlas (loaded from CDN) for the country
// outlines so the silhouettes are geographically real, with the centre dot fixed
// on Spain.
function GlobeBackdrop() {
const [countries, setCountries] = useStateP(null);
// Load world atlas TopoJSON once.
useEffectP(() => {
let cancelled = false;
(async () => {
try {
// Wait briefly for d3 + topojson script tags to attach to window.
for (let i = 0; i < 20 && (!window.d3 || !window.topojson); i++) {
await new Promise((r) => setTimeout(r, 100));
}
if (!window.d3 || !window.topojson) return;
const res = await fetch("https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json");
if (!res.ok) return;
const world = await res.json();
const features = window.topojson.feature(world, world.objects.countries).features;
if (!cancelled) setCountries(features);
} catch (e) {
// Silent fail — globe still works without silhouettes (graticule + arcs).
}
})();
return () => { cancelled = true; };
}, []);
// Geometry constants. viewBox is square; sphere fills it almost edge-to-edge.
const cx = 360, cy = 360, R = 320;
const spain = { lat: 40.4, lon: -3.7 }; // Madrid
// Build a d3 orthographic projection centred on Spain — used for both country
// outlines (via d3.geoPath) and for our connection-arc endpoints.
const projection = useMemoP(() => {
if (!window.d3) return null;
return window.d3.geoOrthographic()
.scale(R)
.translate([cx, cy])
.rotate([-spain.lon, -spain.lat])
.clipAngle(90);
}, []);
const geoPath = useMemoP(() => {
if (!projection || !window.d3) return null;
return window.d3.geoPath(projection);
}, [projection]);
// Origin (Spain) projects to the centre by construction.
const origin = { x: cx, y: cy };
// Connection targets — same set as before.
const targets = [
{ lat: 39.9, lon: 116.4 }, // Beijing
{ lat: 35.7, lon: 139.7 }, // Tokyo
{ lat: 51.0, lon: 71.4 }, // Astana
{ lat: 28.6, lon: 77.2 }, // Delhi
{ lat: -1.3, lon: 36.8 }, // Nairobi
{ lat: -33.9, lon: 18.4 }, // Cape Town
{ lat: -34.6, lon: -58.4 }, // Buenos Aires
{ lat: -23.5, lon: -46.6 }, // São Paulo
{ lat: 19.4, lon: -99.1 }, // Mexico City
{ lat: 40.7, lon: -74.0 }, // New York
{ lat: 37.8, lon: -122.4 }, // San Francisco
{ lat: -33.9, lon: 151.2 }, // Sydney
{ lat: 25.3, lon: 55.3 }, // Dubai
{ lat: 1.35, lon: 103.8 }, // Singapore
{ lat: 55.7, lon: 37.6 }, // Moscow
{ lat: 60.2, lon: 24.9 }, // Helsinki
{ lat: 64.1, lon: -21.9 }, // Reykjavik
{ lat: 6.5, lon: 3.4 }, // Lagos
];
// Project a target via d3 if available, fall back to manual orthographic.
function projectPoint(p) {
if (projection) {
const r = projection([p.lon, p.lat]);
if (r) return { x: r[0], y: r[1], visible: true };
}
// Fallback manual orthographic
const lat0 = (spain.lat * Math.PI) / 180;
const lon0 = (spain.lon * Math.PI) / 180;
const lat = (p.lat * Math.PI) / 180;
const lon = (p.lon * Math.PI) / 180;
const cosc = Math.sin(lat0) * Math.sin(lat) + Math.cos(lat0) * Math.cos(lat) * Math.cos(lon - lon0);
const x = Math.cos(lat) * Math.sin(lon - lon0);
const y = Math.cos(lat0) * Math.sin(lat) - Math.sin(lat0) * Math.cos(lat) * Math.cos(lon - lon0);
return { x: cx + R * x, y: cy - R * y, visible: cosc >= 0 };
}
// Build the arc list. Back-side targets are nudged to the rim so we still
// get the radiating "connections" feel — geographically loose but visually right.
const lines = targets.map((tgt, i) => {
const p = projectPoint(tgt);
let ex = p.x, ey = p.y;
if (!p.visible) {
const dx = p.x - cx, dy = p.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
ex = cx + (dx / dist) * R * 0.96;
ey = cy + (dy / dist) * R * 0.96;
}
// Lift the quadratic Bezier control point outward for a curved feel.
const mx = (origin.x + ex) / 2;
const my = (origin.y + ey) / 2;
const vx = mx - cx, vy = my - cy;
const vlen = Math.sqrt(vx * vx + vy * vy) || 1;
const lift = 60;
const ccx = mx + (vx / vlen) * lift;
const ccy = my + (vy / vlen) * lift;
return { id: i, ex, ey, ccx, ccy, delay: (i % 8) * 0.4 };
});
// Longitudes for the graticule, sampled as polylines (only visible side).
const longitudes = [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150];
function graticulePolyline(lonDeg) {
const pts = [];
for (let latDeg = -90; latDeg <= 90; latDeg += 6) {
const r = projectPoint({ lat: latDeg, lon: lonDeg });
if (r.visible) pts.push(r.x.toFixed(1) + "," + r.y.toFixed(1));
}
return pts.length >= 2 ? pts.join(" ") : null;
}
return (
);
}
// ---------- COLLABORATION FORM (modal) ----------
function CollaborationFormModal({ lang, onClose }) {
const t = window.I18N[lang].collabForm;
const [values, setValues] = useStateP({ first: "", last: "", email: "", website: "" });
const [errors, setErrors] = useStateP({});
const [submitting, setSubmitting] = useStateP(false);
const [submitError, setSubmitError] = useStateP(false);
const [submitted, setSubmitted] = useStateP(false);
const phoneRef = useRefP(null);
// UTM capture (same approach as the player form).
const utm = useMemoP(() => {
const keys = ["utm_source", "utm_campaign", "utm_medium", "utm_content", "utm_term"];
const out = {};
try {
const sp = new URLSearchParams(window.location.search);
keys.forEach((k) => { out[k] = sp.get(k) || ""; });
} catch (e) {
keys.forEach((k) => { out[k] = ""; });
}
return out;
}, []);
const set = (k) => (e) => setValues({ ...values, [k]: e.target.value });
const validate = () => {
const e = {};
if (!values.first.trim()) e.first = t.errors.first;
if (!values.last.trim()) e.last = t.errors.last;
if (!phoneRef.current || !phoneRef.current.isValid()) e.phone = t.errors.phone;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) e.email = t.errors.email;
return e;
};
const submit = async (e) => {
e.preventDefault();
if (submitting) return;
// Honeypot
if (values.website && values.website.trim() !== "") return;
const errs = validate();
setErrors(errs);
if (Object.keys(errs).length > 0) {
const f = document.querySelector(".collab-form .field.error input");
if (f) f.focus();
return;
}
setSubmitError(false);
setSubmitting(true);
const phoneE164 = phoneRef.current ? phoneRef.current.getNumber() : "";
const payload = {
first_name: values.first.trim(),
last_name: values.last.trim(),
phone: phoneE164,
email: values.email.trim(),
// Defaults for the other fields the AmoCRM endpoint expects from the player form,
// so AmoCRM stays consistent across both lead sources.
age: "",
current_club: "",
position: "",
video_link: "",
program: "Collaboration", // fixed value — AmoCRM lead title becomes "First Last — Collaboration"
language: (lang || "en").toUpperCase(),
utm_source: utm.utm_source || "",
utm_campaign: utm.utm_campaign || "",
utm_medium: utm.utm_medium || "",
utm_content: utm.utm_content || "",
utm_term: utm.utm_term || "",
landing_url: (typeof window !== "undefined" && window.location) ? window.location.href : "",
website: "",
};
const endpoint = "https://f1rststeps.com/amo.php";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timer);
if (res.status === 200) {
// Meta Pixel — fire Lead on successful submit (pixel is already
// initialised in the site head, this only triggers the event).
if (typeof fbq !== "undefined") {
try { fbq("track", "Lead"); } catch (e) {}
}
setSubmitted(true);
} else {
setSubmitError(true);
}
} catch (err) {
clearTimeout(timer);
setSubmitError(true);
} finally {
setSubmitting(false);
}
};
return (