/* ========================================================= Tools / Toolkit — INTEGRATION (marquee) Two infinite marquee rows of brand tool tiles + a centered eyebrow badge, title and subtitle below. Logos are pulled from Simple Icons CDN and theme-tinted via CSS `filter`. Light / Dark via [data-theme="dark"]. ========================================================= */ /* ---------- Logo set ---------- Each entry maps a display name to a Simple Icons slug (https://cdn.simpleicons.org//000000 — forced black so our `filter: invert(...)` tints them as neutral greys). `inline` is an optional local fallback for brands Simple Icons no longer publishes (Adobe was pulled from their catalogue — likely a brand-legal request). The fallback is a generic monochrome glyph and uses the same `.frt-logo` filter pipeline as the CDN images. */ const AdobeMark = () => ( ); const TOOL_LOGOS = [ /* Row 1 — scrolls left */ { n: "Figma", slug: "figma" }, { n: "Webflow", slug: "webflow" }, { n: "WordPress", slug: "wordpress" }, { n: "Framer", slug: "framer" }, { n: "Google", slug: "google" }, { n: "Make", slug: "make" }, /* Row 2 — scrolls right */ { n: "n8n", slug: "n8n" }, { n: "Elementor", slug: "elementor" }, { n: "Zapier", slug: "zapier" }, { n: "Stripe", slug: "stripe" }, { n: "Adobe", inline: AdobeMark }, { n: "DaVinci", slug: "davinciresolve" }, ]; /* ---------- Styles (scoped with frt- prefix) ---------- */ const STYLE = ` .frt-section { position: relative; background: var(--bg); padding: clamp(72px, 9vw, 128px) 0; } [data-theme="dark"] .frt-section { background: var(--bg); } .frt-marquee { position: relative; display: flex; flex-direction: column; gap: 12px; align-items: center; } /* Central light bleed — a soft elliptical highlight behind both rows so the tiles in the middle pick up a hint of glow while the ones near the edges sit in shadow. Sits above the tiles (z-index 2) but is purely decorative. */ .frt-marquee::after { content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 2; background: radial-gradient(ellipse 40% 80% at 50% 50%, rgba(0,0,0,0.04) 0%, transparent 70%); } [data-theme="dark"] .frt-marquee::after { background: radial-gradient(ellipse 40% 80% at 50% 50%, rgba(255,255,255,0.12) 0%, transparent 70%); } .frt-row { width: 100%; /* On wider screens cap the row at exactly 5 tiles + 4 gaps so the marquee is the same crisp size everywhere it has room. On narrow viewports the row collapses to 100% of available width so the left/right mask fades (and the bg-coloured ::before/::after overlays) align with the visible edges instead of falling off screen — otherwise the right side cuts hard while the left looks soft. */ max-width: calc(5 * 84px + 4 * 14px); overflow: hidden; margin: 0 auto; -webkit-mask-image: linear-gradient(to right, transparent 0%, #000 12%, #000 88%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, #000 12%, #000 88%, transparent 100%); position: relative; } /* Side fade backstop — solid background gradient on top of the masked strip so the edge fades to the section color even where mask-image is fuzzy. */ .frt-row::before, .frt-row::after { content: ""; position: absolute; top: 0; bottom: 0; width: 56px; pointer-events: none; z-index: 2; } .frt-row::before { left: 0; background: linear-gradient(to right, #f5f2ec 0%, rgba(245, 242, 236, 0) 100%); } .frt-row::after { right: 0; background: linear-gradient(to left, #f5f2ec 0%, rgba(245, 242, 236, 0) 100%); } [data-theme="dark"] .frt-row::before { background: linear-gradient(to right, #14130f 0%, rgba(20,19,15,0) 100%); } [data-theme="dark"] .frt-row::after { background: linear-gradient(to left, #14130f 0%, rgba(20,19,15,0) 100%); } .frt-track { display: flex; gap: 14px; width: max-content; will-change: transform; } .frt-track-1 { animation: frt-scroll-left 26s linear infinite; } .frt-track-2 { animation: frt-scroll-right 30s linear infinite; } @keyframes frt-scroll-left { from { transform: translateX(0); } to { transform: translateX(calc(-6 * 98px)); } } @keyframes frt-scroll-right { from { transform: translateX(calc(-6 * 98px)); } to { transform: translateX(0); } } .frt-tile { flex: 0 0 84px; width: 84px; height: 84px; border-radius: 18px; background: var(--surface); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; transition: background .25s ease, border-color .25s ease; } [data-theme="dark"] .frt-tile { background: #18181a; border: 1px solid rgba(255, 255, 255, 0.06); } /* Brand logo image — Simple Icons returns a solid-black SVG. We tint it via filter (grey on light, lighter grey on dark) and shift to the accent terracotta on hover. */ .frt-logo { width: 32px; height: 32px; display: block; filter: invert(0.4); transition: filter 0.3s ease; } [data-theme="dark"] .frt-logo { filter: invert(0.7); } .frt-tile:hover .frt-logo { /* Approximates #e8624a via hue rotation from black source */ filter: invert(0) sepia(1) saturate(2) hue-rotate(340deg); } .frt-content { margin-top: clamp(40px, 5vw, 64px); text-align: center; padding: 0 24px; display: flex; flex-direction: column; align-items: center; gap: 18px; } .frt-badge { display: inline-block; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; padding: 7px 14px; border-radius: 999px; background: var(--surface); color: var(--text-mute); border: 1px solid var(--line); cursor: pointer; user-select: none; transition: all 0.25s ease; } .frt-badge:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(232, 98, 74, 0.28); background: var(--accent); color: #fff; border-color: var(--accent); } [data-theme="dark"] .frt-badge { background: #1a1a1a; color: #f3ede2; border-color: rgba(255, 255, 255, 0.06); } [data-theme="dark"] .frt-badge:hover { background: var(--accent); color: #fff; border-color: var(--accent); box-shadow: 0 6px 20px rgba(232, 98, 74, 0.45); } .frt-title { font-family: var(--font-display); font-size: clamp(40px, 5.5vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.025em; color: var(--text); margin: 0; text-wrap: balance; /* Wrap whole words to the next line, never split mid-word. */ word-break: normal; overflow-wrap: break-word; hyphens: none; } [data-theme="dark"] .frt-title { color: #f3ede2; } /* Word groups — each word's letters live inside a .frt-title-word that never breaks internally. Real spaces between word groups are the only line-break opportunities, so the title can never split mid-word (the previous nbsp-everywhere render forced the browser to break mid-word at overflow). */ .frt-title-word { display: inline-block; white-space: nowrap; } /* Title "decode" animation — each letter renders as a span. While the letter is still scrambling it carries no .resolved class and is tinted with the accent color. When it locks onto its target char the class is added and the color transitions back to the title color (0.15s). The actual character switching is driven from JS (refs + setInterval). */ .frt-title-letter { display: inline-block; color: var(--accent); transition: color 0.15s ease; } .frt-title-letter.resolved { color: inherit; } .frt-sub { max-width: 52ch; margin: 0; font-size: clamp(15px, 1.15vw, 18px); line-height: 1.55; color: var(--text-2); text-wrap: pretty; } [data-theme="dark"] .frt-sub { color: #9b9a96; } @media (prefers-reduced-motion: reduce) { .frt-track-1, .frt-track-2 { animation: none; } } /* Mobile tweaks — symmetric side fade + title scaled to match the neighbouring section headlines (.pricing-head h2 in particular) so the toolkit title doesn't read as a smaller sibling. */ @media (max-width: 600px) { .frt-row { -webkit-mask-image: linear-gradient(to right, transparent 0%, black 15%, black 85%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, black 15%, black 85%, transparent 100%); } .frt-title { font-size: clamp(32px, 8vw, 48px); } } `; /* ---------- INTEGRATION section ---------- */ const { useRef: useRefTT, useEffect: useEffectTT, useState: useStateTT } = React; /* Pool of glyphs the title shuffles through during the decode animation. */ const SCRAMBLE_CHARS = "0123456789#@$%&*!?ABCDEFabcdef"; const randomScrambleChar = () => SCRAMBLE_CHARS.charAt(Math.floor(Math.random() * SCRAMBLE_CHARS.length)); /* Phrases the badge cycles through on click. Index 0 mirrors `t.tech.title` (the default copy). 1–5 are rotated random-no-repeat, then 6 (the “extra”), then the pool resets. Lang detection prefers `window.__lang__` and falls back to ``. */ const TITLE_PHRASES = { en: [ "Connected systems, not more tools.", "Automate once. Work less forever.", "Your site works. Even while you sleep.", "Design that converts. Systems that scale.", "From idea to client in less time.", "Fewer tools. Better results.", "Still clicking? We've run out of ideas.", ], de: [ "Verbundene Systeme, nicht mehr Tools.", "Einmal einrichten. Für immer sparen.", "Deine Site arbeitet. Auch wenn du schläfst.", "Design das konvertiert. Systeme die skalieren.", "Von der Idee zum Kunden in weniger Zeit.", "Weniger Tools. Bessere Ergebnisse.", "Noch am Klicken? Uns sind die Ideen ausgegangen.", ], es: [ "Sistemas que trabajan. Resultados que se ven.", "Automatiza una vez. Trabaja menos siempre.", "Tu web no es un folleto. Es tu presencia digital.", "Diseño que convierte. Sistemas que escalan.", "De la idea al cliente en menos tiempo.", "Menos herramientas. Más resultados.", "¿Sigues pulsando? Se nos han acabado las ideas.", ], }; /* Resolve the active language at the moment of the click — NOT at mount. Priority (first non-empty wins): 1. window.__lang__ 2. attribute 3. document.documentElement.lang property 4. fallback 'en' The result is lowercased, trimmed, and truncated to 2 chars so values like 'es-ES', 'en-US', 'de-DE' all normalise correctly. */ function getActiveLang() { try { const candidates = [ typeof window !== "undefined" ? window.__lang__ : null, typeof document !== "undefined" ? document.documentElement.getAttribute("lang") : null, typeof document !== "undefined" ? document.documentElement.lang : null, "en", ]; for (const v of candidates) { if (v == null) continue; const s = String(v).toLowerCase().trim(); if (!s) continue; return s.slice(0, 2); } return "en"; } catch (e) { return "en"; } } function shuffleInPlace(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } const TechTools = ({ t }) => { const eyebrowRaw = (t.tech.eyebrow || "Integration").replace(/^\s*\/\s*/, ""); const eyebrowText = eyebrowRaw.toUpperCase(); // First 6 tools → row 1 (scrolls left) // Next 6 tools → row 2 (scrolls right) const row1Tools = TOOL_LOGOS.slice(0, 6); const row2Tools = TOOL_LOGOS.slice(6, 12); // Repeat each row 3× so the linear loop has enough material to feel seamless. const row1 = [...row1Tools, ...row1Tools, ...row1Tools]; const row2 = [...row2Tools, ...row2Tools, ...row2Tools]; // ──────── Title decode state ─────────────────────────────────────────── // letters[i] = { target, revealed, display } // target = real character (' ' for a space) // revealed = whether it has locked onto its target glyph // display = the glyph we render right now // Spaces start as `revealed: true` so they never tint accent and never // get swapped for random characters by the ticker. const defaultTitle = String(t.tech.title || ""); const buildLetters = (text) => [...text].map((ch) => ({ target: ch, revealed: ch === " ", display: ch === " " ? " " : randomScrambleChar(), })); const [letters, setLetters] = useStateTT(() => buildLetters(defaultTitle)); const titleRef = useRefTT(null); const currentTargetRef = useRefTT(defaultTitle); // text we're currently decoding toward const revealTimeoutsRef = useRefTT([]); // pending per-letter reveal timeouts const sequenceRef = useRefTT([]); // phrase pool for badge clicks function clearReveals() { revealTimeoutsRef.current.forEach((id) => clearTimeout(id)); revealTimeoutsRef.current = []; } function startDecode(text) { clearReveals(); currentTargetRef.current = text; const fresh = buildLetters(text); setLetters(fresh); // Schedule each non-space letter to lock onto its target. // Letter i starts decoding at i*40ms; resolution lasts 300ms, // so it locks at (i*40 + 300)ms relative to the call. fresh.forEach((L, i) => { if (L.target === " ") return; const id = setTimeout(() => { // Bail if a newer decode has superseded this one. if (currentTargetRef.current !== text) return; setLetters((prev) => { if (i >= prev.length) return prev; const next = prev.slice(); next[i] = { ...next[i], revealed: true, display: text[i] }; return next; }); }, i * 40 + 300); revealTimeoutsRef.current.push(id); }); } function resetToScramble(text) { clearReveals(); currentTargetRef.current = text; setLetters(buildLetters(text)); } // Random-glyph ticker — refreshes every unrevealed letter every 55ms. // Returns `prev` (no-op) when nothing is scrambling so we don't re-render. useEffectTT(() => { const id = setInterval(() => { setLetters((prev) => { let dirty = false; for (const L of prev) { if (!L.revealed) { dirty = true; break; } } if (!dirty) return prev; return prev.map((L) => L.revealed ? L : { ...L, display: randomScrambleChar() } ); }); }, 55); return () => clearInterval(id); }, []); // When the i18n-driven title changes (language switch), re-target // the decode to the new copy and reset the phrase cycler. useEffectTT(() => { sequenceRef.current = []; startDecode(defaultTitle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultTitle]); // IntersectionObserver — decode on enter, scramble-reset when the // title scrolls *above* the viewport (its top edge passes 0). useEffectTT(() => { const el = titleRef.current; if (!el) return; if (typeof IntersectionObserver === "undefined") { startDecode(currentTargetRef.current); return; } // Run the decode animation exactly once — the first time the title // enters the viewport. After that we disconnect the observer so the // animation never replays on subsequent scroll-ins. const io = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { startDecode(currentTargetRef.current); io.disconnect(); break; } } }, { threshold: 0.2 } ); io.observe(el); return () => { io.disconnect(); clearReveals(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ──────── Badge click → next phrase ─────────────────────────────────── function refillSequence() { // 1..5 in random order, then the extra (6). sequenceRef.current = shuffleInPlace([1, 2, 3, 4, 5]); sequenceRef.current.push(6); } function pickNextPhrase() { if (sequenceRef.current.length === 0) refillSequence(); const idx = sequenceRef.current.shift(); // Resolve the active language INLINE, at the moment of the click — // not at mount, not from state. This way switching language with the // header toggle is picked up by the very next badge press. const lang = ( window.__lang__ || document.documentElement.lang || "en" ) .toLowerCase() .trim() .slice(0, 2); const phrases = TITLE_PHRASES[lang] || TITLE_PHRASES.en; return phrases[idx] || phrases[1] || defaultTitle; } function handleBadgeClick() { const next = pickNextPhrase(); startDecode(next); } // ──────── Render ────────────────────────────────────────────────────── const renderTile = (tool, key) => (
{tool.inline ? ( ) : ( {tool.n} )}
); return (