/* ========================================================= Sections β€” Fran Ruiz portfolio Pulls from window.T (i18n) + window.PROJECTS + window.CLIENTS ========================================================= */ const { useEffect, useRef, useState } = React; // Detect if we're on a subpage and need to prefix anchor links with the home file const HOME = typeof location !== "undefined" && /(project|contact)\.html/i.test(location.pathname) ? "index.html" : ""; /* ---------- TOP NAV ---------- */ const CountUp = ({ value }) => { // value can be "50+", "8 yrs", "100%", "8 J.", etc β€” extract the integer prefix const m = String(value).match(/^(\d+)(.*)$/); if (!m) return {value}; const target = parseInt(m[1], 10); const suffix = m[2] || ""; const ref = useRef(null); const [n, setN] = useState(0); useEffect(() => { const el = ref.current; if (!el) return; let raf, start; const dur = 1400; const animate = (t) => { if (!start) start = t; const p = Math.min(1, (t - start) / dur); const eased = 1 - Math.pow(1 - p, 3); setN(Math.round(eased * target)); if (p < 1) raf = requestAnimationFrame(animate); }; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { raf = requestAnimationFrame(animate); io.unobserve(el); } }); }, { threshold: 0.3 }); io.observe(el); return () => {io.disconnect();cancelAnimationFrame(raf);}; }, [target]); return {n}{suffix}; }; const Nav = ({ lang, setLang, theme, toggleTheme, t }) => { const [open, setOpen] = useState(false); const [drawer, setDrawer] = useState(false); const menuRef = useRef(null); useEffect(() => { const close = (e) => {if (menuRef.current && !menuRef.current.contains(e.target)) setOpen(false);}; document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, []); // Lock body scroll when drawer open useEffect(() => { document.body.style.overflow = drawer ? "hidden" : ""; return () => {document.body.style.overflow = "";}; }, [drawer]); // Close drawer on resize to desktop useEffect(() => { const onR = () => {if (window.innerWidth > 900) setDrawer(false);}; window.addEventListener("resize", onR); return () => window.removeEventListener("resize", onR); }, []); const langs = [ { id: "en", flag: "πŸ‡¬πŸ‡§", name: "English" }, { id: "de", flag: "πŸ‡¨πŸ‡­", name: "Deutsch" }, { id: "es", flag: "πŸ‡ͺπŸ‡Έ", name: "EspaΓ±ol" }]; const current = langs.find((l) => l.id === lang); const navLinks = [ { href: HOME + "#work", label: t.nav.work }, { href: HOME + "#about", label: t.nav.about }, { href: HOME + "#services", label: t.nav.services }, { href: HOME + "#pricing", label: t.nav.pricing }, { href: "contact.html", label: t.nav.contact }]; return ( <> {/* Mobile drawer */}
setDrawer(false)} /> ); }; /* ---------- HERO ---------- */ const Hero = ({ t }) =>
{t.hero.avail}

{t.hero.titleA} {t.hero.titleB}
{t.hero.titleC} {t.hero.titleD}

{t.hero.sub}

{t.hero.stat1.lbl}
{t.hero.stat2.lbl}
{t.hero.stat3.lbl}
; /* ---------- MARQUEE ---------- */ const Marquee = ({ t }) => { const row =
{t.marquee.concat(t.marquee).map((it, i) => {it} )}
; return
{row}
; }; /* ---------- ABOUT ---------- */ const About = ({ t }) =>
{t.about.eyebrow}
Fran Ruiz at his desk
🏍 {t.about.badge}

{t.about.title}

{t.about.p1}

{t.about.p2}

{t.about.p3}

{t.about.p4}

πŸ“

{t.about.cardA.t}

{t.about.cardA.s}

✦

{t.about.cardB.t}

{t.about.cardB.s}

; /* ---------- WORK β€” featured projects (Kurawa-style) ---------- */ const FeaturedCard = ({ p, i, t, lang }) => { const ref = useRef(null); const evenRight = i % 2 === 1; // alternate reveal direction return (
/ {String(i + 1).padStart(2, "0")} {p.year}
{/* Empty canvas β€” replace src on i18n.jsx FEATURED[].img to add real image */} {p.img && {p.title[lang]} } {!p.img &&
+ {p.title[lang]} Drop image here
}
{t.work.view} β†—

{p.title[lang]}

{p.tags[lang].map((tag) => {tag})}
β†—
); }; const CTAFeaturedCard = ({ t }) =>
/ 04 {t.work.ctaEyebrow}
β€” {t.work.ctaEyebrow}

