feat: real people photos in testimonials + distinct sector case images, optimized webp; cleanup unused PNGs
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
336
app/components/FluidBackground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
260
app/components/Testimonials.tsx
Normal 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">
|
||||
“
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
584
app/globals.css
|
|
@ -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,
|
||||
|
|
|
|||
71
app/page.tsx
|
|
@ -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">“</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
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/assets/case-clinic.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/assets/case-fashion.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/assets/case-saas.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/assets/person-1.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/assets/person-2.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/assets/person-3.webp
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/assets/person-4.webp
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/assets/person-5.webp
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/assets/person-6.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |