Files
portifolio-evandrodevbr/app/components/story/ScrollStory.tsx
T

378 lines
14 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
const StoryStage3D = dynamic(() => import("./StoryStage3D"), { ssr: false });
type Chapter = {
kicker: string;
lines: string[];
body?: string;
stats?: Array<{ value: number; suffix: string; label: string }>;
cta?: boolean;
};
/**
* Reel scrollytelling da home: palco sticky de 100dvh dirigido por uma única
* timeline GSAP com scrub — o scroll é o controle de play/rebobinar.
* Sem JS ou com prefers-reduced-motion: capítulos renderizam empilhados,
* inteiros e legíveis (mesma regra de fail-soft do hero).
*/
export default function ScrollStory({
projectsCount,
experiencesCount,
skillsCount,
}: {
projectsCount: number;
experiencesCount: number;
skillsCount: number;
}) {
const chapters: Chapter[] = [
{
kicker: "01 — Visão geral",
lines: ["Software que opera.", "Não só roda."],
body: "Role para rebobinar a engenharia por trás de cada sistema — a mesma página, agora em tomada contínua.",
},
{
kicker: "02 — Arquitetura",
lines: ["Da borda", "ao painel."],
body: "Dispositivo, integração, API e interface girando juntos: contratos claros entre camada física e produto.",
},
{
kicker: "03 — Em produção",
lines: ["Entrega com", "critério real."],
stats: [
{ value: projectsCount, suffix: "", label: "sistemas ativos no portfólio" },
{ value: experiencesCount, suffix: "", label: "ciclos de produto entregues" },
{ value: skillsCount, suffix: "", label: "tecnologias em operação" },
],
},
{
kicker: "04 — Próximo ciclo",
lines: ["Vamos girar o", "seu próximo sistema."],
cta: true,
},
];
const sectionRef = useRef<HTMLElement>(null);
const progressRef = useRef(0);
const [engine, setEngine] = useState(false);
const [webgl, setWebgl] = useState(false);
useEffect(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) return undefined;
setEngine(true);
setWebgl(
!window.matchMedia("(pointer: coarse)").matches && window.innerWidth >= 768,
);
return undefined;
}, []);
useEffect(() => {
if (!engine) return undefined;
const el = sectionRef.current;
if (!el) return undefined;
let cleanup = () => {};
let cancelled = false;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const scenes = gsap.utils.toArray<HTMLElement>("[data-story-scene]");
const bar = el.querySelector("[data-story-bar]");
const indexEl = el.querySelector<HTMLElement>("[data-story-index]");
const hint = el.querySelector("[data-story-hint]");
gsap.set(scenes.slice(1), { autoAlpha: 0 });
gsap.set(scenes, { yPercent: 0 });
const tl = gsap.timeline({
defaults: { ease: "none" },
scrollTrigger: {
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: 0.85,
onUpdate: (self) => {
progressRef.current = self.progress;
if (indexEl) {
const i = Math.min(
scenes.length,
Math.floor(self.progress * scenes.length) + 1,
);
indexEl.textContent = String(i).padStart(2, "0");
}
},
},
});
const span = 1 / scenes.length;
scenes.forEach((scene, i) => {
const at = i * span;
const inner = scene.querySelectorAll("[data-story-line]");
const kicker = scene.querySelector("[data-story-kicker]");
const body = scene.querySelectorAll("[data-story-body]");
const stats = scene.querySelectorAll("[data-story-stat]");
tl.fromTo(
inner,
{ yPercent: 118 },
{ yPercent: 0, duration: span * 0.34, stagger: span * 0.05 },
at + span * 0.02,
);
if (kicker)
tl.fromTo(
kicker,
{ autoAlpha: 0, y: 10 },
{ autoAlpha: 1, y: 0, duration: span * 0.16 },
at,
);
if (body.length)
tl.fromTo(
body,
{ autoAlpha: 0, y: 22 },
{ autoAlpha: 1, y: 0, duration: span * 0.22, stagger: span * 0.04 },
at + span * 0.18,
);
if (stats.length)
tl.fromTo(
stats,
{ autoAlpha: 0, y: 26, filter: "blur(6px)" },
{
autoAlpha: 1,
y: 0,
filter: "blur(0px)",
duration: span * 0.24,
stagger: span * 0.05,
},
at + span * 0.2,
);
// entrada da cena + saída — janelas sobrepostas p/ crossfade contínuo
tl.fromTo(
scene,
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: span * 0.18 },
Math.max(0, at - span * 0.08),
);
if (i < scenes.length - 1)
tl.to(scene, { autoAlpha: 0, yPercent: -5, duration: span * 0.18 }, at + span * 0.8);
if (stats.length) {
stats.forEach((statEl) => {
const numEl = statEl.querySelector<HTMLElement>("[data-story-num]");
if (!numEl) return;
const target = Number(numEl.dataset.storyNumTo ?? "0");
const counter = { v: 0 };
tl.to(
counter,
{
v: target,
duration: span * 0.3,
ease: "power2.out",
onUpdate: () => {
numEl.textContent = String(Math.round(counter.v));
},
},
at + span * 0.22,
);
});
}
});
if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0);
if (hint) tl.to(hint, { autoAlpha: 0, duration: span * 0.1 }, span * 0.12);
}, el);
cleanup = () => ctx.revert();
};
void setup();
return () => {
cancelled = true;
cleanup();
};
}, [engine]);
// ---- Fallback estático: capítulos empilhados, conteúdo inteiro ----
if (!engine) {
return (
<section
id="story"
className="border-b border-[rgba(255,255,255,.06)] bg-[var(--ink)] text-[var(--paper)]"
>
<div className="mx-auto w-full max-w-[1240px] px-6 py-16">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
O reel
</div>
{chapters.map((ch) => (
<article key={ch.kicker} className="mt-10 border-l border-[rgba(255,255,255,.1)] pl-6">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
{ch.kicker}
</div>
<h3 className="mt-3 font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-[1.05] tracking-[-0.02em]">
{ch.lines.join(" ")}
</h3>
{ch.body && (
<p className="mt-3 max-w-[58ch] text-[14px] leading-7 text-[rgba(244,242,237,.72)]">
{ch.body}
</p>
)}
{ch.stats && (
<ul className="mt-4 flex flex-wrap gap-6">
{ch.stats.map((s) => (
<li key={s.label}>
<div className="font-display text-[2rem] font-extrabold text-[var(--slash)]">
{s.value}
{s.suffix}
</div>
<div className="text-[12px] text-[rgba(244,242,237,.6)]">{s.label}</div>
</li>
))}
</ul>
)}
{ch.cta && (
<a
href={buildWhatsAppUrl(
"Olá! Vi o reel do portfólio e quero conversar sobre um sistema.",
)}
target="_blank"
rel="noopener noreferrer"
className="mt-5 inline-flex items-center justify-center bg-[var(--slash)] px-6 py-3 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)]"
>
Conversar agora
</a>
)}
</article>
))}
</div>
</section>
);
}
// ---- Modo reel: palco sticky com cenas em crossfade ----
return (
<section
id="story"
ref={sectionRef}
data-story
className="relative h-[460vh] bg-[var(--ink)]"
aria-label="Apresentação em scroll contínuo"
>
<div className="sticky top-0 h-[100dvh] w-full overflow-hidden">
{/* fundo: gradiente radial + brilho do WebGL */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_55%_at_62%_45%,rgba(204,255,0,0.07),transparent_65%)]" />
{webgl && (
<div className="absolute inset-0">
<StoryStage3D progress={progressRef} />
</div>
)}
{/* legibilidade do texto sobre a cena */}
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,rgba(10,10,10,0.88)_0%,rgba(10,10,10,0.55)_42%,transparent_70%)]" />
{/* cenas */}
<div className="absolute inset-0">
{chapters.map((ch, i) => (
<div
key={ch.kicker}
data-story-scene
className="absolute inset-0 flex items-center"
style={{ zIndex: 10 - i }}
>
<div className="mx-auto w-full max-w-[1240px] px-6 lg:px-16">
<div className="max-w-[720px]">
<div
data-story-kicker
className="font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--slash)]"
>
{ch.kicker}
</div>
<h2 className="mt-4 font-display text-[clamp(2.4rem,7vw,5.2rem)] font-extrabold uppercase leading-[0.98] tracking-[-0.03em] text-[var(--paper)]">
{ch.lines.map((line) => (
<span key={line} className="block overflow-hidden pb-[0.06em]">
<span data-story-line className="block will-change-transform">
{line}
</span>
</span>
))}
</h2>
{ch.body && (
<p
data-story-body
className="mt-6 max-w-[54ch] text-[clamp(14px,1.4vw,17px)] leading-8 text-[rgba(244,242,237,.78)]"
>
{ch.body}
</p>
)}
{ch.stats && (
<ul className="mt-8 flex flex-wrap gap-x-12 gap-y-6">
{ch.stats.map((s) => (
<li key={s.label} data-story-stat className="min-w-[132px]">
<div className="font-display text-[clamp(2.2rem,4.5vw,3.4rem)] font-extrabold leading-none text-[var(--slash)]">
<span data-story-num data-story-num-to={s.value}>
0
</span>
{s.suffix}
</div>
<div className="mt-2 font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.62)]">
{s.label}
</div>
</li>
))}
</ul>
)}
{ch.cta && (
<a
data-story-body
href={buildWhatsAppUrl(
"Olá! Vi o reel do portfólio e quero conversar sobre um sistema.",
)}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto mt-8 inline-flex items-center justify-center bg-[var(--slash)] px-8 py-4 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)] transition hover:bg-white"
>
Conversar agora
</a>
)}
</div>
</div>
</div>
))}
</div>
{/* HUD: índice de capítulo + barra de progresso do reel */}
<div className="pointer-events-none absolute bottom-6 left-6 z-20 lg:left-16">
<div className="flex items-center gap-3 font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)]">
<span data-story-index className="text-[var(--slash)]">
01
</span>
<span>/ 0{chapters.length}</span>
</div>
<div className="mt-2 h-px w-40 bg-[rgba(255,255,255,.14)]">
<div
data-story-bar
className="h-full w-full origin-left bg-[var(--slash)]"
style={{ transform: "scaleX(0)" }}
/>
</div>
</div>
<div
data-story-hint
className="pointer-events-none absolute right-6 top-1/2 z-20 -translate-y-1/2 font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.5)] lg:right-16"
>
↕ scroll = play / rewind
</div>
</div>
</section>
);
}