{t.work.ctaTitle}

β†—

{t.work.ctaBtn}

contacto@franruiz.es
β†—
; const Work = ({ t, lang }) =>
{t.work.eyebrow}

{t.work.title.includes(",") ? <>{t.work.title.split(",")[0]},
{t.work.title.split(",")[1]} : t.work.title}

{FEATURED.map((p, i) => )}
; /* ---------- SERVICES ---------- */ const Services = ({ t }) => { const svcBgs = ["#e8624a", "#a3b59a", "#f4b65c", "#5fa7d4", "#c97a64", "#1a1a1a"]; return (
{t.services.eyebrow}

{t.services.title.split(",").map((part, i) => {i > 0 && <>,
} {i === 1 ? {part.trim()} : part}
)}

{t.services.items.map((s, i) =>
/ {String(i + 1).padStart(2, "0")}
{s.icon}

{s.t}

{s.d}

)}
); }; /* ---------- TECH ---------- */ const Tech = ({ t }) =>

{t.tech.title}

{t.tech.sub}

{t.tech.items.map((it, i) =>
{it.i}
{it.n}
)}
; /* ---------- PRICING ---------- */ const PriceCard = ({ p, i, hoveredIndex, setHoveredIndex }) => { const cardRef = useRef(null); // 3D tilt β€” track cursor offset from the card centre and map to a // rotation. We write transform directly so React state changes don't // throttle the move loop (60fps stays smooth on touch devices that // emit pointermove). No scale change β€” the card stays the same size // and only rotates; the dim of the *other* cards is what makes it // stand out, not growth. Also drives the glare radial gradient via // --mx/my. const onMove = (e) => { const el = cardRef.current; if (!el) return; const r = el.getBoundingClientRect(); const x = e.clientX - r.left - r.width / 2; const y = e.clientY - r.top - r.height / 2; const rotY = (x / r.width) * 8; // -4deg .. +4deg const rotX = -(y / r.height) * 8; el.style.transform = `perspective(800px) rotateX(${rotX}deg) rotateY(${rotY}deg)`; el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`); el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`); }; const onLeave = () => { const el = cardRef.current; if (!el) return; el.style.transform = "perspective(800px) rotateX(0deg) rotateY(0deg)"; setHoveredIndex(null); }; // The centre card (index 1, Business) is always the visually featured // tier regardless of locale. i18n only flags `featured: true` in the // EN block, so we drive the class from index here instead. const isFeatured = i === 1 || p.featured; const isDimmed = hoveredIndex !== null && hoveredIndex !== i; return (
setHoveredIndex(i)} onMouseMove={onMove} onMouseLeave={onLeave}>
{p.tier}

{p.name}

{p.price} {p.per}

