291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|