291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
/**
|
|
* Palco 2 do estilo product-page: filmstrip horizontal dirigido pelo scroll.
|
|
* Arquitetura: runway 280vh + palco `position: sticky` (mesma técnica provada
|
|
* no reel do hero — sem GSAP pin, sem wrapper de flex). O scrub move a faixa
|
|
* da direita para a esquerda; quando a faixa termina, o sticky solta e o
|
|
* scroll continua para baixo normalmente. Role para cima = rebobina.
|
|
*
|
|
* Sem JS / reduced motion: grid estático (fallback), como no ScrollStory.
|
|
*/
|
|
|
|
const PRINTS = [
|
|
{
|
|
src: "/assets/prints/print-nutriscan.png",
|
|
name: "NutriScan",
|
|
desc: "PWA de dados nutricionais sobre Open Food Facts — busca, Nutri-Score e NOVA.",
|
|
url: "https://nutriscan.evandro.dev.br",
|
|
accent: true,
|
|
},
|
|
{
|
|
src: "/assets/prints/print-scplus.png",
|
|
name: "SCPLUS",
|
|
desc: "Educação e tecnologia sustentável em Garuva-SC; interface institucional viva.",
|
|
url: "https://scplus.evandro.dev.br",
|
|
accent: false,
|
|
},
|
|
{
|
|
src: "/assets/prints/print-sev.png",
|
|
name: "SEV Moda Íntima",
|
|
desc: "E-commerce em produção com checkout e catálogo — Next.js ponta a ponta.",
|
|
url: "https://sevmodaintima.com.br",
|
|
accent: true,
|
|
},
|
|
{
|
|
src: "/assets/prints/print-asservicos.png",
|
|
name: "AS Serviços",
|
|
desc: "Presença digital SSR para operação de serviços, pronta para crescer.",
|
|
url: "https://asservicos.com",
|
|
accent: false,
|
|
},
|
|
];
|
|
|
|
const CARD_W = "min(80vw, 430px)";
|
|
|
|
export default function PrintReel() {
|
|
const sectionRef = useRef<HTMLElement>(null);
|
|
const [engine, setEngine] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
return undefined;
|
|
setEngine(true);
|
|
return undefined;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!engine) return undefined;
|
|
const section = sectionRef.current;
|
|
if (!section) 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 track = section.querySelector<HTMLElement>("[data-strip-track]");
|
|
const idxEl = section.querySelector<HTMLElement>("[data-strip-index]");
|
|
const bar = section.querySelector<HTMLElement>("[data-strip-bar]");
|
|
if (!track) return;
|
|
|
|
// distância = largura da faixa menos a viewport
|
|
const distance = () =>
|
|
Math.max(0, track.scrollWidth - window.innerWidth);
|
|
|
|
const tl = gsap.timeline({
|
|
defaults: { ease: "none" },
|
|
scrollTrigger: {
|
|
trigger: section,
|
|
start: "top top",
|
|
end: "bottom bottom",
|
|
scrub: 0.85,
|
|
invalidateOnRefresh: true,
|
|
},
|
|
});
|
|
tl.to(track, { x: () => -distance(), duration: 1 });
|
|
if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0);
|
|
if (idxEl) {
|
|
tl.eventCallback("onUpdate", () => {
|
|
const p = (tl.scrollTrigger?.progress ?? 0) * PRINTS.length;
|
|
idxEl.textContent = String(
|
|
Math.min(PRINTS.length, Math.floor(p) + 1),
|
|
).padStart(2, "0");
|
|
});
|
|
}
|
|
|
|
// cada card "acende" ao entrar e esmaece ao passar (scrub, reversível)
|
|
const cards = Array.from(
|
|
section.querySelectorAll<HTMLElement>("[data-strip-card]"),
|
|
);
|
|
const span = 1 / cards.length;
|
|
cards.forEach((card, i) => {
|
|
tl.fromTo(
|
|
card,
|
|
{ opacity: 0.15, scale: 0.92, rotateY: -9 },
|
|
{
|
|
opacity: 1,
|
|
scale: 1,
|
|
rotateY: 0,
|
|
transformPerspective: 1100,
|
|
duration: span * 0.5,
|
|
},
|
|
Math.max(0, i * span - span * 0.12),
|
|
);
|
|
if (i > 0) {
|
|
tl.to(
|
|
card,
|
|
{ opacity: 0.35, scale: 0.94, duration: span * 0.4 },
|
|
(i - 1) * span + span * 0.55,
|
|
);
|
|
}
|
|
});
|
|
}, section);
|
|
|
|
cleanup = () => ctx.revert();
|
|
};
|
|
|
|
void setup();
|
|
return () => {
|
|
cancelled = true;
|
|
cleanup();
|
|
};
|
|
}, [engine]);
|
|
|
|
// ---- Fallback estático (sem JS / reduced motion) ----
|
|
if (!engine) {
|
|
return (
|
|
<section
|
|
id="print-reel"
|
|
className="border-t border-[rgba(255,255,255,.06)] bg-[var(--ink)] py-16 text-[var(--paper)]"
|
|
>
|
|
<div className="mx-auto w-full max-w-[1240px] px-6">
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
|
No ar, rodando
|
|
</div>
|
|
<h2 className="mt-3 font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold tracking-[-0.02em]">
|
|
Prints ao vivo dos sistemas em produção
|
|
</h2>
|
|
<div className="mt-8 grid gap-6 sm:grid-cols-2">
|
|
{PRINTS.map((print) => (
|
|
<a
|
|
key={print.name}
|
|
href={print.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.03)]"
|
|
>
|
|
<img
|
|
src={print.src}
|
|
alt={`Captura do projeto ${print.name} em produção`}
|
|
className="h-72 w-full object-cover object-top"
|
|
/>
|
|
<div className="p-5">
|
|
<div className="font-display text-[18px] font-extrabold">
|
|
{print.name}
|
|
</div>
|
|
<p className="mt-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
|
|
{print.desc}
|
|
</p>
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ---- Modo reel: runway + sticky ----
|
|
return (
|
|
<section
|
|
id="print-reel"
|
|
ref={sectionRef}
|
|
data-strip
|
|
className="relative h-[280vh] bg-[var(--ink)]"
|
|
aria-label="Galeria horizontal dos projetos em produção"
|
|
>
|
|
<div className="sticky top-0 flex h-[100dvh] w-full flex-col overflow-hidden">
|
|
{/* cabeçalho do palco */}
|
|
<div className="mx-auto flex w-full max-w-[1240px] shrink-0 flex-wrap items-end justify-between gap-4 px-6 pt-sp-16 lg:px-16">
|
|
<div>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--slash)]">
|
|
No ar, rodando
|
|
</div>
|
|
<h2 className="mt-2 font-display text-[clamp(1.6rem,3.4vw,2.6rem)] font-extrabold uppercase leading-[1] tracking-[-0.03em] text-[var(--paper)]">
|
|
Prints ao vivo da produção
|
|
</h2>
|
|
</div>
|
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
|
|
← role para percorrer →
|
|
</div>
|
|
</div>
|
|
|
|
{/* faixa */}
|
|
<div className="flex min-h-0 flex-1 items-center">
|
|
<div
|
|
data-strip-track
|
|
className="flex w-max items-stretch gap-10 pl-[max(1.5rem,calc((100vw-1240px)/2+2rem))] pr-[max(1.5rem,calc((100vw-1240px)/2+2rem))] will-change-transform"
|
|
>
|
|
{PRINTS.map((print, i) => (
|
|
<a
|
|
key={print.name}
|
|
data-strip-card
|
|
href={print.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="group flex shrink-0 flex-col will-change-transform"
|
|
style={{ width: CARD_W }}
|
|
>
|
|
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
|
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
|
<span
|
|
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
|
|
/>
|
|
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
|
|
{print.url.replace("https://", "")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="relative h-[min(50vh,380px)] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
|
|
<img
|
|
src={print.src}
|
|
alt={`Captura do projeto ${print.name} em produção`}
|
|
loading={i < 2 ? "eager" : "lazy"}
|
|
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.03] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-5"
|
|
/>
|
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_62%,rgba(10,10,10,.85)_100%)]" />
|
|
</div>
|
|
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="font-display text-[17px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
|
|
{print.name}
|
|
</h3>
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
|
ao vivo ↗
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-[12px] leading-6 text-[rgba(244,242,237,.62)]">
|
|
{print.desc}
|
|
</p>
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* HUD */}
|
|
<div className="mx-auto mb-6 flex w-full max-w-[1240px] shrink-0 items-center gap-4 px-6 lg:px-16">
|
|
<span
|
|
data-strip-index
|
|
className="font-mono text-[10px] tracking-[0.14em] text-[var(--slash)]"
|
|
>
|
|
01
|
|
</span>
|
|
<span className="font-mono text-[10px] tracking-[0.14em] text-[rgba(244,242,237,.45)]">
|
|
/ 0{PRINTS.length}
|
|
</span>
|
|
<div className="h-px flex-1 bg-[rgba(255,255,255,.12)]">
|
|
<div
|
|
data-strip-bar
|
|
className="h-full w-full origin-left bg-[var(--slash)]"
|
|
style={{ transform: "scaleX(0)" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|