{p.desc}

    {p.features.map((f) =>
  • {f}
  • )}
{p.cta} β†’
); }; const Pricing = ({ t }) => { const [hoveredIndex, setHoveredIndex] = useState(null); return (
{t.pricing.eyebrow}

{t.pricing.title.split(",").map((p, i) => {i > 0 && <>,
{p.trim()}}{i === 0 && p}
)}

{t.pricing.sub}

setHoveredIndex(null)}> {t.pricing.plans.map((p, i) => )}
); }; /* ---------- TESTIMONIALS ---------- */ const Testimonials = ({ t }) => { const [current, setCurrent] = React.useState(0); const [dragging, setDragging] = React.useState(false); const [startX, setStartX] = React.useState(0); const [dragDelta, setDragDelta] = React.useState(0); const trackRef = React.useRef(null); const items = t.tests.items; const total = items.length; const goTo = (idx) => { setCurrent(Math.max(0, Math.min(idx, total - 1))); setDragDelta(0); }; const onPointerDown = (e) => { setDragging(true); setStartX(e.clientX ?? e.touches?.[0]?.clientX ?? 0); setDragDelta(0); }; const onPointerMove = (e) => { if (!dragging) return; const x = e.clientX ?? e.touches?.[0]?.clientX ?? startX; setDragDelta(x - startX); }; const onPointerUp = () => { if (!dragging) return; setDragging(false); if (dragDelta < -50) goTo(current + 1); else if (dragDelta > 50) goTo(current - 1); else setDragDelta(0); }; return (
{t.tests.eyebrow}

{t.tests.title}

{/* Desktop grid β€” unchanged */}
{items.map((tt, i) =>
β˜…β˜…β˜…β˜…β˜…
"{tt.q}"
{tt.name.split(" ").map((s) => s[0]).slice(0, 2).join("")}
{tt.name}
{tt.role}
)}
{/* Mobile carousel */}
onPointerDown(e.touches[0])} onTouchMove={(e) => onPointerMove(e.touches[0])} onTouchEnd={onPointerUp} style={{ transform: `translateX(calc(${-current * 100}% + ${dragDelta}px))`, transition: dragging ? "none" : "transform 0.45s cubic-bezier(0.25,1,0.5,1)", }} > {items.map((tt, i) =>
β˜…β˜…β˜…β˜…β˜… 5
"{tt.q}"
{tt.name.split(" ").map((s) => s[0]).slice(0, 2).join("")}
{tt.name}
{tt.role}
)}
{/* Fade edges */}
{/* Dots */}
{items.map((_, i) =>
); }; /* ---------- CLIENTS / TRUSTED BY ---------- Names only β€” same black color, identity comes from typography choice per brand. No marks, no subs, no decorations. */ const BRANDS = [ { id: "fimed", name: "ClΓ­nica Fimed", style: { fontFamily: "'Hanken Grotesk', sans-serif", fontWeight: 300, letterSpacing: "0.22em", textTransform: "lowercase", fontSize: "20px" } }, { id: "garden", name: "Garden Brasas", style: { fontFamily: "Georgia, 'Times New Roman', serif", fontStyle: "italic", fontWeight: 700, fontSize: "24px", letterSpacing: "-0.01em" } }, { id: "brilla", name: "Brilla Influencers", style: { fontFamily: "'Bricolage Grotesque', sans-serif", fontWeight: 800, letterSpacing: "0.16em", fontSize: "18px" } }, { id: "evaporalia", name: "Evaporalia", style: { fontFamily: "'Bricolage Grotesque', sans-serif", fontWeight: 600, fontSize: "23px", letterSpacing: "-0.01em" } }, { id: "hicool", name: "HiCool", style: { fontFamily: "'Bricolage Grotesque', sans-serif", fontWeight: 800, fontSize: "24px", letterSpacing: "-0.04em" } }, { id: "lfans", name: "LFans", style: { fontFamily: "Georgia, serif", fontWeight: 400, fontSize: "24px", letterSpacing: "0.1em" } }, { id: "mediterranean", name: "Mediterranean", style: { fontFamily: "Georgia, 'Times New Roman', serif", fontStyle: "italic", fontWeight: 400, fontSize: "22px", letterSpacing: "0.02em" } }, { id: "bodegas", name: <>Bodegas
Campanar, style: { fontFamily: "Georgia, serif", fontWeight: 700, fontSize: "17px", letterSpacing: "0.02em", textTransform: "uppercase", lineHeight: 1.2 } }, { id: "jp", name: "JP FisioterΓ‘pia", style: { fontFamily: "'Bricolage Grotesque', sans-serif", fontWeight: 800, fontSize: "22px", letterSpacing: "-0.03em" } }, { id: "calma", name: "CoCreate Succes", style: { fontFamily: "Georgia, serif", fontWeight: 400, fontStyle: "italic", fontSize: "20px", letterSpacing: "0.04em" } }, ]; const Clients = ({ t }) =>
{t.clients?.title || "Trusted by"}
{BRANDS.map((b, i) =>
{b.name}
)}
; /* ---------- FAQ ---------- */ const FAQ = ({ t }) =>
{t.faq.eyebrow}

{t.faq.title.split(" ").length > 2 ? <>{t.faq.title.split(" ").slice(0, -1).join(" ")} {t.faq.title.split(" ").slice(-1)[0]} : t.faq.title}

{t.faq.sub}

{t.faq.items.map((it, i) =>
{it.q} +
{it.a}
)}
; /* ---------- CONTACT ---------- */ const Contact = ({ t }) => { const [sent, setSent] = useState(false); const onSubmit = (e) => { e.preventDefault(); setSent(true); setTimeout(() => setSent(false), 3000); }; return (
{t.contact.eyebrow}

{t.contact.title.split(" ").map((w, i, arr) => {i === arr.length - 1 ? {w} : w}{i < arr.length - 1 ? " " : ""} )}

{t.contact.sub}

βœ‰
{t.contact.info.email}
☎
{t.contact.info.phoneCh}
☎
{t.contact.info.phoneEs}
πŸ“
{t.contact.info.location}
Bern, Switzerland
in
{t.contact.info.linkedin}