agency-web/app/components/FinalCTA.tsx

134 lines
5 KiB
TypeScript

"use client";
/**
* FINAL CTA — the closing convincer. Deliberately MORE dramatic than the hero:
* - A big "cta" FluidBackground (brighter/faster cursor-reactive WebGL) fills
* the whole section behind the content.
* - KINETIC TYPE: the headline splits to chars that rise from a mask on view
* (GSAP), and "grow?" keeps the brand gradient. Per-char pointer parallax —
* each character leans toward the cursor with spring momentum, so the words
* feel physically alive as you move across them.
* - The primary button is magnetic (already) and gains an amplified glow here.
* Reduced-motion / no-JS: type is fully visible and static; CSS gradient stands
* in for the WebGL field.
*/
import { useEffect, useRef } from "react";
import Link from "next/link";
import { gsap, SplitText } from "./gsap";
import Magnetic from "./Magnetic";
import FluidBackground from "./FluidBackground";
import { SITE } from "../content";
export default function FinalCTA() {
const root = useRef<HTMLElement>(null);
const headRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const el = root.current;
const head = headRef.current;
if (!el || !head) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
// Capture the gradient word's length BEFORE splitting — SplitText empties
// the original <span class="grad">, so we must read it first.
const gradLen = (head.querySelector(".grad")?.textContent ?? "")
.replace(/\s/g, "").length;
const ctx = gsap.context(() => {
const split = new SplitText(head, {
type: "lines,chars",
linesClass: "final__line",
charsClass: "final__char",
});
gsap.set(head, { autoAlpha: 1 });
const chars = split.chars as HTMLElement[];
// Re-establish the brand gradient on the chars of the gradient word.
// After SplitText restructures into lines, the original .grad span is
// emptied and its chars become plain divs that inherit
// `-webkit-text-fill-color:transparent` WITHOUT a background (=> invisible).
// The gradient word is the trailing word, so the last `gradLen` chars get
// the .final__char--grad class which repaints each glyph with the gradient.
if (gradLen > 0) {
chars.slice(chars.length - gradLen).forEach((c) => {
c.classList.add("final__char--grad");
});
}
// chars rise from behind the line mask on view; the trigger fires as the
// section enters. `once` + the natural (visible) resting state guarantee
// the headline ends fully shown.
gsap.from(chars, {
yPercent: 120,
opacity: 0,
duration: 0.7,
ease: "emilOut",
stagger: 0.02,
scrollTrigger: { trigger: el, start: "top 85%", once: true },
});
// per-char pointer parallax: each char leans toward the cursor.
const qx = chars.map((c) => gsap.quickTo(c, "x", { duration: 0.6, ease: "power3.out" }));
const qy = chars.map((c) => gsap.quickTo(c, "y", { duration: 0.6, ease: "power3.out" }));
const onMove = (e: PointerEvent) => {
const cx = e.clientX;
const cy = e.clientY;
chars.forEach((c, i) => {
const cr = c.getBoundingClientRect();
const dx = cx - (cr.left + cr.width / 2);
const dy = cy - (cr.top + cr.height / 2);
const dist = Math.hypot(dx, dy);
const pull = Math.max(0, 1 - dist / 420);
qx[i](dx * 0.06 * pull);
qy[i](dy * 0.06 * pull);
});
};
const onLeave = () => chars.forEach((_, i) => { qx[i](0); qy[i](0); });
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
};
}, el);
return () => ctx.revert();
}, []);
return (
<section ref={root} className="final frame" aria-labelledby="final-h">
{/* dramatic cursor-reactive WebGL field — the closing moment */}
<FluidBackground variant="cta" />
<div className="wrap final__wrap">
<p className="kicker">
<span className="kicker__dot" />
The bottom line
</p>
<h2 ref={headRef} id="final-h" className="display final__h">
Ready to <span className="grad">grow?</span>
</h2>
<p className="final__sub">
No long contracts. No vanity reports. Marketing you can measure in
sales.
</p>
<div className="final__cta">
<Magnetic strength={0.45}>
<Link
href={SITE.booking}
className="btn btn--accent btn--xl"
data-cursor="Book a call"
>
Book a call
</Link>
</Magnetic>
<a href={`mailto:${SITE.email}`} className="btn btn--ghost">
or {SITE.email}
</a>
</div>
</div>
</section>
);
}