134 lines
5 KiB
TypeScript
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>
|
|
);
|
|
}
|