/* =========================================================
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 (
<>
FR
Fran Ruiz
{theme === "dark" ? "βΌ" : "βΎ"}
{theme === "dark" ? "Light" : "Dark"}
{e.stopPropagation();setOpen(!open);}}>
{current.flag}
{current.id.toUpperCase()}
βΎ
{langs.map((l) =>
{setLang(l.id);setOpen(false);}}>
{l.flag} {l.name}
)}
{t.nav.cta}
β
setDrawer(true)} aria-label="Open menu">
{/* 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}
;
/* ---------- MARQUEE ---------- */
const Marquee = ({ t }) => {
const row =
{t.marquee.concat(t.marquee).map((it, i) =>
{it}
)}
;
return
{row}
;
};
/* ---------- ABOUT ---------- */
const About = ({ t }) =>
{t.about.eyebrow}
π
{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.img &&
+
{p.title[lang]}
Drop image here
}
{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) =>
)}
;
/* ---------- 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("")}
)}
{/* 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("")}
)}
{/* Fade edges */}
{/* Dots */}
{items.map((_, i) =>
goTo(i)}
aria-label={`Testimonio ${i + 1}`}
/>
)}
);
};
/* ---------- 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 (
);
};
/* ---------- FOOTER ---------- */
const Footer = ({ t }) =>
{t.footer.tagline}
{t.footer.taglineInk}
;
Object.assign(window, { Nav, Hero, Marquee, About, Work, Services, Tech, Pricing, Testimonials, Clients, FAQ, Contact, Footer });