feat: real people photos in testimonials + distinct sector case images, optimized webp; cleanup unused PNGs

This commit is contained in:
Feedback Studios 2026-06-16 09:34:45 +00:00
parent b67840270b
commit 2b7125c56b
22 changed files with 1719 additions and 319 deletions

View file

@ -1,19 +1,21 @@
"use client";
/**
* CASE CARD replaces the rejected repetitive photos with a coded, animated
* data visual unique per case:
* - 3D tilt that tracks the pointer via a SOFT spring (momentum => the card
* floats and settles, never snaps). Transform-only.
* - A small lift + accent shadow on hover; a press dip (scale 0.985) so the
* whole card shares the page's tactile press language.
* - An animated bars + sparkline "result chart" (CSS + GSAP DrawSVG on the
* spark) growing into view no two cards look alike.
* - A glare/sheen that follows the cursor across the surface.
* - The chart panel gets a clip-path inset() wipe on first view (premium).
* Reduced-motion / touch: flat card, bars still grow on view via CSS.
* CASE CARD distinct per case, photo-led with a tailored data viz.
* - Each card leads with its real SECTOR PHOTO (next/image -> WebP/AVIF, lazy,
* reserved aspect-ratio so no CLS). The image scales + parallax-drifts toward
* the pointer on hover.
* - Below the photo, a data visual UNIQUE to the case story:
* dual -> CPA-down + ROAS-up twin lines (fashion)
* ramp -> a slow-then-steep demo-requests line (SaaS)
* bookings -> monthly booking bars climbing toward a capacity target (clinic)
* The viz animates in on view (GSAP DrawSVG / CSS bar grow); no two alike.
* - 3D tilt that tracks the pointer via a soft spring; lift + accent shadow on
* hover; press dip. Transform-only.
* Reduced-motion / touch: flat card, static image, viz renders fully drawn.
*/
import { useEffect, useRef } from "react";
import Image from "next/image";
import {
motion,
useMotionValue,
@ -26,15 +28,141 @@ import { SPRING, EASE_OUT } from "./motion";
export type CaseData = {
tag: string;
image: string;
alt: string;
problem: string;
result: string;
how: string;
metricNum: string;
metricLabel: string;
bars: number[]; // 0..100 heights — unique per case
accent: string; // brand accent for this card
viz: "dual" | "ramp" | "bookings";
series: number[];
series2?: number[];
target?: number;
accent: string;
};
/* ---- viz geometry helpers (0..100 series mapped into a 240x96 box) ---- */
const VW = 240;
const VH = 96;
const VP = 10;
function toPoints(series: number[]) {
const n = series.length;
return series.map((v, i) => {
const x = VP + (i / (n - 1)) * (VW - VP * 2);
const y = VP + (1 - v / 100) * (VH - VP * 2);
return { x, y };
});
}
const poly = (series: number[]) =>
toPoints(series)
.map((p) => `${p.x},${p.y}`)
.join(" ");
function CaseViz({ data }: { data: CaseData }) {
const accent = data.accent;
if (data.viz === "dual") {
// CPA (falling, dashed) + ROAS (rising, solid) twin lines
return (
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
<polyline
className="case__line case__line--muted"
points={poly(data.series)}
fill="none"
stroke="#9a9aac"
strokeWidth="2"
strokeDasharray="4 4"
strokeLinecap="round"
/>
<polyline
className="case__line case__line--accent"
points={poly(data.series2 ?? data.series)}
fill="none"
stroke={accent}
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g className="case__legend" fill="currentColor">
<text x={VP} y={VH - 1}>CPA </text>
<text x={VW - VP} y={VH - 1} textAnchor="end" fill={accent}>ROAS </text>
</g>
</svg>
);
}
if (data.viz === "bookings") {
// monthly bookings bars climbing toward a capacity target line
const n = data.series.length;
const gap = 6;
const bw = (VW - VP * 2 - gap * (n - 1)) / n;
const ty = VP + (1 - (data.target ?? 90) / 100) * (VH - VP * 2);
return (
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
{/* target / capacity line */}
<line
className="case__target"
x1={VP}
y1={ty}
x2={VW - VP}
y2={ty}
stroke={accent}
strokeWidth="1.5"
strokeDasharray="3 4"
/>
<text className="case__targetlab" x={VW - VP} y={ty - 4} textAnchor="end" fill={accent}>
capacity
</text>
{data.series.map((v, i) => {
const h = (v / 100) * (VH - VP * 2);
const x = VP + i * (bw + gap);
const y = VH - VP - h;
return (
<rect
key={i}
className="case__bar2"
x={x}
y={y}
width={bw}
height={h}
rx={2}
fill={accent}
style={{ ["--d" as string]: `${i * 80}ms` }}
/>
);
})}
</svg>
);
}
// ramp — slow-then-steep demo-requests line with an area fill
const pts = toPoints(data.series);
const linePts = poly(data.series);
const area = `${linePts} ${VW - VP},${VH - VP} ${VP},${VH - VP}`;
return (
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
<defs>
<linearGradient id={`rampFill-${accent.slice(1)}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={accent} stopOpacity="0.32" />
<stop offset="100%" stopColor={accent} stopOpacity="0" />
</linearGradient>
</defs>
<polygon className="case__area" points={area} fill={`url(#rampFill-${accent.slice(1)})`} />
<polyline
className="case__line case__line--accent"
points={linePts}
fill="none"
stroke={accent}
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle className="case__rampdot" cx={pts[pts.length - 1].x} cy={pts[pts.length - 1].y} r="3.5" fill={accent} />
</svg>
);
}
export default function CaseCard({
data,
index,
@ -46,13 +174,11 @@ export default function CaseCard({
const ref = useRef<HTMLDivElement>(null);
const mx = useMotionValue(0.5);
const my = useMotionValue(0.5);
const rx = useSpring(useTransform(my, [0, 1], [6, -6]), SPRING.tilt);
const ry = useSpring(useTransform(mx, [0, 1], [-8, 8]), SPRING.tilt);
const glare = useTransform(
[mx, my],
([gx, gy]: number[]) =>
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.16), transparent 45%)`
);
const rx = useSpring(useTransform(my, [0, 1], [5, -5]), SPRING.tilt);
const ry = useSpring(useTransform(mx, [0, 1], [-6, 6]), SPRING.tilt);
// image parallax — drifts opposite the tilt for depth
const imgX = useSpring(useTransform(mx, [0, 1], [10, -10]), SPRING.tilt);
const imgY = useSpring(useTransform(my, [0, 1], [10, -10]), SPRING.tilt);
const onMove = (e: React.PointerEvent) => {
if (reduce || e.pointerType !== "mouse") return;
@ -67,18 +193,27 @@ export default function CaseCard({
my.set(0.5);
};
// DrawSVG the sparkline once the card scrolls in — a small, deliberate detail.
// animate the viz in on view (lines draw, bars grow)
useEffect(() => {
const el = ref.current;
if (!el || reduce) return;
const ctx = gsap.context(() => {
gsap.from(el.querySelector(".case__spark polyline"), {
const lines = el.querySelectorAll(".case__line, .case__area, .case__target");
gsap.from(lines, {
drawSVG: "0%",
duration: 1.1,
ease: "power2.out",
stagger: 0.12,
scrollTrigger: { trigger: el, start: "top 82%", once: true },
delay: 0.15 + index * 0.05,
});
gsap.from(el.querySelectorAll(".case__rampdot, .case__targetlab, .case__legend"), {
autoAlpha: 0,
duration: 0.6,
ease: "power2.out",
scrollTrigger: { trigger: el, start: "top 82%", once: true },
delay: 0.7 + index * 0.05,
});
}, el);
return () => ctx.revert();
}, [reduce, index]);
@ -104,42 +239,32 @@ export default function CaseCard({
className="case__inner"
style={{ ["--case-accent" as string]: data.accent }}
>
<div className="case__head">
<span className="case__no">{String(index + 1).padStart(2, "0")}</span>
<span className="case__tag">{data.tag}</span>
{/* sector photo — leads the card, parallax/scale on hover */}
<div className="case__photo">
<motion.div
className="case__photo-inner"
style={reduce ? undefined : { x: imgX, y: imgY }}
>
<Image
src={data.image}
alt={data.alt}
width={900}
height={672}
sizes="(max-width: 980px) 90vw, 30vw"
loading="lazy"
className="case__img"
/>
</motion.div>
<span className="case__photo-tag">{data.tag}</span>
<span className="case__photo-no" aria-hidden="true">
{String(index + 1).padStart(2, "0")}
</span>
</div>
{/* coded data visual — unique bars per case, clip-wiped on first view */}
<motion.div
className="case__viz"
aria-hidden="true"
initial={reduce ? { opacity: 1 } : { clipPath: "inset(0 0 100% 0)" }}
whileInView={{ clipPath: "inset(0 0 0% 0)" }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.9, delay: index * 0.08 + 0.1, ease: EASE_OUT }}
>
<div className="case__bars">
{data.bars.map((h, i) => (
<span
key={i}
className="case__bar"
style={{
["--h" as string]: `${h}%`,
["--d" as string]: `${i * 70}ms`,
}}
/>
))}
</div>
<svg className="case__spark" viewBox="0 0 120 40" preserveAspectRatio="none">
<polyline
points="0,34 24,30 48,22 72,18 96,8 120,4"
fill="none"
stroke="var(--case-accent)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</motion.div>
{/* per-case data visual */}
<div className="case__viz" aria-hidden="true">
<CaseViz data={data} />
</div>
<div className="case__body">
<p className="case__problem">
@ -154,14 +279,6 @@ export default function CaseCard({
<span className="case__metric-num display">{data.metricNum}</span>
<span className="case__metric-lab">{data.metricLabel}</span>
</div>
{!reduce && (
<motion.span
className="case__glare"
aria-hidden="true"
style={{ background: glare }}
/>
)}
</div>
</motion.article>
);

134
app/components/FinalCTA.tsx Normal file
View file

@ -0,0 +1,134 @@
"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>
);
}

View file

@ -0,0 +1,336 @@
"use client";
/**
* FLUID BACKGROUND a live, cursor-reactive WebGL field (ogl) that sits BEHIND
* the content. A full-screen fragment shader paints a flowing iridescent
* metaball / fluid mesh in the brand palette (blue -> violet -> emerald). The
* field bends, glows and FLOWS toward the pointer: a spring-smoothed pointer
* position warps the domain-warped noise and injects a bright, additive glow
* blob that trails the cursor with momentum.
*
* How the cursor-reactivity works:
* - We track the raw pointer in 0..1 UV space and LERP a smoothed pointer
* toward it every frame (momentum => the glow trails, never snaps).
* - We also track pointer *velocity* (smoothed delta) and feed it to the
* shader as `uVel`, so fast flicks visibly push/stretch the fluid.
* - In the shader, the metaballs and the domain-warp offset are pulled toward
* `uPointer`; a soft radial glow blooms at the pointer; warp strength scales
* with `uVel` so the surface "reacts" to how you move.
*
* Variants:
* - "hero": subtle, recedes behind the headline.
* - "cta": bigger, brighter, faster the closing dramatic moment.
*
* Performance & resilience:
* - Pauses rendering when the tab is hidden OR the canvas is offscreen (IO).
* - DPR capped at 1.6 so it never tanks fill-rate / LCP.
* - WebGL capability check: if no GL context, we render nothing and the CSS
* gradient fallback (always painted underneath) shows through.
* - prefers-reduced-motion: we never start the GL loop; the static CSS
* gradient fallback stands in.
* - Full cleanup on unmount (RAF, listeners, GL context lost).
*/
import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
type Variant = "hero" | "cta";
const FRAG = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uPointer; // smoothed pointer, 0..1 (y up)
uniform float uVel; // smoothed pointer speed 0..~1
uniform float uIntensity; // variant intensity
uniform float uReveal; // 0..1 fade-in on mount
varying vec2 vUv;
// brand palette
const vec3 C_BLUE = vec3(0.231, 0.510, 0.965); // #3b82f6
const vec3 C_VIOLET = vec3(0.545, 0.361, 0.965); // #8b5cf6
const vec3 C_EMER = vec3(0.063, 0.725, 0.506); // #10b981
const vec3 C_BG = vec3(0.027, 0.027, 0.043); // #07070b
// hash / value noise
float hash(vec2 p){
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p){
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p){
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++){
v += a * noise(p);
p *= 2.0;
a *= 0.5;
}
return v;
}
// soft metaball field — sum of inverse-distance blobs that orbit + drift
float metaballs(vec2 p, vec2 ptr, float t, float speed){
float f = 0.0;
for (int i = 0; i < 5; i++){
float fi = float(i);
// each blob orbits on its own phase
vec2 c = vec2(
0.5 + 0.34 * sin(t * speed * (0.4 + fi * 0.12) + fi * 1.7),
0.5 + 0.30 * cos(t * speed * (0.5 + fi * 0.09) + fi * 2.3)
);
// pull every blob a little toward the pointer => the field "flows" to it
c = mix(c, ptr, 0.18 + 0.05 * fi);
float r = 0.16 + 0.05 * sin(t * 0.6 + fi);
f += r * r / (dot(p - c, p - c) + 0.0009);
}
return f;
}
void main(){
vec2 uv = vUv;
// correct for aspect so blobs stay round-ish
float aspect = uRes.x / max(uRes.y, 1.0);
vec2 p = uv;
p.x *= aspect;
vec2 ptr = uPointer;
ptr.x *= aspect;
float t = uTime;
// domain warp — distortion grows toward the pointer and with pointer speed
float dPtr = distance(p, ptr);
float pull = exp(-dPtr * 2.4); // 1 near cursor -> 0 far
float warpAmt = (0.18 + uVel * 0.9) * (0.4 + pull);
vec2 q = p + warpAmt * vec2(
fbm(p * 2.2 + vec2(t * 0.10, t * 0.07)),
fbm(p * 2.2 + vec2(-t * 0.08, t * 0.12) + 5.2)
);
// base flowing fbm field
float n = fbm(q * 1.7 + vec2(t * 0.05, -t * 0.04));
// metaballs flowing toward the pointer
float mb = metaballs(q, ptr, t, 0.7 + uVel * 1.2);
float field = smoothstep(0.7, 1.9, mb) * 0.9 + n * 0.6;
// iridescent color ramp across the field
vec3 col = mix(C_BLUE, C_VIOLET, smoothstep(0.15, 0.7, field + n * 0.3));
col = mix(col, C_EMER, smoothstep(0.55, 1.05, field + 0.18 * sin(t * 0.4 + uv.x * 3.0)));
// composite onto the dark canvas by the field strength
float lum = smoothstep(0.05, 1.2, field);
vec3 outc = mix(C_BG, col, clamp(lum, 0.0, 1.0) * uIntensity);
// pointer bloom — a bright additive glow that trails the cursor
float glow = exp(-dPtr * (4.5 - uVel * 1.5)) * (0.45 + uVel * 0.6);
vec3 glowCol = mix(C_VIOLET, C_EMER, 0.4 + 0.4 * sin(t * 0.7));
outc += glowCol * glow * uIntensity;
// subtle grain so it never bands on dark gradients
float g = hash(uv * uRes.xy * 0.5 + t) - 0.5;
outc += g * 0.018;
// soft vignette keeps edges grounded in the canvas
float vig = smoothstep(1.25, 0.25, length(uv - 0.5));
outc = mix(C_BG, outc, 0.35 + 0.65 * vig);
gl_FragColor = vec4(outc * uReveal, 1.0);
}
`;
const VERT = /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
export default function FluidBackground({
variant = "hero",
className,
}: {
variant?: Variant;
className?: string;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return; // CSS gradient fallback stands in
// --- WebGL capability check ---
let renderer: Renderer;
try {
renderer = new Renderer({
alpha: false,
antialias: false,
dpr: Math.min(window.devicePixelRatio || 1, 1.6),
powerPreference: "high-performance",
});
} catch {
return; // no GL -> CSS fallback shows
}
const gl = renderer.gl;
if (!gl) return;
const canvas = gl.canvas as HTMLCanvasElement;
canvas.className = "fluid__canvas";
canvas.setAttribute("aria-hidden", "true");
wrap.appendChild(canvas);
const isCta = variant === "cta";
// cta is bigger/faster but slightly toned so the centred headline stays
// legible against it (the readability backdrop does the rest)
const intensity = isCta ? 0.9 : 0.78;
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms: {
uTime: { value: 0 },
uRes: { value: new Vec2(1, 1) },
uPointer: { value: new Vec2(0.5, 0.5) },
uVel: { value: 0 },
uIntensity: { value: intensity },
uReveal: { value: 0 },
},
});
const mesh = new Mesh(gl, { geometry, program });
const resize = () => {
const w = wrap.clientWidth || window.innerWidth;
const h = wrap.clientHeight || window.innerHeight;
renderer.setSize(w, h);
program.uniforms.uRes.value.set(gl.drawingBufferWidth, gl.drawingBufferHeight);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(wrap);
// --- pointer tracking (raw -> smoothed, with velocity) ---
const rawPtr = { x: 0.5, y: 0.5 };
const smoothPtr = { x: 0.5, y: 0.5 };
let vel = 0;
let lastX = 0.5;
let lastY = 0.5;
const onPointerMove = (e: PointerEvent) => {
const r = wrap.getBoundingClientRect();
rawPtr.x = (e.clientX - r.left) / r.width;
// flip Y so shader-space y is up and matches screen intuition
rawPtr.y = 1 - (e.clientY - r.top) / r.height;
};
// listen on window so movement is tracked even over the content layer
window.addEventListener("pointermove", onPointerMove, { passive: true });
// --- visibility / offscreen pausing ---
let running = false;
let rafId = 0;
let inView = true;
let pageVisible = !document.hidden;
const startTime = performance.now();
let lastTick = startTime;
const frame = (now: number) => {
if (!running) return;
const dt = Math.min((now - lastTick) / 1000, 0.05);
lastTick = now;
// momentum: lerp smoothed pointer toward raw
const ease = 0.07;
smoothPtr.x += (rawPtr.x - smoothPtr.x) * ease;
smoothPtr.y += (rawPtr.y - smoothPtr.y) * ease;
// velocity from smoothed delta, decayed for a soft trailing reaction
const dx = smoothPtr.x - lastX;
const dy = smoothPtr.y - lastY;
lastX = smoothPtr.x;
lastY = smoothPtr.y;
const instVel = Math.min(Math.hypot(dx, dy) * 60, 1);
vel += (instVel - vel) * 0.12;
program.uniforms.uTime.value = (now - startTime) / 1000;
program.uniforms.uPointer.value.set(smoothPtr.x, smoothPtr.y);
program.uniforms.uVel.value = vel;
// ease the reveal in over ~1s
const rev = program.uniforms.uReveal.value as number;
if (rev < 1) program.uniforms.uReveal.value = Math.min(1, rev + dt * 1.4);
renderer.render({ scene: mesh });
rafId = requestAnimationFrame(frame);
};
const start = () => {
if (running || !inView || !pageVisible) return;
running = true;
lastTick = performance.now();
rafId = requestAnimationFrame(frame);
};
const stop = () => {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
};
const io = new IntersectionObserver(
([entry]) => {
inView = entry.isIntersecting;
if (inView) start();
else stop();
},
{ threshold: 0 }
);
io.observe(wrap);
const onVisibility = () => {
pageVisible = !document.hidden;
if (pageVisible) start();
else stop();
};
document.addEventListener("visibilitychange", onVisibility);
start();
return () => {
stop();
io.disconnect();
ro.disconnect();
window.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("visibilitychange", onVisibility);
const ext = gl.getExtension("WEBGL_lose_context");
ext?.loseContext();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
};
}, [variant]);
return (
<div
ref={wrapRef}
className={`fluid fluid--${variant} ${className ?? ""}`}
aria-hidden="true"
>
{/* static gradient fallback always painted underneath; the only thing
shown under reduced-motion / no-WebGL */}
<div className="fluid__fallback" />
</div>
);
}

View file

@ -17,13 +17,10 @@ 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";
/* Custom GSAP eases that mirror the CSS tokens (registered once, idempotent). */
gsap.registerEase?.("emilOut", (p) => {
// approximates cubic-bezier(0.23,1,0.32,1) feel — strong, soft landing
return 1 - Math.pow(1 - p, 3.2);
});
/* "emilOut" ease is registered once in ./gsap (shared across components). */
export default function Hero() {
const root = useRef<HTMLDivElement>(null);
@ -193,7 +190,12 @@ export default function Hero() {
aria-labelledby="hero-h1"
onPointerMove={onPointerMove}
>
{/* ---- background video (deferred) + poster + scrims ---- */}
{/* ---- live WebGL fluid background (cursor-reactive) ---- */}
<FluidBackground variant="hero" />
{/* ---- background video (deferred) + poster + scrims ----
kept as a subtle moving TEXTURE layered over the fluid; the fluid is
now the dominant, cursor-reactive element. */}
<div className="hero__media" aria-hidden="true">
<video
ref={videoRef}

View file

@ -20,16 +20,22 @@ import { useEffect, useRef } from "react";
import { gsap } from "./gsap";
import { processSteps } from "../content";
// Four target paths for the morph (drawn on a 100x100 canvas).
/* 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
"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",
// 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"];
@ -45,6 +51,7 @@ export default function ProcessLoop() {
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");
@ -84,10 +91,12 @@ export default function ProcessLoop() {
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)
// 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" },
@ -137,17 +146,41 @@ export default function ProcessLoop() {
<div className="loop-card" aria-hidden="true">
<div className="loop-card__glow" />
{/* morphing glyph */}
{/* 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">
<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" />
<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>

View file

@ -2,20 +2,65 @@
/**
* METRICS scoreboard.
* - CountUp numbers animate on view (Motion).
* - A second ANIMATED-SVG moment: a bar chart whose bars DRAW/grow + a baseline
* that draws itself (GSAP DrawSVG) when the section scrolls in.
* Reduced-motion: numbers jump to final, bars render at full height (CSS).
* - The four headline metrics count up on view (Motion) and are now the focus:
* each cell is an interactive tile with a hover lift, an accent edge that
* wipes in, and a tiny per-metric sparkline that animates.
* - The previously-meaningless decorative bars are replaced with a MEANINGFUL,
* LABELLED growth trend: a 6-month "client revenue" area+line chart with real
* month ticks, a value axis, and interactive data points that enlarge + show
* a tooltip on hover/focus. It communicates the compounding-growth story
* instead of being filler.
* Reduced-motion: numbers rest at final value, chart renders fully drawn, no
* count-up, no draw animation.
*/
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { gsap } from "./gsap";
import CountUp from "./CountUp";
import { metrics } from "../content";
const BARS = [38, 56, 72, 64, 88, 96]; // decorative growth bars (0..100)
/* 6-month sample "client revenue generated" trend (index, $M). Compounding
curve that mirrors the "$40M+ generated / 3.8x ROAS" headline story. */
const TREND = [
{ m: "Jan", v: 2.1 },
{ m: "Feb", v: 3.4 },
{ m: "Mar", v: 5.0 },
{ m: "Apr", v: 7.8 },
{ m: "May", v: 11.2 },
{ m: "Jun", v: 16.4 },
];
// tiny per-metric sparkline shapes (each different, matching the metric's story)
const SPARKS: string[] = [
"0,18 20,16 40,12 60,11 80,6 100,2", // revenue — steady climb
"0,16 20,14 40,15 60,9 80,7 100,3", // ROAS — climbing with a dip
"0,19 20,17 40,12 60,10 80,5 100,1", // organic — strong ramp
"0,8 20,7 40,8 60,6 80,7 100,5", // retention — high + stable
];
const W = 560;
const H = 200;
const PAD = { l: 38, r: 16, t: 18, b: 30 };
const maxV = Math.max(...TREND.map((d) => d.v));
function pt(i: number, v: number) {
const x = PAD.l + (i / (TREND.length - 1)) * (W - PAD.l - PAD.r);
const y = PAD.t + (1 - v / maxV) * (H - PAD.t - PAD.b);
return { x, y };
}
export default function Scoreboard() {
const root = useRef<HTMLElement>(null);
const [hover, setHover] = useState<number | null>(null);
const pts = TREND.map((d, i) => pt(i, d.v));
const linePath = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
const areaPath = `${linePath} L${pts[pts.length - 1].x},${H - PAD.b} L${pts[0].x},${H - PAD.b} Z`;
// y-axis gridlines at 0 / 50% / 100% of max
const yTicks = [0, 0.5, 1].map((f) => ({
f,
y: PAD.t + (1 - f) * (H - PAD.t - PAD.b),
label: `$${(maxV * f).toFixed(0)}M`,
}));
useEffect(() => {
const el = root.current;
@ -27,36 +72,29 @@ export default function Scoreboard() {
const tl = gsap.timeline({
scrollTrigger: { trigger: ".score__chart", start: "top 82%", once: true },
});
// Baseline draws first, then bars grow up off it with a tight stagger and
// a soft landing (back ease => a hair of overshoot so they feel physical).
tl.from(".score__baseline", {
drawSVG: "0%",
duration: 0.8,
ease: "power2.out",
})
// gridlines fade, the area wipes up, the line draws itself, points pop in,
// and each metric sparkline draws — a coherent "data resolving" moment.
tl.from(".score__grid line", { autoAlpha: 0, duration: 0.5, stagger: 0.08 })
.from(".score__area", { autoAlpha: 0, yPercent: 8, duration: 0.8, ease: "power2.out" }, "-=0.2")
.from(".score__line", { drawSVG: "0%", duration: 1.4, ease: "power2.inOut" }, "-=0.7")
.from(
".score-bar",
{
scaleY: 0,
transformOrigin: "bottom",
duration: 1,
ease: "back.out(1.4)",
stagger: 0.07,
},
"-=0.45"
".score__pt",
{ scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2.2)", stagger: 0.08 },
"-=0.6"
)
.from(
".score-bar__cap",
{
scale: 0,
autoAlpha: 0,
transformOrigin: "center",
duration: 0.5,
ease: "back.out(2.4)",
stagger: 0.07,
},
"-=0.9"
".score__mlabel",
{ autoAlpha: 0, y: 6, duration: 0.4, stagger: 0.06 },
"-=0.8"
);
gsap.from(".score__spark polyline", {
drawSVG: "0%",
duration: 1,
ease: "power2.out",
stagger: 0.12,
scrollTrigger: { trigger: ".score__grid-stats", start: "top 85%", once: true },
});
}, el);
return () => ctx.revert();
@ -75,8 +113,9 @@ export default function Scoreboard() {
</h2>
</header>
<dl className="score__grid">
{metrics.map((m) => (
{/* four headline metrics — the focus; interactive tiles with sparklines */}
<dl className="score__grid score__grid-stats">
{metrics.map((m, i) => (
<div className="score__cell" key={m.label}>
<dt className="sr-only">{m.label}</dt>
<dd
@ -92,26 +131,126 @@ export default function Scoreboard() {
<p className="score__lab" aria-hidden="true">
{m.label}
</p>
<svg
className="score__spark"
viewBox="0 0 100 20"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
points={SPARKS[i]}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
))}
</dl>
{/* decorative animated bar chart */}
<div className="score__chart" aria-hidden="true">
<div className="score__bars">
{BARS.map((h, i) => (
<span key={i} className="score-bar" style={{ height: `${h}%` }}>
<span className="score-bar__cap" />
</span>
))}
</div>
<svg className="score__axis" viewBox="0 0 600 8" preserveAspectRatio="none">
<line className="score__baseline" x1="0" y1="4" x2="600" y2="4" />
</svg>
</div>
{/* meaningful labelled growth trend — replaces the decorative bars */}
<figure className="score__chart">
<figcaption className="score__chart-cap">
Client revenue generated cumulative, sample 6-month engagement
</figcaption>
<svg
className="score__trend"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Cumulative client revenue rising from $2.1M in January to $16.4M in June (sample data)."
>
<defs>
<linearGradient id="scoreLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#2563eb" />
<stop offset="55%" stopColor="#7c3aed" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
<linearGradient id="scoreArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.28" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
</defs>
{/* y gridlines + value labels */}
<g className="score__grid">
{yTicks.map((t) => (
<line key={t.f} x1={PAD.l} y1={t.y} x2={W - PAD.r} y2={t.y} />
))}
</g>
<g className="score__ylabels" aria-hidden="true">
{yTicks.map((t) => (
<text key={t.f} x={PAD.l - 8} y={t.y + 4} textAnchor="end">
{t.label}
</text>
))}
</g>
<path className="score__area" d={areaPath} fill="url(#scoreArea)" />
<path
className="score__line"
d={linePath}
fill="none"
stroke="url(#scoreLine)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* interactive data points + month labels */}
{TREND.map((d, i) => {
const p = pts[i];
const on = hover === i;
return (
<g
key={d.m}
className="score__ptg"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
tabIndex={0}
role="img"
aria-label={`${d.m}: $${d.v}M`}
>
{/* invisible larger hit area */}
<rect
x={p.x - 22}
y={PAD.t}
width={44}
height={H - PAD.t - PAD.b}
fill="transparent"
/>
<circle
className="score__pt"
cx={p.x}
cy={p.y}
r={on ? 7 : 4.5}
/>
<text className="score__mlabel" x={p.x} y={H - 10} textAnchor="middle">
{d.m}
</text>
{on && (
<g className="score__tip">
<rect
x={p.x - 26}
y={p.y - 34}
width={52}
height={22}
rx={6}
/>
<text x={p.x} y={p.y - 19} textAnchor="middle">
${d.v}M
</text>
</g>
)}
</g>
);
})}
</svg>
</figure>
{/* anchors the section + sets honest expectation; kills the empty gap
above the gradient divider */}
<p className="score__foot">Sample data your numbers, reported monthly.</p>
</div>
</section>

View file

@ -0,0 +1,260 @@
"use client";
/**
* TESTIMONIALS an interactive, auto-advancing carousel that feels substantial.
* - 6 sample testimonials, one large featured quote at a time, with a
* monogram avatar, name, role, company and a result chip.
* - Auto-advances every ~6s; pauses on hover/focus-within and when offscreen
* or the tab is hidden. Manual prev/next buttons + clickable dots.
* - A thumbnail rail of all clients (monogram avatars) doubles as navigation;
* the active one is highlighted.
* - Slides crossfade/slide with Motion (AnimatePresence) using the page's
* EASE curves; direction-aware.
* - Fully keyboard accessible: real <button>s, arrow-key support, aria-live
* announces the active slide, aria-roledescription="carousel".
* - prefers-reduced-motion: no auto-advance, instant slide swaps.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import Reveal from "./Reveal";
import { testimonials } from "../content";
import { EASE_OUT } from "./motion";
const AUTOPLAY_MS = 6000;
export default function Testimonials() {
const reduce = useReducedMotion();
const [[index, dir], setState] = useState<[number, number]>([0, 0]);
const [paused, setPaused] = useState(false);
const rootRef = useRef<HTMLElement>(null);
const inView = useRef(true);
const count = testimonials.length;
const go = useCallback(
(next: number, direction: number) => {
setState([(next + count) % count, direction]);
},
[count]
);
const next = useCallback(() => go(index + 1, 1), [go, index]);
const prev = useCallback(() => go(index - 1, -1), [go, index]);
// autoplay — paused on hover/focus, offscreen, hidden tab, reduced-motion
useEffect(() => {
if (reduce) return;
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => {
inView.current = e.isIntersecting;
},
{ threshold: 0.25 }
);
io.observe(el);
const id = window.setInterval(() => {
if (paused || !inView.current || document.hidden) return;
setState(([i]) => [(i + 1) % count, 1]);
}, AUTOPLAY_MS);
return () => {
io.disconnect();
window.clearInterval(id);
};
}, [reduce, paused, count]);
// keyboard arrows when the carousel has focus
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
}
};
const t = testimonials[index];
const variants = {
enter: (d: number) =>
reduce
? { opacity: 0 }
: { opacity: 0, x: d > 0 ? 60 : -60, filter: "blur(6px)" },
center: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: (d: number) =>
reduce
? { opacity: 0 }
: { opacity: 0, x: d > 0 ? -60 : 60, filter: "blur(6px)" },
};
return (
<section
ref={rootRef}
className="quotes frame"
aria-labelledby="quotes-h"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
>
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
In their words sample
</p>
<Reveal as="h2" variant="clip">
<span id="quotes-h" className="display sec-head__title">
The number is the point
</span>
</Reveal>
</header>
<div
className="carousel"
role="group"
aria-roledescription="carousel"
aria-label="Client testimonials"
tabIndex={0}
onKeyDown={onKeyDown}
>
<div className="carousel__stage">
{/* decorative oversized quote mark */}
<span className="carousel__mark display" aria-hidden="true">
&ldquo;
</span>
<AnimatePresence mode="wait" custom={dir} initial={false}>
<motion.figure
key={index}
className="carousel__slide"
custom={dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduce ? 0.2 : 0.55, ease: EASE_OUT }}
aria-roledescription="slide"
aria-label={`${index + 1} of ${count}`}
>
<blockquote className="carousel__quote display">
{t.quote}
</blockquote>
<figcaption className="carousel__by">
<span className="carousel__avatar">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={t.avatar}
alt=""
width={64}
height={64}
loading="lazy"
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
/>
</span>
<span className="carousel__id">
<span className="carousel__name">{t.name}</span>
<span className="carousel__role">
{t.role}, {t.company}
</span>
</span>
<span className="carousel__chip">{t.metric}</span>
</figcaption>
</motion.figure>
</AnimatePresence>
</div>
{/* controls */}
<div className="carousel__controls">
<button
type="button"
className="carousel__arrow"
onClick={prev}
aria-label="Previous testimonial"
data-cursor="Prev"
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M15 5l-7 7 7 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<ul className="carousel__dots" role="tablist" aria-label="Choose testimonial">
{testimonials.map((tt, i) => (
<li key={tt.name} role="presentation">
<button
type="button"
role="tab"
aria-selected={i === index}
aria-label={`${tt.name}, ${tt.company}`}
className={`carousel__dot ${i === index ? "is-active" : ""}`}
onClick={() => go(i, i > index ? 1 : -1)}
/>
</li>
))}
</ul>
<button
type="button"
className="carousel__arrow"
onClick={next}
aria-label="Next testimonial"
data-cursor="Next"
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M9 5l7 7-7 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
{/* avatar rail — all clients; doubles as quick nav */}
<ul className="carousel__rail" aria-hidden="true">
{testimonials.map((tt, i) => (
<li key={tt.name}>
<button
type="button"
tabIndex={-1}
className={`carousel__railitem ${i === index ? "is-active" : ""}`}
onClick={() => go(i, i > index ? 1 : -1)}
>
<span className="carousel__railmono">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={tt.avatar}
alt=""
width={44}
height={44}
loading="lazy"
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%", display: "block" }}
/>
</span>
<span className="carousel__railname">{tt.company}</span>
</button>
</li>
))}
</ul>
{/* live region for screen readers */}
<p className="sr-only" aria-live="polite">
Testimonial {index + 1} of {count}: {t.name}, {t.role} at {t.company}.
</p>
</div>
</section>
);
}

View file

@ -14,6 +14,9 @@ import { SplitText } from "gsap/SplitText";
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText);
// Shared signature ease (mirrors the CSS --ease-out token). Registered once
// here so every component can use ease:"emilOut" without re-registering.
gsap.registerEase("emilOut", (p) => 1 - Math.pow(1 - p, 3.2));
}
export { gsap, ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText };

View file

@ -57,38 +57,56 @@ export const metrics = [
] as const;
/**
* Case studies. Visuals are now CODED data charts (unique `bars` per case +
* `accent`), not the rejected repetitive raster images so no two look alike.
* Case studies. Each card now pairs a distinct SECTOR PHOTO with a data
* visualisation tailored to its story, so no two cards look alike:
* - Fashion: dual-metric viz CPA falling while ROAS climbs.
* - SaaS: a ramping demo-requests line (slow start, steep finish).
* - Clinic: monthly bookings bars converging on a target line.
* `viz` selects the chart; `series`/`series2` hold the (sample) data 0..100.
*/
export const cases = [
{
tag: "E-commerce · Fashion",
image: "/assets/case-fashion.webp",
alt: "A minimalist clothing rack of folded knitwear in a dim showroom.",
problem: "Rising ad costs were eating the margin.",
result: "34% CPA and +52% ROAS in 90 days",
how: "Meta + Google Shopping restructure.",
metricNum: "+52%",
metricLabel: "ROAS in 90 days",
bars: [28, 40, 36, 58, 72, 92],
viz: "dual",
// CPA falling (down is good) + ROAS rising — the two-line story
series: [92, 80, 74, 60, 52, 42], // CPA index, falling
series2: [38, 44, 50, 62, 78, 96], // ROAS index, rising
accent: "#8b5cf6",
},
{
tag: "B2B SaaS",
image: "/assets/case-saas.webp",
alt: "A laptop on a desk at dusk showing a glowing analytics dashboard.",
problem: "Plenty of traffic, no pipeline.",
result: "+217% qualified demo requests in 6 months",
how: "SEO + content + LinkedIn.",
metricNum: "+217%",
metricLabel: "demo requests",
bars: [18, 22, 30, 48, 70, 96],
viz: "ramp",
// a slow-then-steep demo-requests ramp
series: [12, 16, 24, 38, 64, 96],
accent: "#3b82f6",
},
{
tag: "Aesthetic clinic",
image: "/assets/case-clinic.webp",
alt: "A calm, softly-lit aesthetic clinic treatment room with a garden view.",
problem: "Empty calendar despite the ad spend.",
result: "+128 booked consultations a month",
how: "Paid + landing-page rebuild.",
metricNum: "+128",
metricLabel: "consultations / month",
bars: [24, 34, 30, 52, 66, 88],
viz: "bookings",
// monthly bookings bars climbing toward a capacity target line
series: [26, 38, 44, 58, 74, 92],
target: 85,
accent: "#10b981",
},
] as const;
@ -100,16 +118,66 @@ export const processSteps = [
{ n: "04", name: "Report", desc: "Every month: marketing tied straight to pipeline and sales." },
] as const;
/**
* Testimonials illustrative SAMPLES (consistent with the footer disclaimer).
* Structured with name / role / company so the carousel can render coded
* monogram avatars and proper figcaptions. Swap for real, attributed quotes
* before launch.
*/
export const testimonials = [
{
quote:
"We were spending $20k a month on ads with nothing to show. Six months later, marketing is our most predictable growth channel.",
by: "Sarah Lin, Head of Growth, Lumen Apparel",
name: "Sarah Lin",
role: "Head of Growth",
company: "Lumen Apparel",
metric: "+52% ROAS",
avatar: "/assets/person-1.webp",
},
{
quote:
"They talk in revenue, not impressions. First agency that moved our pipeline.",
by: "Marcus Reyes, CMO, Northpeak SaaS",
"They talk in revenue, not impressions. First agency that actually moved our pipeline instead of our vanity dashboards.",
name: "Marcus Reyes",
role: "CMO",
company: "Northpeak SaaS",
metric: "+217% demos",
avatar: "/assets/person-2.webp",
},
{
quote:
"Our calendar went from empty to fully booked. The monthly report ties every dollar of spend straight to consultations.",
name: "Dr. Amara Osei",
role: "Founder",
company: "Vista Aesthetics",
metric: "+128 booked/mo",
avatar: "/assets/person-3.webp",
},
{
quote:
"Finally an agency that pushes back when something isn't working. They killed two channels and doubled down on what sold.",
name: "Daniel Brenner",
role: "VP Marketing",
company: "Forge Commerce",
metric: "34% CPA",
avatar: "/assets/person-4.webp",
},
{
quote:
"Organic traffic used to be a black box. Now we can see exactly which content earns pipeline, and we brief around it.",
name: "Arjun Nair",
role: "Demand Gen Lead",
company: "Cobalt Systems",
metric: "+183% organic",
avatar: "/assets/person-5.webp",
},
{
quote:
"The reporting alone is worth it. Our board finally trusts the marketing number because it reconciles with sales.",
name: "Elena Rossi",
role: "CEO",
company: "Maris Group",
metric: "92% retention",
avatar: "/assets/person-6.webp",
},
] as const;

View file

@ -232,6 +232,12 @@ body::before {
font-size: var(--step-4);
margin-top: 1rem;
max-width: 16ch;
/* descender room so the clip-path reveal never crops g/y/p/q tails
(e.g. "grow your business"); compensated by a negative margin so the
rhythm below the title is unchanged */
display: block;
padding-bottom: 0.16em;
margin-bottom: -0.16em;
}
.sec-head--center .sec-head__title { margin-inline: auto; }
@ -523,6 +529,45 @@ body::before {
.mobile-menu .btn { margin-top: 1rem; }
}
/* ---------------------------------------------------------------------------
7b. FLUID WEBGL BACKGROUND (cursor-reactive)
--------------------------------------------------------------------------- */
.fluid {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none; /* never steals clicks from the content above */
}
.fluid__canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
display: block;
/* fade in over the fallback so there's no flash before the GL reveal ramp */
animation: fluidFade 0.9s var(--ease-out) both;
}
@keyframes fluidFade { from { opacity: 0; } to { opacity: 1; } }
/* static gradient stand-in: shown under reduced-motion / no-WebGL, and sits
beneath the canvas otherwise so first paint is never an empty black box */
.fluid__fallback {
position: absolute;
inset: 0;
background:
radial-gradient(60% 60% at 25% 25%, rgba(59, 130, 246, 0.28), transparent 70%),
radial-gradient(55% 55% at 80% 30%, rgba(139, 92, 246, 0.26), transparent 70%),
radial-gradient(60% 60% at 55% 90%, rgba(16, 185, 129, 0.2), transparent 70%),
var(--c-bg);
}
.fluid--cta .fluid__fallback {
background:
radial-gradient(65% 65% at 20% 20%, rgba(59, 130, 246, 0.4), transparent 70%),
radial-gradient(60% 60% at 85% 25%, rgba(139, 92, 246, 0.4), transparent 70%),
radial-gradient(70% 70% at 50% 95%, rgba(16, 185, 129, 0.3), transparent 70%),
var(--c-bg);
}
/* ---------------------------------------------------------------------------
8. HERO
--------------------------------------------------------------------------- */
@ -537,14 +582,26 @@ body::before {
--mx: 50%;
--my: 40%;
}
.hero__media { position: absolute; inset: -12% 0; z-index: 0; will-change: transform; }
.hero__video { width: 100%; height: 100%; object-fit: cover; opacity: 0.55; }
/* fluid sits at z-index:0; the video media now layers OVER it as a soft texture
(z-index:1, screen-blended at low opacity) so the cursor-reactive fluid is the
dominant background while the footage keeps a filmic grain on top */
.hero__media { position: absolute; inset: -12% 0; z-index: 1; will-change: transform; }
.hero__video {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.22;
mix-blend-mode: screen;
}
/* readability scrim kept lighter than before so the live fluid stays visible
behind the copy, while a left-weighted gradient still guarantees H1 contrast.
Sits on the media layer (z-index:1) above the fluid. */
.hero__scrim {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(7, 7, 11, 0.5), rgba(7, 7, 11, 0.82) 60%, var(--c-bg)),
linear-gradient(90deg, rgba(7, 7, 11, 0.72), transparent 62%);
linear-gradient(180deg, rgba(7, 7, 11, 0.35), rgba(7, 7, 11, 0.55) 65%, rgba(7, 7, 11, 0.8)),
linear-gradient(90deg, rgba(7, 7, 11, 0.78), rgba(7, 7, 11, 0.32) 48%, transparent 70%);
}
.hero__spotlight {
position: absolute;
@ -625,9 +682,18 @@ body::before {
max-width: 15ch;
visibility: hidden; /* revealed by GSAP; reduced-motion fallback re-shows */
}
/* SplitText line wrappers — clip so words rise from behind a mask */
.hero__line { overflow: hidden; padding-bottom: 0.06em; }
.hero__h1 .word { display: inline-block; }
/* SplitText line wrappers clip so words rise from behind a mask.
The mask MUST leave room for descenders (g, y, p, q) or "grow"/"your" get
their tails clipped. We pad the bottom of each masked line by .2em and pull
the next line up by the same amount with a negative margin so the visual
line spacing is unchanged (no layout shift). The words themselves also get
the same bottom padding so the mask never crops the glyph. */
.hero__line {
overflow: hidden;
padding-bottom: 0.2em;
margin-bottom: -0.2em;
}
.hero__h1 .word { display: inline-block; padding-bottom: 0.2em; }
/* FOUC guard: hide stagger items until GSAP takes over (re-shown by JS, or by
the reduced-motion fallback below). */
.hero__stagger { opacity: 0; }
@ -968,16 +1034,46 @@ body::before {
/* ---------------------------------------------------------------------------
13. SCOREBOARD (metrics)
--------------------------------------------------------------------------- */
/* trim the bottom padding ~40% so there's no large empty gap before the
gradient divider; the sub-label anchors the section */
.score.frame { padding-bottom: clamp(2.7rem, 5.4vw, 5.4rem); }
.score__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: clamp(1rem, 3vw, 2.5rem);
gap: clamp(0.8rem, 2vw, 1.5rem);
text-align: center;
}
.score__num { font-size: var(--step-4); line-height: 1; font-variant-numeric: tabular-nums; }
/* interactive metric tiles — the focus of the section */
.score__cell {
position: relative;
padding: clamp(1.2rem, 2.5vw, 1.8rem) clamp(0.8rem, 1.5vw, 1.2rem);
border-radius: 14px;
border: 1px solid var(--paper-line);
background: rgba(255, 255, 255, 0.35);
transition:
transform var(--t-mid) var(--ease-out),
box-shadow var(--t-slow) var(--ease-out),
border-color var(--t-mid) var(--ease-out);
}
.score__cell::after {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 3px;
border-radius: 14px 14px 0 0;
background: var(--grad-brand);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--t-mid) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.score__cell:hover {
transform: translateY(-4px);
border-color: var(--paper-ink);
box-shadow: 0 24px 50px -28px rgba(20, 20, 26, 0.4);
}
.score__cell:hover::after { transform: scaleX(1); }
.score__cell:hover .score__spark { color: var(--violet-600); opacity: 1; }
}
.score__num { font-size: var(--step-3); line-height: 1; font-variant-numeric: tabular-nums; }
.score__num.is-accent {
color: transparent;
background: var(--grad-text-ink); /* dark gradient => legible on light paper */
@ -985,13 +1081,55 @@ body::before {
background-clip: text;
}
.score__lab {
margin-top: 0.7rem;
margin-top: 0.6rem;
font-size: var(--step--1);
color: var(--paper-dim);
max-width: 18ch;
margin-inline: auto;
}
.score__chart { margin-top: clamp(2.5rem, 5vw, 4rem); max-width: 600px; margin-inline: auto; }
.score__spark {
width: 70%;
height: 18px;
margin: 0.7rem auto 0;
color: #8a86b0;
opacity: 0.7;
transition: color var(--t-mid) var(--ease-out), opacity var(--t-mid) var(--ease-out);
}
/* meaningful labelled growth trend chart */
.score__chart { margin-top: clamp(2.5rem, 5vw, 4rem); max-width: 640px; margin-inline: auto; }
.score__chart-cap {
text-align: center;
font-family: var(--font-mono);
font-size: var(--step--1);
letter-spacing: 0.04em;
color: var(--paper-dim);
margin-bottom: 1rem;
}
.score__trend { width: 100%; height: auto; }
.score__grid line { stroke: var(--paper-line); stroke-width: 1; }
.score__ylabels text,
.score__mlabel {
font-family: var(--font-mono);
font-size: 11px;
fill: #6b675f; /* ~4.9:1 on paper */
}
.score__pt {
fill: var(--violet-600);
stroke: var(--paper);
stroke-width: 2;
transition: r 0.18s var(--ease-out);
}
.score__ptg { cursor: pointer; outline: none; }
.score__ptg:focus-visible .score__pt { r: 7; }
.score__ptg:focus-visible { outline: none; }
.score__tip rect { fill: var(--paper-ink); }
.score__tip text {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
fill: var(--paper);
}
.score__foot {
margin-top: clamp(1.4rem, 3vw, 2.2rem);
text-align: center;
@ -1000,27 +1138,8 @@ body::before {
letter-spacing: 0.02em;
color: #6b675f; /* ~4.9:1 on paper */
}
.score__bars { display: flex; align-items: flex-end; gap: clamp(0.6rem, 2vw, 1.4rem); height: 130px; }
.score-bar {
position: relative;
flex: 1;
border-radius: 6px 6px 0 0;
background: var(--grad-brand);
opacity: 0.9;
}
.score-bar__cap {
position: absolute;
top: -5px; left: 50%;
width: 8px; height: 8px;
margin-left: -4px;
border-radius: 50%;
background: var(--emerald);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.7);
}
.score__axis { width: 100%; height: 8px; margin-top: 4px; }
.score__baseline { stroke: var(--paper-ink); stroke-width: 2; }
@media (max-width: 720px) {
.score__grid { grid-template-columns: repeat(2, 1fr); gap: 2rem 1rem; }
.score__grid { grid-template-columns: repeat(2, 1fr); gap: 0.8rem; }
}
/* ---------------------------------------------------------------------------
@ -1038,7 +1157,8 @@ body::before {
.case__inner {
position: relative;
height: 100%;
padding: clamp(1.4rem, 2.5vw, 2rem);
display: flex;
flex-direction: column;
border-radius: var(--radius);
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line);
@ -1055,42 +1175,89 @@ body::before {
transform: translateZ(40px);
}
}
.case__glare { position: absolute; inset: 0; pointer-events: none; }
.case__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
/* sector photo — leads the card with reserved aspect ratio (no CLS) */
.case__photo {
position: relative;
aspect-ratio: 16 / 11;
overflow: hidden;
border-bottom: 1px solid var(--c-line);
}
.case__photo-inner { position: absolute; inset: -6%; }
.case__img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--t-slow) var(--ease-out);
}
/* gradient veil so the tag/number stay legible on any photo */
.case__photo::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(7, 7, 11, 0.55), transparent 40%, rgba(7, 7, 11, 0.7));
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.case:hover .case__img { transform: scale(1.07); }
}
.case__photo-tag {
position: absolute;
top: 0.9rem; left: 0.9rem;
z-index: 1;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: rgba(7, 7, 11, 0.55);
backdrop-filter: blur(6px);
border: 1px solid var(--c-line-strong);
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--c-text);
}
.case__no { color: var(--case-accent); font-weight: 600; }
.case__tag { color: var(--c-text-dim); text-align: right; }
.case__photo-no {
position: absolute;
bottom: 0.7rem; right: 0.9rem;
z-index: 1;
font-family: var(--font-mono);
font-weight: 700;
font-size: var(--step-2);
color: var(--case-accent);
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
/* per-case data visual */
.case__viz {
margin: 1.4rem 0 1.6rem;
padding: 1rem;
margin: clamp(1.1rem, 2vw, 1.4rem) clamp(1.4rem, 2.5vw, 2rem) 0;
padding: 0.9rem;
border-radius: 12px;
background: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--c-line);
}
.case__bars { display: flex; align-items: flex-end; gap: 8px; height: 84px; }
.case__bar {
flex: 1;
height: var(--h);
border-radius: 4px 4px 0 0;
background: linear-gradient(var(--case-accent), color-mix(in srgb, var(--case-accent) 30%, transparent));
.case__chart { width: 100%; height: auto; display: block; }
.case__chart text {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.02em;
}
.case__legend { color: var(--c-text-faint); }
.case__targetlab { font-size: 8px; }
.case__bar2 {
transform: scaleY(0);
transform-origin: bottom;
animation: barGrow 0.85s var(--ease-out) forwards;
transform-box: fill-box;
animation: caseBarGrow 0.7s var(--ease-out) forwards;
animation-delay: var(--d);
}
@keyframes barGrow {
@keyframes caseBarGrow {
0% { transform: scaleY(0); }
72% { transform: scaleY(1.04); }
100% { transform: scaleY(1); }
}
.case__spark { width: 100%; height: 26px; margin-top: 8px; }
.case__body { position: relative; z-index: 1; }
.case__body {
position: relative;
z-index: 1;
padding: clamp(1.1rem, 2vw, 1.4rem) clamp(1.4rem, 2.5vw, 2rem) 0;
}
.case__problem {
display: flex;
flex-direction: column;
@ -1108,7 +1275,9 @@ body::before {
.case__result { font-size: var(--step-1); margin: 0.7rem 0 0.4rem; }
.case__how { font-size: var(--step--1); color: var(--c-text-dim); }
.case__metric {
margin-top: 1.4rem;
margin-top: auto; /* pin to the bottom so cards align regardless of copy len */
margin-inline: clamp(1.4rem, 2.5vw, 2rem);
margin-bottom: clamp(1.4rem, 2.5vw, 2rem);
padding-top: 1.2rem;
border-top: 1px solid var(--c-line);
display: flex;
@ -1126,8 +1295,14 @@ body::before {
/* active-step accent, set by JS as the scroll advances */
--loop-accent: #3b82f6;
}
/* loop-pin is a flex column-centerer; loop__inner ALSO carries .wrap, so it
must NOT override .wrap's constrained width (that was forcing the whole loop
title, kicker and dark card to full-bleed/touch the left edge). We let
.wrap own the width + auto side margins; in this flex context margin-inline
auto keeps it centered with equal left/right gutters like every other
section. min-width:0 lets the inner grid shrink correctly. */
.loop-pin { min-height: 100svh; display: flex; align-items: center; }
.loop__inner { width: 100%; }
.loop__inner { min-width: 0; }
.loop__stage {
display: grid;
grid-template-columns: 1fr 1fr;
@ -1393,68 +1568,185 @@ body::before {
}
/* ---------------------------------------------------------------------------
16. TESTIMONIALS
16. TESTIMONIALS interactive carousel
--------------------------------------------------------------------------- */
.quotes__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(1.5rem, 3vw, 2.5rem);
}
@media (max-width: 760px) { .quotes__grid { grid-template-columns: 1fr; } }
.quote {
.carousel {
position: relative;
padding: clamp(1.6rem, 3vw, 2.4rem);
border-radius: var(--radius);
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line);
background:
radial-gradient(120% 130% at 0% 0%, rgba(59, 130, 246, 0.1), transparent 55%),
radial-gradient(120% 130% at 100% 100%, rgba(16, 185, 129, 0.08), transparent 55%),
linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
padding: clamp(1.8rem, 4vw, 3.2rem);
overflow: hidden;
transition:
border-color var(--t-mid) var(--ease-out),
box-shadow var(--t-slow) var(--ease-out),
transform var(--t-mid) var(--ease-out);
}
.quote::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(120% 80% at 100% 0%, rgba(139, 92, 246, 0.12), transparent 60%);
opacity: 0;
transition: opacity var(--t-slow) var(--ease-out);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.quote:hover {
border-color: var(--c-line-strong);
box-shadow: 0 24px 60px -28px rgba(139, 92, 246, 0.5);
transform: translateY(-4px);
}
.quote:hover::before { opacity: 1; }
}
.quote__mark {
.carousel:focus-visible { outline-offset: 6px; }
.carousel__stage {
position: relative;
font-size: 4rem;
line-height: 0.5;
min-height: clamp(220px, 30vw, 280px);
display: grid;
align-items: center;
}
.carousel__mark {
position: absolute;
top: -0.35em;
left: -0.05em;
font-size: clamp(6rem, 16vw, 11rem);
line-height: 1;
color: var(--violet);
opacity: 0.5;
transition: opacity var(--t-mid) var(--ease-out);
opacity: 0.16;
pointer-events: none;
user-select: none;
}
@media (hover: hover) and (pointer: fine) {
.quote:hover .quote__mark { opacity: 0.85; }
.carousel__slide {
grid-area: 1 / 1;
max-width: 60ch;
}
.quote__text { position: relative; font-size: var(--step-1); font-weight: 500; line-height: 1.35; letter-spacing: -0.01em; margin-top: 1rem; }
.quote__by {
.carousel__quote {
font-size: var(--step-2);
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.015em;
}
.carousel__by {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 1.4rem;
gap: 1rem;
margin-top: clamp(1.6rem, 3vw, 2.4rem);
flex-wrap: wrap;
}
.carousel__avatar {
flex-shrink: 0;
display: grid;
place-items: center;
width: 52px; height: 52px;
border-radius: 50%;
font-family: var(--font-mono);
font-weight: 600;
font-size: var(--step-0);
color: #fff;
background: var(--grad-btn);
box-shadow: 0 6px 20px -8px rgba(139, 92, 246, 0.6);
}
.carousel__id { display: flex; flex-direction: column; gap: 0.1rem; margin-right: auto; }
.carousel__name { font-weight: 700; font-size: var(--step-0); }
.carousel__role {
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--c-text-faint);
}
.quote__rule { width: 24px; height: 1px; background: var(--violet); }
.partners { margin-top: clamp(2.5rem, 5vw, 4rem); display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; }
.partners__list { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.partners__list li { font-weight: 700; color: var(--c-text-dim); }
.carousel__chip {
flex-shrink: 0;
padding: 0.4rem 0.9rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--emerald) 45%, transparent);
background: rgba(16, 185, 129, 0.1);
font-family: var(--font-mono);
font-size: var(--step--1);
font-weight: 600;
color: #6ee7b7; /* emerald-300 — ~7:1 on the dark card */
}
.carousel__controls {
display: flex;
align-items: center;
justify-content: center;
gap: 1.2rem;
margin-top: clamp(1.6rem, 3vw, 2.2rem);
padding-top: clamp(1.2rem, 2.5vw, 1.8rem);
border-top: 1px solid var(--c-line);
}
.carousel__arrow {
display: grid;
place-items: center;
width: 44px; height: 44px;
border-radius: 50%;
color: var(--c-text);
border: 1px solid var(--c-line-strong);
background: rgba(255, 255, 255, 0.02);
transition:
border-color var(--t-mid) var(--ease-out),
background var(--t-mid) var(--ease-out),
transform var(--t-press) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.carousel__arrow:hover {
border-color: var(--violet);
background: rgba(139, 92, 246, 0.12);
transform: translateY(-2px);
}
}
.carousel__arrow:active { transform: scale(0.92); }
.carousel__dots { display: flex; align-items: center; gap: 0.6rem; }
.carousel__dot {
width: 9px; height: 9px;
border-radius: 50%;
background: var(--c-line-strong);
transition:
background var(--t-mid) var(--ease-out),
transform var(--t-mid) var(--ease-out),
box-shadow var(--t-mid) var(--ease-out);
}
.carousel__dot:hover { background: var(--c-text-faint); transform: scale(1.2); }
.carousel__dot.is-active {
background: var(--violet);
transform: scale(1.35);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.7);
}
/* avatar rail — quick nav across all clients */
.carousel__rail {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: clamp(1.6rem, 3vw, 2.2rem);
}
.carousel__railitem {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.8rem 0.45rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--c-line);
background: rgba(255, 255, 255, 0.02);
color: var(--c-text-dim);
font-family: var(--font-mono);
font-size: var(--step--1);
transition:
border-color var(--t-mid) var(--ease-out),
color var(--t-mid) var(--ease-out),
background var(--t-mid) var(--ease-out),
transform var(--t-fast) var(--ease-out);
}
.carousel__railmono {
display: grid;
place-items: center;
width: 28px; height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
font-size: 0.7rem;
font-weight: 600;
color: var(--c-text);
}
@media (hover: hover) and (pointer: fine) {
.carousel__railitem:hover {
border-color: var(--c-line-strong);
color: var(--c-text);
transform: translateY(-2px);
}
}
.carousel__railitem.is-active {
border-color: var(--violet);
color: var(--c-text);
background: rgba(139, 92, 246, 0.1);
}
.carousel__railitem.is-active .carousel__railmono {
background: var(--grad-btn);
color: #fff;
}
@media (max-width: 560px) {
.carousel__railname { display: none; }
.carousel__railitem { padding: 0.4rem; }
}
/* ---------------------------------------------------------------------------
17. FAQ
@ -1496,18 +1788,88 @@ body::before {
.faq__a { padding-bottom: clamp(1.1rem, 2.5vw, 1.6rem); max-width: 64ch; color: var(--paper-dim); font-size: var(--step-0); }
/* ---------------------------------------------------------------------------
18. FINAL CTA
18. FINAL CTA dramatic closing moment over a cursor-reactive WebGL field
--------------------------------------------------------------------------- */
.final { text-align: center; }
.final {
position: relative;
text-align: center;
overflow: hidden;
/* taller so the big interactive field has room to breathe */
padding-block: clamp(6rem, 12vw, 11rem);
isolation: isolate;
}
/* top/bottom feather so the section blends into the dark canvas above + below */
.final::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(180deg, var(--c-bg), transparent 16%, transparent 84%, var(--c-bg));
}
/* readability backdrop directly behind the copy: a soft, blurred dark vignette
centred on the text so the kinetic headline + gradient word ALWAYS clear
contrast over the bright cursor-reactive field, while the field stays vivid
around the edges. */
.final__wrap {
width: min(100% - var(--gutter) * 2, 760px);
position: relative;
z-index: 2;
width: min(100% - var(--gutter) * 2, 820px);
margin-inline: auto;
display: flex;
flex-direction: column;
align-items: center;
padding: clamp(2rem, 5vw, 4rem) clamp(1.5rem, 4vw, 3rem);
}
.final__wrap::before {
content: "";
position: absolute;
inset: -8% -4%;
z-index: -1;
pointer-events: none;
background: radial-gradient(
62% 60% at 50% 50%,
rgba(7, 7, 11, 0.82),
rgba(7, 7, 11, 0.5) 55%,
transparent 80%
);
backdrop-filter: blur(2px);
}
.final__h {
font-size: var(--step-5);
margin: 1.2rem 0;
/* "grow?" descender room — the gradient-clipped word must not crop its tail */
padding-bottom: 0.16em;
visibility: visible;
}
/* kinetic-type line masks (GSAP SplitText) — descender room like the hero */
.final__line { overflow: hidden; padding-bottom: 0.16em; margin-bottom: -0.16em; }
.final__char { display: inline-block; will-change: transform; }
/* gradient word ("grow?") repainted per-glyph SplitText breaks the parent
.grad clip, so each char carries the gradient itself */
.final__char--grad {
background-image: var(--grad-text);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.final__sub {
font-size: var(--step-1);
color: var(--c-text);
max-width: 40ch;
text-shadow: 0 2px 16px rgba(7, 7, 11, 0.6);
}
.final__h { font-size: var(--step-5); margin: 1.2rem 0; }
.final__sub { font-size: var(--step-1); color: var(--c-text-dim); max-width: 40ch; }
.final__cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; margin-top: 2.2rem; }
/* amplified primary button for the closing CTA */
.btn--xl {
padding: 1.15rem 2.2rem;
font-size: var(--step-1);
}
.btn--accent.btn--xl { box-shadow: 0 14px 50px -10px rgba(139, 92, 246, 0.75); }
@media (hover: hover) and (pointer: fine) {
.btn--accent.btn--xl:hover { box-shadow: 0 22px 64px -10px rgba(139, 92, 246, 0.9); }
}
/* ---------------------------------------------------------------------------
19. FOOTER
@ -1541,7 +1903,11 @@ body::before {
}
.hero__h1 { visibility: visible !important; }
.hero__stagger { opacity: 1 !important; }
.case__bar { transform: scaleY(1); animation: none; }
/* final CTA kinetic headline: GSAP SplitText never runs, so keep it visible */
.final__h { visibility: visible !important; opacity: 1 !important; }
/* case viz: bars/lines render fully, no grow/draw */
.case__bar2 { transform: scaleY(1) !important; animation: none !important; }
.case__img { transform: none !important; }
.hero__video { display: none; } /* poster image only */
/* keep decorative loops fully still, not snapping each 0.001ms */
.hero__live,

View file

@ -1,9 +1,7 @@
import Link from "next/link";
import {
SITE,
services,
cases,
testimonials,
} from "./content";
import SiteHeader from "./components/SiteHeader";
@ -13,9 +11,11 @@ import Reveal, { RevealItem } from "./components/Reveal";
import Scoreboard from "./components/Scoreboard";
import CaseCard, { type CaseData } from "./components/CaseCard";
import ProcessLoop from "./components/ProcessLoop";
import Testimonials from "./components/Testimonials";
import Faq from "./components/Faq";
import Magnetic from "./components/Magnetic";
import SectionDivider from "./components/SectionDivider";
import FinalCTA from "./components/FinalCTA";
const TAPE = [
"Revenue, not vanity metrics",
@ -259,41 +259,8 @@ export default function Page() {
{/* ===== PROCESS — pinned scroll + morphing SVG ===== */}
<ProcessLoop />
{/* ===== TESTIMONIALS ===== */}
<section className="quotes frame" aria-label="What clients say">
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
In their words sample
</p>
<Reveal as="h2" variant="clip">
<span className="display sec-head__title">
The number is the point
</span>
</Reveal>
</header>
</div>
<div className="wrap quotes__grid">
{testimonials.map((t, i) => (
<Reveal as="figure" className="quote" key={i} delay={i * 120}>
<span className="quote__mark display" aria-hidden="true">&ldquo;</span>
<blockquote className="quote__text display">{t.quote}</blockquote>
<figcaption className="quote__by">
<span className="quote__rule" aria-hidden="true" />
{t.by}
</figcaption>
</Reveal>
))}
</div>
<div className="wrap partners">
<p className="kicker">Partners</p>
<ul className="partners__list">
<li>Google Partner</li>
<li>Meta Business Partner</li>
</ul>
</div>
</section>
{/* ===== TESTIMONIALS — interactive auto-advancing carousel ===== */}
<Testimonials />
{/* ===== FAQ ===== */}
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
@ -313,34 +280,8 @@ export default function Page() {
</div>
</section>
{/* ===== FINAL CTA ===== */}
<section className="final frame" aria-labelledby="final-h">
<div className="wrap final__wrap">
<p className="kicker">
<span className="kicker__dot" />
The bottom line
</p>
<Reveal as="header">
<h2 id="final-h" className="display final__h">
Ready to <span className="grad">grow?</span>
</h2>
</Reveal>
<p className="final__sub">
No long contracts. No vanity reports. Marketing you can measure in
sales.
</p>
<div className="final__cta">
<Magnetic strength={0.4}>
<Link href={SITE.booking} className="btn btn--accent" 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>
{/* ===== FINAL CTA — dramatic cursor-reactive WebGL + kinetic type ===== */}
<FinalCTA />
</main>
{/* ===== FOOTER ===== */}

BIN
audit2/hero-after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
public/assets/person-1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
public/assets/person-2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
public/assets/person-3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/assets/person-4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
public/assets/person-5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
public/assets/person-6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long