agency-web/app/components/ProcessLoop.tsx

291 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* PROCESS — "The Feedback Loop" as pinned scroll storytelling.
* - GSAP ScrollTrigger pins the panel while the user scrolls through 4 steps.
* - The LEFT column is a dark "stage card" (matching the work-cards' visual
* language) that floats over the cream paper. Inside it a single SVG glyph
* MORPHS between 4 shapes (MorphSVGPlugin) AND a per-step VISUAL PANEL swaps:
* Audit -> a "revenue leak" line chart
* Plan -> a channel x objective mini-matrix
* Execute -> rising animated bars
* Report -> a metric card
* This kills the empty cream void and gives the section weight.
* - A progress rail + numbered steps light up in sync (scrub).
* - prefers-reduced-motion: no pin, no morph; panels render statically (all
* visible, no blur) and steps render as a legible static list.
*/
import { useEffect, useRef } from "react";
// Side-effect import registers ScrollTrigger + MorphSVGPlugin; gsap exposes both.
import { gsap } from "./gsap";
import { processSteps } from "../content";
// Four target paths for the morph (drawn on a 100x100 canvas).
const SHAPES = [
// Audit — magnifying glass
"M44 20a24 24 0 1 0 15 43l18 18 7-7-18-18A24 24 0 0 0 44 20Zm0 10a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z",
// Plan — connected route / nodes
"M22 30a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm40 40a8 8 0 1 1 16 0 8 8 0 0 1-16 0ZM30 38v8a16 16 0 0 0 16 16h8a16 16 0 0 1 16 16v-2 2h-8a26 26 0 0 1-26-26v-8Z",
// Execute — lightning bolt
"M55 14 26 58h20l-6 30 32-46H50l9-28Z",
// Report — bar chart trending up
"M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14",
];
const ACCENTS = ["#3b82f6", "#8b5cf6", "#10b981", "#7c3aed"];
export default function ProcessLoop() {
const root = useRef<HTMLElement>(null);
useEffect(() => {
const el = root.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const ctx = gsap.context(() => {
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
if (!morph) return;
const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
const panels = gsap.utils.toArray<HTMLElement>(".loop-panel");
const total = SHAPES.length;
const setActive = (idx: number) => {
steps.forEach((s, j) => s.classList.toggle("is-active", j === idx));
panels.forEach((p, j) => p.classList.toggle("is-active", j === idx));
el.style.setProperty("--loop-accent", ACCENTS[idx] ?? ACCENTS[0]);
};
// One pinned, scrubbed timeline drives the morph + rotation + rail fill.
// Trimmed end length (was innerHeight*(total-0.2)) so the pin is shorter
// and the section no longer amplifies the emptiness.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".loop-pin",
start: "top top",
end: () => `+=${window.innerHeight * (total - 1.85)}`,
pin: true,
scrub: 0.6,
anticipatePin: 1,
onUpdate: (self) => {
const idx = Math.min(
total - 1,
Math.floor(self.progress * total + 0.0001)
);
setActive(idx);
},
},
});
setActive(0);
const counter = el.querySelector<HTMLElement>(".loop__count-cur");
const node = el.querySelector<HTMLElement>(".loop-rail__node");
SHAPES.forEach((shape, i) => {
if (i === 0) return; // start shape already in markup
const at = i - 1;
// morph the glyph + a counter-rotation so it tumbles as it transforms,
// and grow the rail fill (+ a node that rides the fill edge).
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at)
.to(".loop-glyph", { rotate: i * 5, duration: 1, ease: "power2.inOut" }, at)
.to(
".loop-rail__fill",
{ scaleY: i / (total - 1), duration: 1, ease: "none" },
at
)
.to(
node,
{ top: `${(i / (total - 1)) * 100}%`, duration: 1, ease: "none" },
at
);
});
// separate light update so the counter is robust at every scroll position
tl.eventCallback("onUpdate", () => {
const idx = Math.min(total, Math.floor(tl.progress() * total) + 1);
if (counter) counter.textContent = String(idx).padStart(2, "0");
});
}, el);
return () => ctx.revert();
}, []);
return (
<section ref={root} id="process" className="loop frame" data-invert aria-labelledby="loop-h">
<div className="loop-pin">
<div className="wrap loop__inner">
<header className="sec-head loop__head">
<div>
<p className="kicker">
<span className="kicker__dot" />
The Feedback Loop
</p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</div>
<p className="loop__count" aria-hidden="true">
<span className="loop__count-cur">01</span>
<span className="loop__count-sep">/</span>
<span className="loop__count-tot">0{processSteps.length}</span>
</p>
</header>
<div className="loop__stage">
{/* dark stage card — floats the glyph + per-step visual over the
cream paper (mirrors how the work-cards float over the dark) */}
<div className="loop-card" aria-hidden="true">
<div className="loop-card__glow" />
{/* morphing glyph */}
<div className="loop-glyph">
<svg viewBox="0 0 100 100">
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
<defs>
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="50%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
</svg>
</div>
{/* per-step visual panels — only the active one is shown */}
<div className="loop-panels">
{/* 01 AUDIT — "revenue leak" line chart (drops off) */}
<figure className="loop-panel is-active" data-step="audit">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Revenue leak detected
</figcaption>
<svg className="loop-panel__svg" viewBox="0 0 240 96" preserveAspectRatio="none">
<defs>
<linearGradient id="leakFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.32" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0,40 L48,32 L96,38 L120,26 L144,58 L192,74 L240,82 L240,96 L0,96 Z"
fill="url(#leakFill)"
/>
<polyline
points="0,40 48,32 96,38 120,26 144,58 192,74 240,82"
fill="none"
stroke="#3b82f6"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* leak marker */}
<circle cx="120" cy="26" r="3.5" fill="#10b981" />
<circle cx="144" cy="58" r="4.5" fill="none" stroke="#ef6a6a" strokeWidth="2" />
</svg>
<p className="loop-panel__note">
<span className="loop-panel__lab">$14k/mo</span> wasted spend
</p>
</figure>
{/* 02 PLAN — channel x objective mini-matrix */}
<figure className="loop-panel" data-step="plan">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Channel × objective mix
</figcaption>
<div className="loop-matrix">
<span className="loop-matrix__h" />
<span className="loop-matrix__h">Reach</span>
<span className="loop-matrix__h">Leads</span>
<span className="loop-matrix__h">Sales</span>
{(
[
["Paid", 1, 2, 3],
["SEO", 2, 3, 2],
["Content", 3, 2, 1],
] as const
).flatMap((row) => [
<span className="loop-matrix__r" key={`${row[0]}-l`}>
{row[0]}
</span>,
...[row[1], row[2], row[3]].map((lvl, ci) => (
<span
className="loop-matrix__c"
data-lvl={lvl}
key={`${row[0]}-${ci}`}
/>
)),
])}
</div>
</figure>
{/* 03 EXECUTE — rising animated bars */}
<figure className="loop-panel" data-step="execute">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Live optimization
</figcaption>
<div className="loop-bars">
{[34, 52, 46, 70, 84, 96].map((h, i) => (
<span
className="loop-bar"
style={{ ["--h" as string]: `${h}%`, ["--i" as string]: i }}
key={i}
/>
))}
</div>
<p className="loop-panel__note">
<span className="loop-panel__lab">+38%</span> ROAS, week over week
</p>
</figure>
{/* 04 REPORT — metric card */}
<figure className="loop-panel" data-step="report">
<figcaption className="loop-panel__cap">
<span className="loop-panel__dot" />
Monthly report
</figcaption>
<div className="loop-metric">
<span className="loop-metric__num display">+183%</span>
<span className="loop-metric__lab">revenue vs. last quarter</span>
<span className="loop-metric__trend">
<svg viewBox="0 0 80 24" preserveAspectRatio="none">
<polyline
points="0,20 16,18 32,12 48,13 64,6 80,2"
fill="none"
stroke="#10b981"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
<em>tied to pipeline</em>
</span>
</div>
</figure>
</div>
</div>
{/* steps + progress rail */}
<div className="loop__steps">
<div className="loop-rail" aria-hidden="true">
<span className="loop-rail__fill" />
<span className="loop-rail__node" />
</div>
<ol>
{processSteps.map((p) => (
<li className="loop-step" key={p.n}>
<span className="loop-step__n">{p.n}</span>
<div>
<h3 className="loop-step__name">{p.name}</h3>
<p className="loop-step__desc">{p.desc}</p>
</div>
</li>
))}
</ol>
</div>
</div>
</div>
</div>
</section>
);
}