/* ========================================================= Testimonials — full-bleed band with a centered headline and a masonry-style grid of review cards. Hard-isolated from the rest of the page: - Constant renamed to TST_STYLE to avoid colliding with the `STYLE` constant in tools.jsx (Babel scripts share global scope; two top-level `const STYLE` declarations conflict and break whichever script loads second). - Prefix `.fr-tst-` (no overlap with `.frt-` toolkit or `.tsh-` legacy) - `isolation: isolate` creates an independent stacking context - Background painted via a `::before` pseudo-element so the section never changes its own width and never introduces horizontal scroll - All CSS lives inside this file and is scoped to `.fr-tst-…` Data: `t.tests` from i18n.jsx — { eyebrow, title, items[] } where each item is { q, name, role }. ========================================================= */ const TST_STYLE = ` /* Outer band — full document width, padding only vertical. Background lives on a ::before pseudo so the section box stays clean. */ .fr-tst-band { position: relative; width: 100%; padding: clamp(80px, 10vw, 140px) 0; isolation: isolate; overflow: hidden; box-sizing: border-box; } .fr-tst-band::before { content: ""; position: absolute; inset: 0; background: var(--surface); z-index: -1; } [data-theme="dark"] .fr-tst-band::before { background: #1a1a1a; } /* Decorative color blobs — soft brand-tinted halos sitting behind the grid so the section never feels flat. Two blobs in complementary palette colors, blurred and at low opacity so they read as ambient light rather than shapes. Sit on z-index 0 — above the bg layer at -1 but below the head/grid which paint on their own positive layers. Each blob has its own translate driven by a scroll-progress CSS variable (--p, 0–1) set on the section by JS as the band crosses the viewport. The blobs move at different rates / directions for a soft parallax that breathes when you scroll. */ .fr-tst-blob { position: absolute; border-radius: 50%; filter: blur(90px); pointer-events: none; z-index: 0; will-change: transform; transition: transform 0.18s linear; } .fr-tst-blob-a { width: 520px; height: 520px; background: var(--accent); opacity: 0.18; top: -160px; left: -120px; transform: translate3d( calc(var(--p, 0) * 80px), calc(var(--p, 0) * 60px), 0 ); } .fr-tst-blob-b { width: 460px; height: 460px; background: var(--accent-2); opacity: 0.28; bottom: -180px; right: -100px; transform: translate3d( calc(var(--p, 0) * -90px), calc(var(--p, 0) * -50px), 0 ); } .fr-tst-blob-c { width: 320px; height: 320px; background: var(--accent-4); opacity: 0.18; top: 40%; right: 18%; transform: translate3d( calc(var(--p, 0) * 50px), calc(var(--p, 0) * -120px), 0 ); } [data-theme="dark"] .fr-tst-blob-a, [data-theme="dark"] .fr-tst-blob-b, [data-theme="dark"] .fr-tst-blob-c { opacity: 0.14; } @media (prefers-reduced-motion: reduce) { .fr-tst-blob { transition: none; transform: none !important; } } /* Oversized decorative quote mark sitting behind the title area — pure typography, brand-tinted, low-opacity. Adds visual rhythm without slop-iconography or stock imagery. */ .fr-tst-quotemark { position: absolute; top: 24px; left: 50%; transform: translateX(-50%); font-family: var(--font-display); font-size: clamp(220px, 28vw, 380px); line-height: 0.6; font-weight: 800; color: color-mix(in srgb, var(--accent) 12%, transparent); pointer-events: none; user-select: none; z-index: 0; } [data-theme="dark"] .fr-tst-quotemark { color: color-mix(in srgb, var(--accent) 18%, transparent); } /* Centered head */ .fr-tst-head { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto clamp(48px, 6vw, 80px); padding: 0 clamp(20px, 4vw, 64px); text-align: center; display: flex; flex-direction: column; align-items: center; gap: 18px; box-sizing: border-box; } /* Eyebrow — match the global .eyebrow pattern (mono caps + orange leading dot) used in every other section of the site. */ .fr-tst-badge { display: inline-flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-mute); } .fr-tst-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); display: inline-block; } .fr-tst-title { font-family: var(--font-display); font-size: clamp(40px, 6vw, 88px); font-weight: 800; line-height: 1.02; letter-spacing: -0.03em; color: var(--text); margin: 0; text-wrap: balance; word-break: normal; overflow-wrap: break-word; hyphens: none; } [data-theme="dark"] .fr-tst-title { color: #f3ede2; } /* Carousel wrap — holds arrows + viewport in one row. */ .fr-tst-carousel { position: relative; z-index: 1; max-width: 1380px; margin: 0 auto; padding: 24px clamp(20px, 4vw, 64px); display: flex; align-items: center; gap: clamp(12px, 1.5vw, 20px); box-sizing: border-box; } .fr-tst-arrow { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 50%; border: 1px solid var(--line); background: var(--bg); color: var(--text); font-size: 18px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-family: inherit; transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; z-index: 3; } .fr-tst-arrow:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: #fff; transform: translateY(-2px); box-shadow: 0 10px 24px -10px rgba(232, 98, 74, 0.5); } .fr-tst-arrow:disabled { opacity: 0.35; cursor: not-allowed; } .fr-tst-arrow:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } [data-theme="dark"] .fr-tst-arrow { background: #1f1f1f; border-color: rgba(255, 255, 255, 0.08); color: #f3ede2; } /* Viewport — fixed window that crops the track. Padding plus a soft alpha mask on all four sides give the centre card's tall shadow plenty of room to bleed out softly into the section background. The mask uses two gradients composited with intersect so both the horizontal AND the vertical edges fade instead of hard-cutting. */ .fr-tst-viewport { flex: 1 1 auto; min-width: 0; position: relative; --visible: 3; --gap: 20px; --card-w: calc((100% - (var(--visible) - 1) * var(--gap)) / var(--visible)); --idx: 0; overflow: hidden; padding: 64px 36px 80px; -webkit-mask-image: linear-gradient(to right, transparent 0, black 36px, black calc(100% - 36px), transparent 100%), linear-gradient(to bottom, black 0, black calc(100% - 60px), transparent 100%); -webkit-mask-composite: source-in; mask-image: linear-gradient(to right, transparent 0, black 36px, black calc(100% - 36px), transparent 100%), linear-gradient(to bottom, black 0, black calc(100% - 60px), transparent 100%); mask-composite: intersect; } @media (max-width: 1099px) and (min-width: 760px) { .fr-tst-viewport { --visible: 2; --gap: 16px; padding: 48px 28px 72px; } } @media (max-width: 759px) { .fr-tst-viewport { --visible: 1; --gap: 0px; padding: 36px 16px 64px; } } /* Track — lays the full set of cards in a flex row, translates as the user moves through the carousel. Smooth easing on transform for the slide animation. */ .fr-tst-track { display: flex; gap: var(--gap); width: 100%; transform: translateX(calc(var(--idx) * (var(--card-w) + var(--gap)) * -1)); transition: transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1); will-change: transform; } @media (prefers-reduced-motion: reduce) { .fr-tst-track { transition: none; } } .fr-tst-card { position: relative; flex: 0 0 var(--card-w); width: var(--card-w); background: var(--bg); border: 1px solid transparent; border-radius: 28px; padding: 32px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; min-height: 320px; transition: transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1), box-shadow 0.6s cubic-bezier(0.2, 0.7, 0.2, 1), border-color 0.45s ease, opacity 0.45s ease; } [data-theme="dark"] .fr-tst-card { background: #222; } /* Narrow-screen card polish: tighter padding so the lone card on mobile doesn't feel airy and the quote does the visual work. */ @media (max-width: 759px) { .fr-tst-card { padding: 24px; border-radius: 22px; min-height: 260px; max-width: 520px; margin: 0 auto; } } /* Centre / side states only apply where 3 cards fit (wide desktop). At narrower widths the highlight is suppressed so the 2-up tablet and 1-up phone layouts feel balanced. */ @media (min-width: 1100px) { .fr-tst-card.is-side { transform: scale(0.97); opacity: 0.92; } .fr-tst-card.is-center { transform: translateY(-8px) scale(1.04); z-index: 2; border-color: color-mix(in srgb, var(--accent) 30%, transparent); box-shadow: 0 60px 100px -30px rgba(20, 18, 14, 0.28), 0 24px 48px -24px rgba(20, 18, 14, 0.16), 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent); } .fr-tst-card.is-center::after { content: ""; position: absolute; inset: -1px; border-radius: 28px; pointer-events: none; background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 8%, transparent) 0%, transparent 35%); z-index: 0; } [data-theme="dark"] .fr-tst-card.is-center { box-shadow: 0 60px 100px -30px rgba(0, 0, 0, 0.6), 0 24px 48px -24px rgba(0, 0, 0, 0.4), 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); } /* Cards outside the visible window keep the base style but stay flat. */ .fr-tst-card.is-offscreen { opacity: 0.4; pointer-events: none; } } .fr-tst-card > * { position: relative; z-index: 1; } /* Dots indicator below the track so the user can see progress. */ .fr-tst-dots { display: flex; justify-content: center; gap: 8px; margin-top: 8px; position: relative; z-index: 1; } .fr-tst-dot { width: 28px; height: 4px; border-radius: 99px; background: var(--line); border: 0; cursor: pointer; transition: background 0.25s ease, width 0.25s ease; padding: 0; } .fr-tst-dot:hover { background: var(--text-mute); } .fr-tst-dot.active { background: var(--accent); width: 48px; } /* Stars row — 5 layered glyphs supporting full / half / empty fills. Each star renders as a translucent base “★” with an absolutely positioned overlay “★” in accent colour, clipped to a percentage of the star’s width. So rating = 4.5 lights 4 full + 1 half. */ .fr-tst-stars { display: inline-flex; gap: 3px; margin-bottom: 12px; font-size: 16px; line-height: 1; } .fr-tst-star { position: relative; display: inline-block; width: 1em; height: 1em; line-height: 1; } .fr-tst-star .bg { color: color-mix(in srgb, var(--accent) 22%, transparent); } .fr-tst-star .fg { color: var(--accent); position: absolute; inset: 0; overflow: hidden; white-space: nowrap; display: inline-block; } .fr-tst-rating-num { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; color: var(--text-mute); margin-left: 8px; align-self: center; } .fr-tst-quote { font-family: var(--font-display); font-weight: 500; font-size: clamp(16px, 1.4vw, 20px); line-height: 1.35; letter-spacing: -0.015em; color: var(--text); margin: 0 0 8px; text-wrap: pretty; } [data-theme="dark"] .fr-tst-quote { color: #f3ede2; } .fr-tst-body { font-size: 14px; line-height: 1.55; color: var(--text-2); margin: 0 0 8px; } [data-theme="dark"] .fr-tst-body { color: #9b9a96; } .fr-tst-author { margin-top: auto; padding-top: 20px; display: flex; flex-direction: column; gap: 2px; } .fr-tst-name { font-weight: 600; font-size: 14px; color: var(--text); } [data-theme="dark"] .fr-tst-name { color: #f3ede2; } .fr-tst-company { font-size: 13px; color: var(--text-2); } [data-theme="dark"] .fr-tst-company { color: #9b9a96; } `; /* ---------- Helpers ---------- */ // Split a long quote into a bold headline + softer body when the first // sentence ends with . ! or ?. Otherwise the whole quote is the headline. function tstSplitQuote(q) { const s = String(q || ""); const m = s.match(/^([^.!?\n]+[.!?])\s+(.+)$/); if (m) return { headline: m[1], body: m[2] }; return { headline: s, body: "" }; } /* ---------- Component ---------- */ const { useRef: useRefTST, useEffect: useEffectTST, useState: useStateTST } = React; // Star row supporting fractional ratings (0..5). `value` is a number. const StarRow = ({ value, label }) => { const v = Math.max(0, Math.min(5, Number(value) || 0)); const stars = []; for (let i = 0; i < 5; i++) { const fill = Math.max(0, Math.min(1, v - i)); stars.push( ); } const valueStr = Number.isInteger(v) ? v.toFixed(0) : v.toFixed(1); return (
{stars} {valueStr}
); }; const Testimonials = ({ t }) => { const data = (t && t.tests) || {}; const items = Array.isArray(data.items) ? data.items : []; // Carousel idx — represents the left-most visible card. We render // ALL items in a horizontal track and translate the track left by // (idx * card width). Visible window: 3 desktop / 2 tablet / 1 phone. // Initial idx is the middle of the carousel so the user lands on a // central testimonial rather than the first one. const initialVisible = (() => { if (typeof window === "undefined") return 3; if (window.matchMedia("(min-width: 1100px)").matches) return 3; if (window.matchMedia("(min-width: 760px)").matches) return 2; return 1; })(); const [visibleCount, setVisibleCount] = useStateTST(initialVisible); const total = items.length; const maxIdx = Math.max(0, total - visibleCount); const [idx, setIdxTST] = useStateTST(() => Math.max(0, Math.floor((items.length - initialVisible) / 2)) ); // Centre slot — the visually elevated card always sits in the middle // of the visible window (slot 1 of 3 on desktop, slot 0 of 1 on mobile). const centreSlot = Math.floor(visibleCount / 2); // Track responsive visible count via matchMedia with two breakpoints. // When the viewport changes width we clamp idx into range but don't // re-centre, so the user keeps whatever position they were on. useEffectTST(() => { if (typeof window === "undefined") return; const mqWide = window.matchMedia("(min-width: 1100px)"); const mqMid = window.matchMedia("(min-width: 760px)"); const apply = () => { const v = mqWide.matches ? 3 : mqMid.matches ? 2 : 1; setVisibleCount(v); setIdxTST((cur) => Math.min(cur, Math.max(0, total - v))); }; apply(); mqWide.addEventListener("change", apply); mqMid.addEventListener("change", apply); return () => { mqWide.removeEventListener("change", apply); mqMid.removeEventListener("change", apply); }; }, [total]); const next = () => setIdxTST((i) => Math.min(maxIdx, i + 1)); const prev = () => setIdxTST((i) => Math.max(0, i - 1)); const goTo = (target) => setIdxTST(Math.max(0, Math.min(maxIdx, target))); // Scroll-driven background animation. We compute a 0..1 progress value // representing how far the section has travelled through the viewport // (0 = section just entering from the bottom, 1 = section just exited // past the top) and write it to a CSS custom property `--p` on the // section element. CSS reads --p on each blob to translate it; each // blob has a different direction & magnitude so they drift apart as // the user scrolls. rAF-throttled, passive listener, no per-frame work // when the section isn't on screen. const sectionRef = useRefTST(null); useEffectTST(() => { const el = sectionRef.current; if (!el) return; if (typeof window === "undefined") return; // Respect prefers-reduced-motion — don't animate, blobs stay put. const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); if (mq.matches) return; let raf = 0; let onScreen = false; const update = () => { raf = 0; const r = el.getBoundingClientRect(); const vh = window.innerHeight || 1; // p = 0 when section bottom is just touching viewport top (above) // = 1 when section top is just touching viewport bottom (below) // We want the inverse-feeling so blobs drift as you scroll the // section through view: as r.top decreases (section moves up), // p increases. const total = r.height + vh; const travelled = vh - r.top; const p = Math.max(0, Math.min(1, travelled / total)); el.style.setProperty("--p", p.toFixed(4)); }; const onScroll = () => { if (!onScreen) return; if (raf) return; raf = requestAnimationFrame(update); }; // Only listen to scroll while the section intersects the viewport. // Cheaper on long pages. let io; if (typeof IntersectionObserver !== "undefined") { io = new IntersectionObserver( (entries) => { for (const entry of entries) { onScreen = entry.isIntersecting; if (onScreen) update(); } }, { threshold: 0, rootMargin: "100px" } ); io.observe(el); } else { onScreen = true; } // Prime once and start listening. update(); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); return () => { window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); if (io) io.disconnect(); if (raf) cancelAnimationFrame(raf); }; }, []); return (