// ─────────────────────────────────────────────────────────────
// POSTER CAROUSEL — Netflix-style, full-bleed, drag tátil 1:1
// Auto-scroll contínuo + momentum + arrasto direto pelo dedo
// ─────────────────────────────────────────────────────────────
const { useState: _useState, useEffect: _useEffect, useRef: _useRef, useMemo: _useMemo, useCallback: _useCallback } = React;
// AUTO-SCROLL constant (pixels per millisecond) — bem lento, ambiente
const AUTO_SPEED = 0.018;
const FRICTION = 0.93;
const VELOCITY_THRESHOLD = 0.02;
const DRAG_THRESHOLD = 8; // px — abaixo disso é clique, acima é arrasto
// ───────── PÔSTER UNITÁRIO ─────────
function PosterCard({ module, t, isCenter, onOpen, onBuy, onCode, unlocked, width, height }) {
const isSoon = module.status === "soon";
const isCombo = !!module.isCombo;
const comboActive = isCombo && unlocked; // combo com tudo liberado
const locked = !isSoon && !unlocked; // seção bloqueada
// tonalidade sutil por módulo
const TONES = {
stone: { wash: "rgba(180,170,150,0.06)", glow: "rgba(220,200,160,0.10)" },
olive: { wash: "rgba(140,160,120,0.06)", glow: "rgba(160,180,130,0.10)" },
ink: { wash: "rgba(120,130,160,0.05)", glow: "rgba(150,160,200,0.08)" },
ash: { wash: "rgba(180,180,180,0.04)", glow: "rgba(200,200,200,0.06)" },
sage: { wash: "rgba(150,170,140,0.06)", glow: "rgba(175,195,150,0.13)" },
premium: { wash: "rgba(220,190,130,0.12)", glow: "rgba(240,210,150,0.18)" },
};
const tone = TONES[module.accent] || TONES.stone;
const accessPill = (() => {
if (isSoon) return { label: "Em breve" };
if (module.free) return { label: "Grátis" };
if (isCombo) return { label: comboActive ? "Ativo" : "Combo" };
return { label: unlocked ? "Liberado" : "Bloqueado" };
})();
// Decide rótulo do botão principal
const buttonLabel = isCombo ? (comboActive ? "Acesso ativo" : "Adquirir") :
module.free ? "Acessar grátis" :
unlocked ? "Acessar" :
"Comprar";
const countLabelStyle = {
fontFamily: TYPE.sans, fontSize: 10, color: t.ink3,
letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 700,
marginBottom: 6, whiteSpace: "nowrap",
};
const buttonBase = {
fontFamily: TYPE.sans, fontSize: 12,
letterSpacing: "0.12em", textTransform: "uppercase", fontWeight: 800,
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
width: "100%", padding: "16px 18px",
borderRadius: 12, whiteSpace: "nowrap", boxSizing: "border-box",
};
return (
{/* textura de papel sutil */}
{/* faixa de luz sutil */}
{/* moldura interior */}
{/* ÁREA DE TOQUE — abre o módulo / lista. NÃO cobre a área dos botões. */}
{!isSoon && (
{ e.stopPropagation(); onOpen?.(); }}
aria-label={`Abrir ${module.name}`}
style={{
position: "absolute",
top: 0, left: 0, right: 0,
bottom: 110,
background: "transparent",
border: "none",
cursor: "pointer",
zIndex: 2,
}}
>
)}
{/* CONTEÚDO */}
{/* TOP META */}
{isCombo ? "Combo" : module.sub}
{/* caixa de largura FIXA p/ o selo — mantém a quebra do texto à esquerda
idêntica em qualquer estado (Bloqueado/Liberado/Grátis/etc) */}
{locked && (
)}
{accessPill.label}
{/* CENTRO: número grande (borrado quando bloqueado) */}
{/* BOTTOM TÍTULO */}
{nl2br(module.name)}
{nl2br(module.desc)}
{/* PREÇO + AÇÃO */}
{isSoon ? (
) : (unlocked || comboActive) ? (
{module.count}
{module.free ? "Grátis" : "Acesso liberado"}
) : (
{module.count}
{module.term === "ano" ? (
12x
R$ {(module.price / 12).toFixed(2).replace(".", ",")}
{module.priceOld && R$ {module.priceOld} }
ou R$ {module.price} à vista · 1 ano de acesso
) : (
{module.priceOld && (
R$ {module.priceOld}
)}
R$ {module.price}
)}
)}
{comboActive ? (
) : (
{ e.stopPropagation(); (unlocked ? onOpen : onBuy)?.(); }}
style={{
...buttonBase,
background: (unlocked || isSoon) ? "transparent" : t.ink1,
color: isSoon ? t.ink2 : (unlocked ? t.ink1 : t.bg0),
border: isSoon ? `1px dashed ${t.border}` : `1px solid ${unlocked ? t.border : t.ink1}`,
cursor: "pointer", zIndex: 3, position: "relative",
}}
>
{isSoon ? "Avisar quando sair" : buttonLabel}
{isSoon ? (
) : (
)}
)}
);
}
// ───────── CARROSSEL ─────────
function PosterCarousel({ modules, t, onOpenModule, onBuyModule, onCodeModule, isUnlocked, frameWidth, frameHeight }) {
const posterW = Math.round(frameWidth * 0.78);
const posterH = Math.max(Math.round(frameHeight * 0.70) + 78, 460);
const gap = 14;
const itemW = posterW + gap;
const sidePad = (frameWidth - posterW) / 2;
const REPS = 3;
const repeated = _useMemo(() => {
const arr = [];
for (let i = 0; i < REPS; i++) modules.forEach((m, idx) => arr.push({ ...m, _key: `${i}-${idx}` }));
return arr;
}, [modules]);
const trackRef = _useRef(null);
const wrapRef = _useRef(null);
const offsetRef = _useRef(0);
const velocityRef = _useRef(0);
const rafRef = _useRef(0);
const lastTimeRef = _useRef(0);
const draggingRef = _useRef(false);
const startXRef = _useRef(0);
const startYRef = _useRef(0);
const startOffsetRef = _useRef(0);
const lastPxRef = _useRef(0);
const lastTRef = _useRef(0);
const movedRef = _useRef(false); // já passou do threshold?
const [centerIdx, setCenterIdx] = _useState(0);
const baseLen = modules.length * itemW;
_useEffect(() => {
offsetRef.current = -baseLen;
if (trackRef.current) trackRef.current.style.transform = `translate3d(${offsetRef.current}px,0,0)`;
}, [baseLen]);
// loop principal
_useEffect(() => {
let canceled = false;
const loop = (now) => {
if (canceled) return;
const dt = Math.min(50, now - (lastTimeRef.current || now));
lastTimeRef.current = now;
if (!draggingRef.current) {
offsetRef.current += velocityRef.current * dt;
velocityRef.current *= FRICTION;
if (Math.abs(velocityRef.current) < VELOCITY_THRESHOLD) {
velocityRef.current = -AUTO_SPEED;
}
}
while (offsetRef.current <= -2 * baseLen) offsetRef.current += baseLen;
while (offsetRef.current > -baseLen) offsetRef.current -= baseLen;
if (trackRef.current) {
trackRef.current.style.transform = `translate3d(${offsetRef.current}px,0,0)`;
}
const localOffset = offsetRef.current % baseLen;
const adjusted = (-localOffset - sidePad) / itemW;
const idx = ((Math.round(adjusted) % modules.length) + modules.length) % modules.length;
setCenterIdx((cur) => (cur === idx ? cur : idx));
// parallax sutil do glow interno (baseado na posição real na tela)
const wrapEl = wrapRef.current, trEl = trackRef.current;
if (wrapEl && trEl) {
const half = wrapEl.clientWidth / 2;
const base = wrapEl.getBoundingClientRect().left;
const kids = trEl.children;
for (let i = 0; i < kids.length; i++) {
const r = kids[i].getBoundingClientRect();
const d = (r.left - base + r.width / 2) - half;
kids[i].style.setProperty("--gx", (d * 0.05).toFixed(1) + "px");
}
}
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => { canceled = true; cancelAnimationFrame(rafRef.current); };
}, [baseLen, itemW, sidePad, modules.length]);
// ─── POINTER HANDLERS (sem capture — deixa clicks passarem) ───
_useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const onDown = (e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
draggingRef.current = true;
movedRef.current = false;
startXRef.current = e.clientX;
startYRef.current = e.clientY;
startOffsetRef.current = offsetRef.current;
lastPxRef.current = e.clientX;
lastTRef.current = performance.now();
velocityRef.current = 0;
};
const onMove = (e) => {
if (!draggingRef.current) return;
const dx = e.clientX - startXRef.current;
const dy = e.clientY - startYRef.current;
// Se moveu mais vertical que horizontal nos primeiros pixels, é scroll da página
if (!movedRef.current && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > DRAG_THRESHOLD) {
draggingRef.current = false;
return;
}
if (Math.abs(dx) > DRAG_THRESHOLD) {
movedRef.current = true;
e.preventDefault?.();
}
if (movedRef.current) {
offsetRef.current = startOffsetRef.current + dx;
const now = performance.now();
const ddt = now - lastTRef.current;
if (ddt > 0) {
const dxLocal = e.clientX - lastPxRef.current;
velocityRef.current = dxLocal / ddt;
}
lastPxRef.current = e.clientX;
lastTRef.current = now;
}
};
const onUp = () => {
draggingRef.current = false;
};
// Listeners de move/up no documento garantem que continuamos
// recebendo eventos mesmo se o dedo sair do wrap.
// Click bubbles normalmente, sem ser sequestrado por pointer capture.
wrap.addEventListener("pointerdown", onDown);
window.addEventListener("pointermove", onMove, { passive: false });
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
return () => {
wrap.removeEventListener("pointerdown", onDown);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
}, []);
// Bloqueia o click no poster se foi arrasto (capture phase)
const onClickCapture = (e) => {
if (movedRef.current) {
e.stopPropagation();
e.preventDefault();
movedRef.current = false;
}
};
return (
{repeated.map((m, i) => {
const realIdx = i % modules.length;
return (
onOpenModule?.(m)}
onBuy={() => onBuyModule?.(m)}
onCode={() => onCodeModule?.(m)}
/>
);
})}
{/* PAGINAÇÃO — pontos */}
{modules.map((m, i) => (
))}
);
}
Object.assign(window, { PosterCarousel });