make slidee carousel
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-04-06 16:16:15 +01:00
parent 6a239557d2
commit ff97450a39
+96 -101
View File
@@ -4,84 +4,53 @@ import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { SliderPhoto } from "@/lib/slider-photos"; // Definição do tipo para garantir 100% de compatibilidade TS
export interface SliderPhoto {
src: string;
alt: string;
id?: string | number;
}
const ROTATE_MS = 5500; const ROTATE_MS = 5500;
function PromoStripSingleSlide({ // --- 1. COMPONENTE DE SLIDE INDIVIDUAL ---
photos, interface SlideProps {
activeIndex, photo: SliderPhoto;
}: { onRatioLoad: (src: string, ratio: number) => void;
photos: SliderPhoto[]; }
activeIndex: number;
}) {
const [ratioBySrc, setRatioBySrc] = useState<Record<string, number>>({});
const lastKnownRatioRef = useRef(16 / 9);
const active = photos[activeIndex];
const activeRatio = active ? ratioBySrc[active.src] : undefined;
const displayRatio =
activeRatio != null && activeRatio > 0 ? activeRatio : photos.length > 0 ? lastKnownRatioRef.current : undefined;
useEffect(() => {
if (activeRatio != null && activeRatio > 0) {
lastKnownRatioRef.current = activeRatio;
}
}, [activeRatio]);
const containerStyle = useMemo((): CSSProperties => {
if (photos.length === 0) {
return { minHeight: "10rem" };
}
if (displayRatio != null) {
return { aspectRatio: displayRatio };
}
return { minHeight: "10rem" };
}, [photos.length, displayRatio]);
const handleImageLoad = useCallback((src: string, naturalWidth: number, naturalHeight: number) => {
if (naturalHeight <= 0) return;
setRatioBySrc((prev) => {
const next = naturalWidth / naturalHeight;
if (prev[src] === next) return prev;
return { ...prev, [src]: next };
});
}, []);
const n = photos.length;
function PromoStripSingleSlide({ photo, onRatioLoad }: SlideProps) {
return ( return (
<div <div className="relative h-full w-full">
className="relative w-full bg-neutral-100 dark:bg-neutral-900" <Image
style={containerStyle} src={photo.src}
> alt={photo.alt}
{n > 0 ? ( fill
photos.map((photo, i) => ( priority
<Image className="object-cover object-center"
key={photo.src} sizes="100vw"
src={photo.src} onLoad={(e) => {
alt={photo.alt} const target = e.currentTarget;
fill if (target.naturalHeight > 0) {
priority={i === 0} onRatioLoad(photo.src, target.naturalWidth / target.naturalHeight);
className={`object-cover object-center transition-opacity duration-[900ms] ease-in-out ${ }
i === activeIndex ? "z-[1] opacity-100" : "pointer-events-none z-0 opacity-0" }}
}`} />
sizes="100vw"
aria-hidden={i !== activeIndex} {/* Label Interna - Estilo Premium/iOS */}
onLoad={(e) => handleImageLoad(photo.src, e.currentTarget.naturalWidth, e.currentTarget.naturalHeight)} <div className="absolute bottom-6 left-6 z-10 md:bottom-10 md:left-10">
/> <span className="rounded-full bg-black/40 backdrop-blur-xl px-4 py-2 text-[10px] font-bold uppercase tracking-[0.2em] text-white border border-white/10 shadow-2xl">
)) Destaque
) : ( </span>
<div className="absolute inset-0 bg-neutral-200 dark:bg-neutral-800" aria-hidden /> </div>
)}
</div> </div>
); );
} }
// --- 2. HOOK DE DADOS (Simulação de Fetch) ---
function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } { function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
const [photos, setPhotos] = useState<SliderPhoto[]>([]); const [photos, setPhotos] = useState<SliderPhoto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -90,19 +59,11 @@ function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
.then((data: unknown) => { .then((data: unknown) => {
if (cancelled) return; if (cancelled) return;
if (Array.isArray(data)) { if (Array.isArray(data)) {
setPhotos( const validated = data.filter(
data.filter( (x): x is SliderPhoto =>
(x): x is SliderPhoto => typeof x === "object" && x !== null && "src" in x && "alt" in x
typeof x === "object" &&
x !== null &&
"src" in x &&
typeof (x as SliderPhoto).src === "string" &&
"alt" in x &&
typeof (x as SliderPhoto).alt === "string",
),
); );
} else { setPhotos(validated);
setPhotos([]);
} }
}) })
.catch(() => { .catch(() => {
@@ -111,20 +72,20 @@ function useSliderPhotos(): { photos: SliderPhoto[]; loading: boolean } {
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { return () => { cancelled = true; };
cancelled = true;
};
}, []); }, []);
return { photos, loading }; return { photos, loading };
} }
/** Slider: one image at a time; container height follows each image aspect ratio (no fixed empty band). */ // --- 3. COMPONENTE PRINCIPAL (O CARROSSEL) ---
export function TvonePromoStrip() { export function TvonePromoStrip() {
const { photos, loading } = useSliderPhotos(); const { photos, loading } = useSliderPhotos();
const [index, setIndex] = useState(0); const [index, setIndex] = useState<number>(0);
const [reduceMotion, setReduceMotion] = useState(false); const [ratios, setRatios] = useState<Record<string, number>>({});
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
// Detetar preferência de movimento do sistema (iOS/MacOS/Windows)
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReduceMotion(mq.matches); const sync = () => setReduceMotion(mq.matches);
@@ -133,9 +94,10 @@ export function TvonePromoStrip() {
return () => mq.removeEventListener("change", sync); return () => mq.removeEventListener("change", sync);
}, []); }, []);
// Lógica de avanço automático
const advance = useCallback(() => { const advance = useCallback(() => {
if (photos.length <= 1) return; if (photos.length <= 1) return;
setIndex((i) => (i + 1) % photos.length); setIndex((i: number) => (i + 1) % photos.length);
}, [photos.length]); }, [photos.length]);
useEffect(() => { useEffect(() => {
@@ -144,27 +106,60 @@ export function TvonePromoStrip() {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [photos.length, reduceMotion, advance]); }, [photos.length, reduceMotion, advance]);
useEffect(() => { // Handler para atualizar rácios de imagem
setIndex((prev) => (photos.length === 0 ? 0 : Math.min(prev, photos.length - 1))); const handleRatio = useCallback((src: string, ratio: number) => {
}, [photos.length]); setRatios((prev) => (prev[src] === ratio ? prev : { ...prev, [src]: ratio }));
}, []);
const current = photos.length > 0 ? photos[index] : null; // Calcula a proporção do contentor baseado no slide ativo
const regionLabel = loading ? "A carregar galeria" : current?.alt ?? "Galeria"; const currentRatio = useMemo<number>(() => {
const activePhoto = photos[index];
return activePhoto ? (ratios[activePhoto.src] || 16 / 9) : 16 / 9;
}, [photos, index, ratios]);
if (loading && photos.length === 0) {
return <div className="aspect-[16/9] w-full animate-pulse bg-neutral-100 dark:bg-neutral-800 rounded-3xl" />;
}
if (photos.length === 0) return null;
return ( return (
<div <section
className="relative w-full overflow-hidden bg-neutral-100 dark:bg-neutral-900" className="relative w-full overflow-hidden bg-neutral-100 dark:bg-neutral-900 transition-[aspect-ratio] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)]"
style={{ aspectRatio: currentRatio } as CSSProperties}
role="region" role="region"
aria-roledescription="carrossel" aria-roledescription="carrossel"
aria-label={regionLabel}
> >
<span className="sr-only" aria-live="polite"> {/* O "ROLO" (A fita flexível) */}
{current ? current.alt : loading ? "A carregar" : "Sem imagens"} <div
</span> className="flex h-full w-full transition-transform duration-1000 ease-[cubic-bezier(0.32,0.72,0,1)]"
style={{ transform: `translateX(-${index * 100}%)` }}
<div className="relative w-full"> >
<PromoStripSingleSlide photos={photos} activeIndex={index} /> {photos.map((photo: SliderPhoto, i: number) => (
<div key={photo.src || i} className="h-full w-full flex-shrink-0">
<PromoStripSingleSlide
photo={photo}
onRatioLoad={handleRatio}
/>
</div>
))}
</div> </div>
</div>
{/* INDICADORES (iOS Dot Style) */}
{photos.length > 1 && (
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-2 z-20">
{photos.map((_, i: number) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`h-1.5 rounded-full transition-all duration-500 ${
index === i ? "w-8 bg-white" : "w-1.5 bg-white/30 hover:bg-white/60"
}`}
aria-label={`Ir para slide ${i + 1}`}
/>
))}
</div>
)}
</section>
); );
} }