/* global React */
/* Bature Digital — Hero Story
An animated narrative replacing the static hero panel.
Tells the story of compounding progress across 5 marketing channels.
*/
const { useEffect, useState, useRef } = React;
const STAGES = [
{
key: 'seo',
label: 'SEO',
eyebrow: 'Month 1 — SEO',
headline: 'Climbing search rankings',
sub: 'From page 4 to page 1',
metric: { from: 20, to: 407, suffix: ' clicks/mo' },
color: '#F1C518',
},
{
key: 'ads',
label: 'Paid Ads',
eyebrow: 'Month 3 — Paid Ads',
headline: 'ROAS compounding',
sub: 'Spend tightened, returns up',
metric: { from: 1.2, to: 4.8, suffix: '× ROAS', decimals: 1 },
color: '#A77FD0',
},
{
key: 'social',
label: 'Social',
eyebrow: 'Month 5 — Social',
headline: 'Audience growing',
sub: 'Real reach, not vanity',
metric: { from: 1.2, to: 14.6, suffix: 'K followers', decimals: 1 },
color: '#F5D24A',
},
{
key: 'web',
label: 'Web',
eyebrow: 'Month 7 — Web',
headline: 'Speed & conversion',
sub: 'CWV passing, CVR up',
metric: { from: 1.4, to: 3.9, suffix: '% CVR', decimals: 1 },
color: '#7B3DB8',
},
{
key: 'email',
label: 'Email',
eyebrow: 'Month 12 — Email',
headline: 'Automated revenue',
sub: 'Flows running on autopilot',
metric: { from: 89, to: 370, prefix: '$', suffix: 'K' },
color: '#F1C518',
},
];
// Easing — match brand
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const easeInOut = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
// Revenue curve data — sampled across all 5 stages, exponential growth feel
function curvePoints(progress) {
// progress 0..1 across the whole story
const pts = [];
const N = 60;
for (let i = 0; i < N; i++) {
const x = i / (N - 1);
if (x > progress) break;
// exponential-ish curve with a bit of organic wobble
const wobble = Math.sin(x * 14) * 0.012 + Math.sin(x * 33) * 0.006;
const y = Math.pow(x, 1.7) * 0.92 + wobble;
pts.push([x, y]);
}
return pts;
}
function pathFromPoints(points, w, h, padX, padY) {
if (points.length < 2) return '';
const innerW = w - padX * 2;
const innerH = h - padY * 2;
const xy = points.map(([x, y]) => [padX + x * innerW, padY + (1 - y) * innerH]);
let d = `M ${xy[0][0].toFixed(2)} ${xy[0][1].toFixed(2)}`;
for (let i = 1; i < xy.length; i++) {
const [px, py] = xy[i - 1];
const [cx, cy] = xy[i];
const mx = (px + cx) / 2;
d += ` Q ${px.toFixed(2)} ${py.toFixed(2)} ${mx.toFixed(2)} ${((py + cy) / 2).toFixed(2)}`;
d += ` T ${cx.toFixed(2)} ${cy.toFixed(2)}`;
}
return d;
}
function fillPathFromPoints(points, w, h, padX, padY) {
if (points.length < 2) return '';
const linePath = pathFromPoints(points, w, h, padX, padY);
const lastX = padX + points[points.length - 1][0] * (w - padX * 2);
const firstX = padX + points[0][0] * (w - padX * 2);
return `${linePath} L ${lastX.toFixed(2)} ${h - padY} L ${firstX.toFixed(2)} ${h - padY} Z`;
}
function format(n, p) {
const v = p.decimals ? n.toFixed(p.decimals) : Math.round(n);
return `${p.prefix || ''}${v}${p.suffix || ''}`;
}
// ───────────────────────── Stage scene components ─────────────────────────
function SeoScene({ t }) {
// t: 0..1 within stage
const positions = [4, 3, 2, 1, 1, 1];
const idx = Math.min(positions.length - 1, Math.floor(t * positions.length));
const rank = positions[idx];
return (
{"\"best digital marketing agency\"".slice(0, Math.floor(33 * Math.min(1, t * 1.4)))}
{[0, 1, 2, 3].map((i) => {
const isUs = (rank - 1) === i;
return (
-
{isUs && YOU}
);
})}
);
}
function AdsScene({ t }) {
// CTR rising, impressions filling
const ctr = 1.2 + (4.8 - 1.2) * easeOut(t);
const bars = [
[0.15, 0.25, 0.35, 0.55, 0.45, 0.7, 0.65, 0.85, 0.78, 0.95],
][0];
return (
Campaign · Conversions
{ctr.toFixed(1)}× ROAS
{bars.map((b, i) => {
const localT = Math.max(0, Math.min(1, t * 1.3 - i * 0.05));
const h = b * 100 * easeOut(localT);
return (
);
})}
MonTueWedThuFri
);
}
function SocialScene({ t }) {
// Followers ticking up; small profile rows appearing
const N = 6;
const visible = Math.floor(N * Math.min(1, t * 1.1));
return (
@yourbrand
{(1.2 + (14.6 - 1.2) * easeOut(t)).toFixed(1)}K followers
{Array.from({ length: N }).map((_, i) => (
-
))}
);
}
function WebScene({ t }) {
// Performance gauges ramping up
const lcp = 5.2 - (5.2 - 1.8) * easeOut(t); // seconds, lower is better
const cvr = 1.4 + (3.9 - 1.4) * easeOut(t); // percent
const score = 38 + (96 - 38) * easeOut(t); // perf score
return (
yourbrand.com
{Math.round(score)}
Performance
LCP
{lcp.toFixed(1)}s
CVR
{cvr.toFixed(1)}%
CLS
0.04
);
}
function EmailScene({ t }) {
const nodes = [
{ x: 12, y: 18, label: 'Welcome' },
{ x: 50, y: 14, label: 'Educate' },
{ x: 86, y: 22, label: 'Offer' },
{ x: 30, y: 60, label: 'Win-back' },
{ x: 70, y: 64, label: 'Loyal' },
];
const lines = [
[0, 1], [1, 2], [1, 3], [3, 4], [2, 4],
];
return (
Automation flow
{Math.round(38 + 24 * easeOut(t))}%
Open
{(2.4 + 8.6 * easeOut(t)).toFixed(1)}%
CTR
${Math.round(1.2 + 11.8 * easeOut(t))}K
Revenue
);
}
const SCENES = { seo: SeoScene, ads: AdsScene, social: SocialScene, web: WebScene, email: EmailScene };
// ───────────────────────── Main component ─────────────────────────
function HeroStory({ speed = 1, autoplay = true }) {
const [stageIdx, setStageIdx] = useState(0);
const [stageT, setStageT] = useState(0); // 0..1 progress within current stage
const [overallT, setOverallT] = useState(0); // 0..1 progress across all stages
const [paused, setPaused] = useState(!autoplay);
const rafRef = useRef(null);
const lastTickRef = useRef(0);
const STAGE_MS = 3200 / Math.max(0.25, speed);
// Reset when autoplay flips back on, or speed changes meaningfully
useEffect(() => { setPaused(!autoplay); }, [autoplay]);
useEffect(() => {
function tick(now) {
if (lastTickRef.current === 0) lastTickRef.current = now;
const dt = now - lastTickRef.current;
lastTickRef.current = now;
if (!paused) {
setStageT((prev) => {
const next = prev + dt / STAGE_MS;
if (next >= 1) {
setStageIdx((s) => {
const ns = (s + 1) % STAGES.length;
if (ns === 0) setOverallT(0);
return ns;
});
return 0;
}
return next;
});
setOverallT((prev) => {
const inc = dt / (STAGE_MS * STAGES.length);
const n = prev + inc;
return n > 1 ? 1 : n;
});
}
rafRef.current = requestAnimationFrame(tick);
}
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafRef.current);
lastTickRef.current = 0;
};
}, [paused, STAGE_MS]);
const stage = STAGES[stageIdx];
const Scene = SCENES[stage.key];
// Curve uses overallT but also adds per-stage smooth fill so it "draws" live
const liveProgress = Math.min(1, overallT + (stageT / STAGES.length) * 0.6);
const points = curvePoints(liveProgress);
const W = 460, H = 90, PX = 8, PY = 10;
const linePath = pathFromPoints(points, W, H, PX, PY);
const fillPath = fillPathFromPoints(points, W, H, PX, PY);
const headPt = points[points.length - 1];
const headXY = headPt
? [PX + headPt[0] * (W - PX * 2), PY + (1 - headPt[1]) * (H - PY * 2)]
: null;
// Metric counter for current stage
const m = stage.metric;
const metricVal = m.from + (m.to - m.from) * easeOut(stageT);
return (
{/* Stage indicator pill row */}
{STAGES.map((s, i) => (
))}
{/* Scene area */}
{stage.eyebrow}
{stage.headline}
{format(metricVal, m)}
{/* Cumulative revenue curve at the bottom */}
Cumulative revenue
${Math.round(89 + (370 - 89) * easeOut(overallT))}K
{/* Play / pause control */}
);
}
window.HeroStory = HeroStory;