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"; "use client";
/** /**
* CASE CARD replaces the rejected repetitive photos with a coded, animated * CASE CARD distinct per case, photo-led with a tailored data viz.
* data visual unique per case: * - Each card leads with its real SECTOR PHOTO (next/image -> WebP/AVIF, lazy,
* - 3D tilt that tracks the pointer via a SOFT spring (momentum => the card * reserved aspect-ratio so no CLS). The image scales + parallax-drifts toward
* floats and settles, never snaps). Transform-only. * the pointer on hover.
* - A small lift + accent shadow on hover; a press dip (scale 0.985) so the * - Below the photo, a data visual UNIQUE to the case story:
* whole card shares the page's tactile press language. * dual -> CPA-down + ROAS-up twin lines (fashion)
* - An animated bars + sparkline "result chart" (CSS + GSAP DrawSVG on the * ramp -> a slow-then-steep demo-requests line (SaaS)
* spark) growing into view no two cards look alike. * bookings -> monthly booking bars climbing toward a capacity target (clinic)
* - A glare/sheen that follows the cursor across the surface. * The viz animates in on view (GSAP DrawSVG / CSS bar grow); no two alike.
* - The chart panel gets a clip-path inset() wipe on first view (premium). * - 3D tilt that tracks the pointer via a soft spring; lift + accent shadow on
* Reduced-motion / touch: flat card, bars still grow on view via CSS. * hover; press dip. Transform-only.
* Reduced-motion / touch: flat card, static image, viz renders fully drawn.
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import Image from "next/image";
import { import {
motion, motion,
useMotionValue, useMotionValue,
@ -26,15 +28,141 @@ import { SPRING, EASE_OUT } from "./motion";
export type CaseData = { export type CaseData = {
tag: string; tag: string;
image: string;
alt: string;
problem: string; problem: string;
result: string; result: string;
how: string; how: string;
metricNum: string; metricNum: string;
metricLabel: string; metricLabel: string;
bars: number[]; // 0..100 heights — unique per case viz: "dual" | "ramp" | "bookings";
accent: string; // brand accent for this card 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({ export default function CaseCard({
data, data,
index, index,
@ -46,13 +174,11 @@ export default function CaseCard({
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const mx = useMotionValue(0.5); const mx = useMotionValue(0.5);
const my = useMotionValue(0.5); const my = useMotionValue(0.5);
const rx = useSpring(useTransform(my, [0, 1], [6, -6]), SPRING.tilt); const rx = useSpring(useTransform(my, [0, 1], [5, -5]), SPRING.tilt);
const ry = useSpring(useTransform(mx, [0, 1], [-8, 8]), SPRING.tilt); const ry = useSpring(useTransform(mx, [0, 1], [-6, 6]), SPRING.tilt);
const glare = useTransform( // image parallax — drifts opposite the tilt for depth
[mx, my], const imgX = useSpring(useTransform(mx, [0, 1], [10, -10]), SPRING.tilt);
([gx, gy]: number[]) => const imgY = useSpring(useTransform(my, [0, 1], [10, -10]), SPRING.tilt);
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.16), transparent 45%)`
);
const onMove = (e: React.PointerEvent) => { const onMove = (e: React.PointerEvent) => {
if (reduce || e.pointerType !== "mouse") return; if (reduce || e.pointerType !== "mouse") return;
@ -67,18 +193,27 @@ export default function CaseCard({
my.set(0.5); 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(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el || reduce) return; if (!el || reduce) return;
const ctx = gsap.context(() => { 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%", drawSVG: "0%",
duration: 1.1, duration: 1.1,
ease: "power2.out", ease: "power2.out",
stagger: 0.12,
scrollTrigger: { trigger: el, start: "top 82%", once: true }, scrollTrigger: { trigger: el, start: "top 82%", once: true },
delay: 0.15 + index * 0.05, 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); }, el);
return () => ctx.revert(); return () => ctx.revert();
}, [reduce, index]); }, [reduce, index]);
@ -104,42 +239,32 @@ export default function CaseCard({
className="case__inner" className="case__inner"
style={{ ["--case-accent" as string]: data.accent }} style={{ ["--case-accent" as string]: data.accent }}
> >
<div className="case__head"> {/* sector photo — leads the card, parallax/scale on hover */}
<span className="case__no">{String(index + 1).padStart(2, "0")}</span> <div className="case__photo">
<span className="case__tag">{data.tag}</span> <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> </div>
{/* coded data visual — unique bars per case, clip-wiped on first view */} {/* per-case data visual */}
<motion.div <div className="case__viz" aria-hidden="true">
className="case__viz" <CaseViz data={data} />
aria-hidden="true" </div>
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>
<div className="case__body"> <div className="case__body">
<p className="case__problem"> <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-num display">{data.metricNum}</span>
<span className="case__metric-lab">{data.metricLabel}</span> <span className="case__metric-lab">{data.metricLabel}</span>
</div> </div>
{!reduce && (
<motion.span
className="case__glare"
aria-hidden="true"
style={{ background: glare }}
/>
)}
</div> </div>
</motion.article> </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 Link from "next/link";
import { gsap, SplitText } from "./gsap"; import { gsap, SplitText } from "./gsap";
import Magnetic from "./Magnetic"; import Magnetic from "./Magnetic";
import FluidBackground from "./FluidBackground";
import { SITE } from "../content"; import { SITE } from "../content";
/* Custom GSAP eases that mirror the CSS tokens (registered once, idempotent). */ /* "emilOut" ease is registered once in ./gsap (shared across components). */
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);
});
export default function Hero() { export default function Hero() {
const root = useRef<HTMLDivElement>(null); const root = useRef<HTMLDivElement>(null);
@ -193,7 +190,12 @@ export default function Hero() {
aria-labelledby="hero-h1" aria-labelledby="hero-h1"
onPointerMove={onPointerMove} 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"> <div className="hero__media" aria-hidden="true">
<video <video
ref={videoRef} ref={videoRef}

View file

@ -20,16 +20,22 @@ import { useEffect, useRef } from "react";
import { gsap } from "./gsap"; import { gsap } from "./gsap";
import { processSteps } from "../content"; 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 = [ const SHAPES = [
// Audit — magnifying glass // Audit — magnifying glass (lens ring + handle)
"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", "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 — connected route / nodes // Plan — target / bullseye (outer ring + mid ring + center dot)
"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", "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 — lightning bolt // Execute — rocket (nose, body, fins) rising
"M55 14 26 58h20l-6 30 32-46H50l9-28Z", "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 — bar chart trending up // Report — line chart trending up (axes + plotted line + nodes)
"M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14", "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"]; const ACCENTS = ["#3b82f6", "#8b5cf6", "#10b981", "#7c3aed"];
@ -45,6 +51,7 @@ export default function ProcessLoop() {
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path"); const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
const morphGlow = el.querySelector<SVGPathElement>(".loop-glyph__glow");
if (!morph) return; if (!morph) return;
const steps = gsap.utils.toArray<HTMLElement>(".loop-step"); const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
const panels = gsap.utils.toArray<HTMLElement>(".loop-panel"); const panels = gsap.utils.toArray<HTMLElement>(".loop-panel");
@ -84,10 +91,12 @@ export default function ProcessLoop() {
SHAPES.forEach((shape, i) => { SHAPES.forEach((shape, i) => {
if (i === 0) return; // start shape already in markup if (i === 0) return; // start shape already in markup
const at = i - 1; const at = i - 1;
// morph the glyph + a counter-rotation so it tumbles as it transforms, // morph the glyph (+ its glow clone in sync) + a gentle counter-rotation
// and grow the rail fill (+ a node that rides the fill edge). // so it tumbles as it transforms, and grow the rail fill.
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at) tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at);
.to(".loop-glyph", { rotate: i * 5, 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( .to(
".loop-rail__fill", ".loop-rail__fill",
{ scaleY: i / (total - 1), duration: 1, ease: "none" }, { 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" aria-hidden="true">
<div className="loop-card__glow" /> <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"> <div className="loop-glyph">
<svg viewBox="0 0 100 100"> <svg viewBox="0 0 100 100">
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
<defs> <defs>
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" /> <stop offset="0%" stopColor="#60a5fa" />
<stop offset="50%" stopColor="#8b5cf6" /> <stop offset="50%" stopColor="#a78bfa" />
<stop offset="100%" stopColor="#10b981" /> <stop offset="100%" stopColor="#34d399" />
</linearGradient> </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> </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> </svg>
</div> </div>

View file

@ -2,20 +2,65 @@
/** /**
* METRICS scoreboard. * METRICS scoreboard.
* - CountUp numbers animate on view (Motion). * - The four headline metrics count up on view (Motion) and are now the focus:
* - A second ANIMATED-SVG moment: a bar chart whose bars DRAW/grow + a baseline * each cell is an interactive tile with a hover lift, an accent edge that
* that draws itself (GSAP DrawSVG) when the section scrolls in. * wipes in, and a tiny per-metric sparkline that animates.
* Reduced-motion: numbers jump to final, bars render at full height (CSS). * - 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 { gsap } from "./gsap";
import CountUp from "./CountUp"; import CountUp from "./CountUp";
import { metrics } from "../content"; 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() { export default function Scoreboard() {
const root = useRef<HTMLElement>(null); 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(() => { useEffect(() => {
const el = root.current; const el = root.current;
@ -27,36 +72,29 @@ export default function Scoreboard() {
const tl = gsap.timeline({ const tl = gsap.timeline({
scrollTrigger: { trigger: ".score__chart", start: "top 82%", once: true }, scrollTrigger: { trigger: ".score__chart", start: "top 82%", once: true },
}); });
// Baseline draws first, then bars grow up off it with a tight stagger and // gridlines fade, the area wipes up, the line draws itself, points pop in,
// a soft landing (back ease => a hair of overshoot so they feel physical). // and each metric sparkline draws — a coherent "data resolving" moment.
tl.from(".score__baseline", { tl.from(".score__grid line", { autoAlpha: 0, duration: 0.5, stagger: 0.08 })
drawSVG: "0%", .from(".score__area", { autoAlpha: 0, yPercent: 8, duration: 0.8, ease: "power2.out" }, "-=0.2")
duration: 0.8, .from(".score__line", { drawSVG: "0%", duration: 1.4, ease: "power2.inOut" }, "-=0.7")
ease: "power2.out",
})
.from( .from(
".score-bar", ".score__pt",
{ { scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2.2)", stagger: 0.08 },
scaleY: 0, "-=0.6"
transformOrigin: "bottom",
duration: 1,
ease: "back.out(1.4)",
stagger: 0.07,
},
"-=0.45"
) )
.from( .from(
".score-bar__cap", ".score__mlabel",
{ { autoAlpha: 0, y: 6, duration: 0.4, stagger: 0.06 },
scale: 0, "-=0.8"
autoAlpha: 0,
transformOrigin: "center",
duration: 0.5,
ease: "back.out(2.4)",
stagger: 0.07,
},
"-=0.9"
); );
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); }, el);
return () => ctx.revert(); return () => ctx.revert();
@ -75,8 +113,9 @@ export default function Scoreboard() {
</h2> </h2>
</header> </header>
<dl className="score__grid"> {/* four headline metrics — the focus; interactive tiles with sparklines */}
{metrics.map((m) => ( <dl className="score__grid score__grid-stats">
{metrics.map((m, i) => (
<div className="score__cell" key={m.label}> <div className="score__cell" key={m.label}>
<dt className="sr-only">{m.label}</dt> <dt className="sr-only">{m.label}</dt>
<dd <dd
@ -92,26 +131,126 @@ export default function Scoreboard() {
<p className="score__lab" aria-hidden="true"> <p className="score__lab" aria-hidden="true">
{m.label} {m.label}
</p> </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> </div>
))} ))}
</dl> </dl>
{/* decorative animated bar chart */} {/* meaningful labelled growth trend — replaces the decorative bars */}
<div className="score__chart" aria-hidden="true"> <figure className="score__chart">
<div className="score__bars"> <figcaption className="score__chart-cap">
{BARS.map((h, i) => ( Client revenue generated cumulative, sample 6-month engagement
<span key={i} className="score-bar" style={{ height: `${h}%` }}> </figcaption>
<span className="score-bar__cap" /> <svg
</span> className="score__trend"
))} viewBox={`0 0 ${W} ${H}`}
</div> role="img"
<svg className="score__axis" viewBox="0 0 600 8" preserveAspectRatio="none"> aria-label="Cumulative client revenue rising from $2.1M in January to $16.4M in June (sample data)."
<line className="score__baseline" x1="0" y1="4" x2="600" y2="4" /> >
</svg> <defs>
</div> <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> <p className="score__foot">Sample data your numbers, reported monthly.</p>
</div> </div>
</section> </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") { if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText); 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 }; export { gsap, ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText };

View file

@ -57,38 +57,56 @@ export const metrics = [
] as const; ] as const;
/** /**
* Case studies. Visuals are now CODED data charts (unique `bars` per case + * Case studies. Each card now pairs a distinct SECTOR PHOTO with a data
* `accent`), not the rejected repetitive raster images so no two look alike. * 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 = [ export const cases = [
{ {
tag: "E-commerce · Fashion", 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.", problem: "Rising ad costs were eating the margin.",
result: "34% CPA and +52% ROAS in 90 days", result: "34% CPA and +52% ROAS in 90 days",
how: "Meta + Google Shopping restructure.", how: "Meta + Google Shopping restructure.",
metricNum: "+52%", metricNum: "+52%",
metricLabel: "ROAS in 90 days", 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", accent: "#8b5cf6",
}, },
{ {
tag: "B2B SaaS", 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.", problem: "Plenty of traffic, no pipeline.",
result: "+217% qualified demo requests in 6 months", result: "+217% qualified demo requests in 6 months",
how: "SEO + content + LinkedIn.", how: "SEO + content + LinkedIn.",
metricNum: "+217%", metricNum: "+217%",
metricLabel: "demo requests", 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", accent: "#3b82f6",
}, },
{ {
tag: "Aesthetic clinic", 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.", problem: "Empty calendar despite the ad spend.",
result: "+128 booked consultations a month", result: "+128 booked consultations a month",
how: "Paid + landing-page rebuild.", how: "Paid + landing-page rebuild.",
metricNum: "+128", metricNum: "+128",
metricLabel: "consultations / month", 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", accent: "#10b981",
}, },
] as const; ] as const;
@ -100,16 +118,66 @@ export const processSteps = [
{ n: "04", name: "Report", desc: "Every month: marketing tied straight to pipeline and sales." }, { n: "04", name: "Report", desc: "Every month: marketing tied straight to pipeline and sales." },
] as const; ] 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 = [ export const testimonials = [
{ {
quote: quote:
"We were spending $20k a month on ads with nothing to show. Six months later, marketing is our most predictable growth channel.", "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: quote:
"They talk in revenue, not impressions. First agency that moved our pipeline.", "They talk in revenue, not impressions. First agency that actually moved our pipeline instead of our vanity dashboards.",
by: "Marcus Reyes, CMO, Northpeak SaaS", 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; ] as const;

View file

@ -232,6 +232,12 @@ body::before {
font-size: var(--step-4); font-size: var(--step-4);
margin-top: 1rem; margin-top: 1rem;
max-width: 16ch; 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; } .sec-head--center .sec-head__title { margin-inline: auto; }
@ -523,6 +529,45 @@ body::before {
.mobile-menu .btn { margin-top: 1rem; } .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 8. HERO
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
@ -537,14 +582,26 @@ body::before {
--mx: 50%; --mx: 50%;
--my: 40%; --my: 40%;
} }
.hero__media { position: absolute; inset: -12% 0; z-index: 0; will-change: transform; } /* fluid sits at z-index:0; the video media now layers OVER it as a soft texture
.hero__video { width: 100%; height: 100%; object-fit: cover; opacity: 0.55; } (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 { .hero__scrim {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
linear-gradient(180deg, rgba(7, 7, 11, 0.5), rgba(7, 7, 11, 0.82) 60%, var(--c-bg)), 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.72), transparent 62%); linear-gradient(90deg, rgba(7, 7, 11, 0.78), rgba(7, 7, 11, 0.32) 48%, transparent 70%);
} }
.hero__spotlight { .hero__spotlight {
position: absolute; position: absolute;
@ -625,9 +682,18 @@ body::before {
max-width: 15ch; max-width: 15ch;
visibility: hidden; /* revealed by GSAP; reduced-motion fallback re-shows */ visibility: hidden; /* revealed by GSAP; reduced-motion fallback re-shows */
} }
/* SplitText line wrappers — clip so words rise from behind a mask */ /* SplitText line wrappers clip so words rise from behind a mask.
.hero__line { overflow: hidden; padding-bottom: 0.06em; } The mask MUST leave room for descenders (g, y, p, q) or "grow"/"your" get
.hero__h1 .word { display: inline-block; } 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 /* FOUC guard: hide stagger items until GSAP takes over (re-shown by JS, or by
the reduced-motion fallback below). */ the reduced-motion fallback below). */
.hero__stagger { opacity: 0; } .hero__stagger { opacity: 0; }
@ -968,16 +1034,46 @@ body::before {
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
13. SCOREBOARD (metrics) 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.frame { padding-bottom: clamp(2.7rem, 5.4vw, 5.4rem); }
.score__grid { .score__grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: clamp(1rem, 3vw, 2.5rem); gap: clamp(0.8rem, 2vw, 1.5rem);
text-align: center; 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 { .score__num.is-accent {
color: transparent; color: transparent;
background: var(--grad-text-ink); /* dark gradient => legible on light paper */ background: var(--grad-text-ink); /* dark gradient => legible on light paper */
@ -985,13 +1081,55 @@ body::before {
background-clip: text; background-clip: text;
} }
.score__lab { .score__lab {
margin-top: 0.7rem; margin-top: 0.6rem;
font-size: var(--step--1); font-size: var(--step--1);
color: var(--paper-dim); color: var(--paper-dim);
max-width: 18ch; max-width: 18ch;
margin-inline: auto; 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 { .score__foot {
margin-top: clamp(1.4rem, 3vw, 2.2rem); margin-top: clamp(1.4rem, 3vw, 2.2rem);
text-align: center; text-align: center;
@ -1000,27 +1138,8 @@ body::before {
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: #6b675f; /* ~4.9:1 on paper */ 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) { @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 { .case__inner {
position: relative; position: relative;
height: 100%; height: 100%;
padding: clamp(1.4rem, 2.5vw, 2rem); display: flex;
flex-direction: column;
border-radius: var(--radius); border-radius: var(--radius);
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2)); background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line); border: 1px solid var(--c-line);
@ -1055,42 +1175,89 @@ body::before {
transform: translateZ(40px); transform: translateZ(40px);
} }
} }
.case__glare { position: absolute; inset: 0; pointer-events: none; }
.case__head { /* sector photo — leads the card with reserved aspect ratio (no CLS) */
display: flex; .case__photo {
align-items: center; position: relative;
justify-content: space-between; aspect-ratio: 16 / 11;
gap: 1rem; 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-family: var(--font-mono);
font-size: var(--step--1); font-size: var(--step--1);
color: var(--c-text);
} }
.case__no { color: var(--case-accent); font-weight: 600; } .case__photo-no {
.case__tag { color: var(--c-text-dim); text-align: right; } 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 { .case__viz {
margin: 1.4rem 0 1.6rem; margin: clamp(1.1rem, 2vw, 1.4rem) clamp(1.4rem, 2.5vw, 2rem) 0;
padding: 1rem; padding: 0.9rem;
border-radius: 12px; border-radius: 12px;
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--c-line); border: 1px solid var(--c-line);
} }
.case__bars { display: flex; align-items: flex-end; gap: 8px; height: 84px; } .case__chart { width: 100%; height: auto; display: block; }
.case__bar { .case__chart text {
flex: 1; font-family: var(--font-mono);
height: var(--h); font-size: 9px;
border-radius: 4px 4px 0 0; letter-spacing: 0.02em;
background: linear-gradient(var(--case-accent), color-mix(in srgb, var(--case-accent) 30%, transparent)); }
.case__legend { color: var(--c-text-faint); }
.case__targetlab { font-size: 8px; }
.case__bar2 {
transform: scaleY(0); transform: scaleY(0);
transform-origin: bottom; 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); animation-delay: var(--d);
} }
@keyframes barGrow { @keyframes caseBarGrow {
0% { transform: scaleY(0); } 0% { transform: scaleY(0); }
72% { transform: scaleY(1.04); } 72% { transform: scaleY(1.04); }
100% { transform: scaleY(1); } 100% { transform: scaleY(1); }
} }
.case__spark { width: 100%; height: 26px; margin-top: 8px; } .case__body {
.case__body { position: relative; z-index: 1; } position: relative;
z-index: 1;
padding: clamp(1.1rem, 2vw, 1.4rem) clamp(1.4rem, 2.5vw, 2rem) 0;
}
.case__problem { .case__problem {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1108,7 +1275,9 @@ body::before {
.case__result { font-size: var(--step-1); margin: 0.7rem 0 0.4rem; } .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__how { font-size: var(--step--1); color: var(--c-text-dim); }
.case__metric { .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; padding-top: 1.2rem;
border-top: 1px solid var(--c-line); border-top: 1px solid var(--c-line);
display: flex; display: flex;
@ -1126,8 +1295,14 @@ body::before {
/* active-step accent, set by JS as the scroll advances */ /* active-step accent, set by JS as the scroll advances */
--loop-accent: #3b82f6; --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-pin { min-height: 100svh; display: flex; align-items: center; }
.loop__inner { width: 100%; } .loop__inner { min-width: 0; }
.loop__stage { .loop__stage {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -1393,68 +1568,185 @@ body::before {
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
16. TESTIMONIALS 16. TESTIMONIALS interactive carousel
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
.quotes__grid { .carousel {
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 {
position: relative; position: relative;
padding: clamp(1.6rem, 3vw, 2.4rem);
border-radius: var(--radius); border-radius: var(--radius);
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line); 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; 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 { .carousel:focus-visible { outline-offset: 6px; }
content: ""; .carousel__stage {
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 {
position: relative; position: relative;
font-size: 4rem; min-height: clamp(220px, 30vw, 280px);
line-height: 0.5; 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); color: var(--violet);
opacity: 0.5; opacity: 0.16;
transition: opacity var(--t-mid) var(--ease-out); pointer-events: none;
user-select: none;
} }
@media (hover: hover) and (pointer: fine) { .carousel__slide {
.quote:hover .quote__mark { opacity: 0.85; } 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; } .carousel__quote {
.quote__by { font-size: var(--step-2);
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.015em;
}
.carousel__by {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.7rem; gap: 1rem;
margin-top: 1.4rem; 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-family: var(--font-mono);
font-size: var(--step--1); font-size: var(--step--1);
color: var(--c-text-faint); color: var(--c-text-faint);
} }
.quote__rule { width: 24px; height: 1px; background: var(--violet); } .carousel__chip {
.partners { margin-top: clamp(2.5rem, 5vw, 4rem); display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; } flex-shrink: 0;
.partners__list { display: flex; gap: 1.5rem; flex-wrap: wrap; } padding: 0.4rem 0.9rem;
.partners__list li { font-weight: 700; color: var(--c-text-dim); } 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 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); } .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 { .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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; 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; } .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 19. FOOTER
@ -1541,7 +1903,11 @@ body::before {
} }
.hero__h1 { visibility: visible !important; } .hero__h1 { visibility: visible !important; }
.hero__stagger { opacity: 1 !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 */ .hero__video { display: none; } /* poster image only */
/* keep decorative loops fully still, not snapping each 0.001ms */ /* keep decorative loops fully still, not snapping each 0.001ms */
.hero__live, .hero__live,

View file

@ -1,9 +1,7 @@
import Link from "next/link";
import { import {
SITE, SITE,
services, services,
cases, cases,
testimonials,
} from "./content"; } from "./content";
import SiteHeader from "./components/SiteHeader"; import SiteHeader from "./components/SiteHeader";
@ -13,9 +11,11 @@ import Reveal, { RevealItem } from "./components/Reveal";
import Scoreboard from "./components/Scoreboard"; import Scoreboard from "./components/Scoreboard";
import CaseCard, { type CaseData } from "./components/CaseCard"; import CaseCard, { type CaseData } from "./components/CaseCard";
import ProcessLoop from "./components/ProcessLoop"; import ProcessLoop from "./components/ProcessLoop";
import Testimonials from "./components/Testimonials";
import Faq from "./components/Faq"; import Faq from "./components/Faq";
import Magnetic from "./components/Magnetic"; import Magnetic from "./components/Magnetic";
import SectionDivider from "./components/SectionDivider"; import SectionDivider from "./components/SectionDivider";
import FinalCTA from "./components/FinalCTA";
const TAPE = [ const TAPE = [
"Revenue, not vanity metrics", "Revenue, not vanity metrics",
@ -259,41 +259,8 @@ export default function Page() {
{/* ===== PROCESS — pinned scroll + morphing SVG ===== */} {/* ===== PROCESS — pinned scroll + morphing SVG ===== */}
<ProcessLoop /> <ProcessLoop />
{/* ===== TESTIMONIALS ===== */} {/* ===== TESTIMONIALS — interactive auto-advancing carousel ===== */}
<section className="quotes frame" aria-label="What clients say"> <Testimonials />
<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>
{/* ===== FAQ ===== */} {/* ===== FAQ ===== */}
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h"> <section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
@ -313,34 +280,8 @@ export default function Page() {
</div> </div>
</section> </section>
{/* ===== FINAL CTA ===== */} {/* ===== FINAL CTA — dramatic cursor-reactive WebGL + kinetic type ===== */}
<section className="final frame" aria-labelledby="final-h"> <FinalCTA />
<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>
</main> </main>
{/* ===== FOOTER ===== */} {/* ===== 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