agency-web/app/components/ProcessLoop.tsx

324 lines
14 KiB
TypeScript
Raw Permalink 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 MEANINGFUL, recognizable icon paths for the morph (100x100 canvas).
Drawn as filled silhouettes with similar complexity so MorphSVG tweens
cleanly between them — never to nonsense:
Audit -> magnifying glass
Plan -> target (concentric rings + center)
Execute -> rocket (rising)
Report -> line chart trending up with plotted nodes */
const SHAPES = [
// Audit — magnifying glass (lens ring + handle)
"M46 16a26 26 0 1 0 16 46.6l16.7 16.7a5 5 0 0 0 7-7L69 71.6A26 26 0 0 0 46 16Zm0 12a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z",
// Plan — target / bullseye (outer ring + mid ring + center dot)
"M50 14a36 36 0 1 0 0 72 36 36 0 0 0 0-72Zm0 12a24 24 0 1 1 0 48 24 24 0 0 1 0-48Zm0 12a12 12 0 1 0 0 24 12 12 0 0 0 0-24Z",
// Execute — rocket (nose, body, fins) rising
"M50 12c12 8 18 22 18 38l-6 14H38l-6-14c0-16 6-30 18-38Zm0 20a6 6 0 1 0 0 12 6 6 0 0 0 0-12ZM34 66l-8 16 14-6Zm32 0 8 16-14-6Z",
// Report — line chart trending up (axes + plotted line + nodes)
"M22 18v54a6 6 0 0 0 6 6h54v-10H30V18ZM40 60l12-14 10 8 18-22 7 6-23 28-10-8-9 11Z",
];
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");
const morphGlow = el.querySelector<SVGPathElement>(".loop-glyph__glow");
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 (+ its glow clone in sync) + a gentle counter-rotation
// so it tumbles as it transforms, and grow the rail fill.
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at);
if (morphGlow)
tl.to(morphGlow, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at);
tl.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 — dimensional: a blurred glow clone sits behind
the crisp gradient-filled + gradient-stroked face */}
<div className="loop-glyph">
<svg viewBox="0 0 100 100">
<defs>
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#60a5fa" />
<stop offset="50%" stopColor="#a78bfa" />
<stop offset="100%" stopColor="#34d399" />
</linearGradient>
<linearGradient id="loopStroke" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#dbeafe" />
<stop offset="100%" stopColor="#a7f3d0" />
</linearGradient>
<filter id="loopGlow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" />
</filter>
</defs>
{/* soft glow clone (kept in sync with the face via MorphSVG) */}
<path
className="loop-glyph__glow"
d={SHAPES[0]}
fill="url(#loopGrad)"
filter="url(#loopGlow)"
opacity="0.6"
/>
{/* crisp face — gradient fill + light gradient stroke for dimension */}
<path
className="loop-glyph__path"
d={SHAPES[0]}
fill="url(#loopGrad)"
stroke="url(#loopStroke)"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</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>
);
}