diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml index 4bed2ab..a4e03ad 100644 --- a/.github/workflows/sync-main-to-dev.yml +++ b/.github/workflows/sync-main-to-dev.yml @@ -12,16 +12,23 @@ jobs: sync: runs-on: ubuntu-latest steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SYNC_BOT_APP_ID }} + private-key: ${{ secrets.SYNC_BOT_PRIVATE_KEY }} + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.SYNC_BOT_TOKEN_CHRISERT }} + token: ${{ steps.app-token.outputs.token }} - name: Configure Git run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "chrisert-sync-bot[bot]" + git config user.email "chrisert-sync-bot[bot]@users.noreply.github.com" - name: Sync main to dev run: | @@ -41,5 +48,5 @@ jobs: git commit --allow-empty -m "chore: sync from main [skip ci]" # Push with force-with-lease (safe force push that fails if remote has new commits) - # This workflow uses a PAT with admin bypass to push directly to the protected branch + # This workflow uses a GitHub App token to push to the protected branch git push origin dev --force-with-lease diff --git a/src/components/common/SEO.jsx b/src/components/common/Seo.jsx similarity index 96% rename from src/components/common/SEO.jsx rename to src/components/common/Seo.jsx index af73b86..18169df 100644 --- a/src/components/common/SEO.jsx +++ b/src/components/common/Seo.jsx @@ -1,6 +1,6 @@ import PropTypes from "prop-types"; -const SEO = ({ +const Seo = ({ title, description, keywords, @@ -51,7 +51,7 @@ const SEO = ({ ); }; -SEO.propTypes = { +Seo.propTypes = { title: PropTypes.string, description: PropTypes.string, keywords: PropTypes.string, @@ -60,5 +60,5 @@ SEO.propTypes = { ogType: PropTypes.string, }; -export { SEO }; -export default SEO; +export { Seo }; +export default Seo; diff --git a/src/components/common/SEO.test.jsx b/src/components/common/Seo.test.jsx similarity index 87% rename from src/components/common/SEO.test.jsx rename to src/components/common/Seo.test.jsx index 4a9ea4a..8689b73 100644 --- a/src/components/common/SEO.test.jsx +++ b/src/components/common/Seo.test.jsx @@ -1,40 +1,40 @@ import { render } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import { SEO } from "./SEO"; +import { Seo } from "./Seo"; -describe("SEO", () => { +describe("Seo", () => { it("renders title with site name", () => { - render(); + render(); expect(document.title).toBe("Contactos | Chrisert"); }); it("renders default title when no title prop provided", () => { - render(); + render(); expect(document.title).toBe( "Chrisert - Especialistas em ETICS e Isolamento Térmico" ); }); it("renders meta description", () => { - render(); + render(); const metaDescription = document.querySelector('meta[name="description"]'); expect(metaDescription).toHaveAttribute("content", "Descrição personalizada"); }); it("renders default description when not provided", () => { - render(); + render(); const metaDescription = document.querySelector('meta[name="description"]'); expect(metaDescription?.getAttribute("content")).toContain("ETICS"); }); it("renders meta keywords", () => { - render(); + render(); const metaKeywords = document.querySelector('meta[name="keywords"]'); expect(metaKeywords).toHaveAttribute("content", "isolamento, capoto"); }); it("renders canonical URL with base URL", () => { - render(); + render(); const canonicalLink = document.querySelector('link[rel="canonical"]'); expect(canonicalLink).toHaveAttribute( "href", @@ -43,14 +43,14 @@ describe("SEO", () => { }); it("renders default canonical URL when not provided", () => { - render(); + render(); const canonicalLink = document.querySelector('link[rel="canonical"]'); expect(canonicalLink).toHaveAttribute("href", "https://chrisert.pt"); }); it("renders Open Graph meta tags", () => { render( - { }); it("renders custom ogType", () => { - render(); + render(); const ogType = document.querySelector('meta[property="og:type"]'); expect(ogType).toHaveAttribute("content", "article"); }); it("renders custom ogImage", () => { - render(); + render(); const ogImage = document.querySelector('meta[property="og:image"]'); expect(ogImage).toHaveAttribute("content", "https://example.com/image.jpg"); }); it("renders Twitter meta tags", () => { - render(); + render(); const twitterCard = document.querySelector('meta[name="twitter:card"]'); const twitterTitle = document.querySelector('meta[name="twitter:title"]'); diff --git a/src/components/ui/FullscreenViewer.jsx b/src/components/ui/FullscreenViewer.jsx new file mode 100644 index 0000000..324c8c9 --- /dev/null +++ b/src/components/ui/FullscreenViewer.jsx @@ -0,0 +1,148 @@ +import PropTypes from "prop-types"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; + +const FullscreenViewer = ({ + images, + currentIndex, + scale, + position, + isFullscreen, + isZoomed, + canGoPrev, + canGoNext, + isDragging, + containerRef, + onClose, + onPrev, + onNext, + onImageClick, + onMouseDown, + onMouseMove, + onMouseUp, + onTouchStart, + onTouchMove, + onTouchEnd, +}) => { + return ( +
+ {/* Close button */} + + + {/* Previous button */} + + + {/* Next button */} + + + {/* Image */} + {isFullscreen && ( + {images[currentIndex].alt} + )} + + {/* Gesture capture layer - handles all mouse/touch interactions */} +
+ ); +}; + +FullscreenViewer.propTypes = { + images: PropTypes.arrayOf( + PropTypes.shape({ + image: PropTypes.string.isRequired, + alt: PropTypes.string, + }) + ).isRequired, + currentIndex: PropTypes.number.isRequired, + scale: PropTypes.number.isRequired, + position: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }).isRequired, + isFullscreen: PropTypes.bool.isRequired, + isZoomed: PropTypes.bool.isRequired, + canGoPrev: PropTypes.bool.isRequired, + canGoNext: PropTypes.bool.isRequired, + isDragging: PropTypes.shape({ current: PropTypes.bool }).isRequired, + containerRef: PropTypes.shape({ current: PropTypes.any }).isRequired, + onClose: PropTypes.func.isRequired, + onPrev: PropTypes.func.isRequired, + onNext: PropTypes.func.isRequired, + onImageClick: PropTypes.func.isRequired, + onMouseDown: PropTypes.func.isRequired, + onMouseMove: PropTypes.func.isRequired, + onMouseUp: PropTypes.func.isRequired, + onTouchStart: PropTypes.func.isRequired, + onTouchMove: PropTypes.func.isRequired, + onTouchEnd: PropTypes.func.isRequired, +}; + +export default FullscreenViewer; diff --git a/src/components/ui/FullscreenViewer.test.jsx b/src/components/ui/FullscreenViewer.test.jsx new file mode 100644 index 0000000..098713b --- /dev/null +++ b/src/components/ui/FullscreenViewer.test.jsx @@ -0,0 +1,229 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import FullscreenViewer from "./FullscreenViewer"; + +const mockImages = [ + { id: 1, image: "/image1.jpg", alt: "Imagem 1" }, + { id: 2, image: "/image2.jpg", alt: "Imagem 2" }, + { id: 3, image: "/image3.jpg", alt: "Imagem 3" }, +]; + +const defaultProps = { + images: mockImages, + currentIndex: 0, + scale: 1, + position: { x: 0, y: 0 }, + isFullscreen: true, + isZoomed: false, + canGoPrev: false, + canGoNext: true, + isDragging: { current: false }, + containerRef: { current: null }, + onClose: vi.fn(), + onPrev: vi.fn(), + onNext: vi.fn(), + onImageClick: vi.fn(), + onMouseDown: vi.fn(), + onMouseMove: vi.fn(), + onMouseUp: vi.fn(), + onTouchStart: vi.fn(), + onTouchMove: vi.fn(), + onTouchEnd: vi.fn(), +}; + +describe("FullscreenViewer", () => { + it("renders when isFullscreen is true", () => { + render(); + + expect( + screen.getByAltText("Imagem 1") + ).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + }); + + it("is hidden when isFullscreen is false", () => { + render(); + + expect( + screen.getByLabelText("Visualizador de imagens em ecrã inteiro") + ).toHaveClass("hidden"); + }); + + it("renders navigation buttons", () => { + render(); + + expect(screen.getByLabelText("Foto anterior")).toBeInTheDocument(); + expect(screen.getByLabelText("Próxima foto")).toBeInTheDocument(); + expect(screen.getByLabelText("Fechar")).toBeInTheDocument(); + }); + + it("calls onClose when close button is clicked", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Fechar")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onPrev when previous button is clicked", () => { + const onPrev = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Foto anterior")); + expect(onPrev).toHaveBeenCalledTimes(1); + }); + + it("calls onNext when next button is clicked", () => { + const onNext = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Próxima foto")); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it("shows correct image based on currentIndex", () => { + render(); + + expect(screen.getByAltText("Imagem 2")).toBeInTheDocument(); + expect(screen.getByText("2 / 3")).toBeInTheDocument(); + }); + + it("applies zoom-in cursor when not zoomed", () => { + render(); + + const gestureButton = screen.getByLabelText("Clique para ampliar"); + expect(gestureButton).toHaveClass("cursor-zoom-in"); + }); + + it("applies grab cursor when zoomed", () => { + render(); + + const gestureButton = screen.getByLabelText("Arraste para mover a imagem"); + expect(gestureButton).toHaveClass("cursor-grab"); + }); + + it("calls onImageClick when gesture layer is clicked", () => { + const onImageClick = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Clique para ampliar")); + expect(onImageClick).toHaveBeenCalledTimes(1); + }); + + it("calls mouse handlers on gesture layer", () => { + const onMouseDown = vi.fn(); + const onMouseMove = vi.fn(); + const onMouseUp = vi.fn(); + render( + + ); + + const gestureLayer = screen.getByLabelText("Clique para ampliar"); + fireEvent.mouseDown(gestureLayer); + fireEvent.mouseMove(gestureLayer); + fireEvent.mouseUp(gestureLayer); + + expect(onMouseDown).toHaveBeenCalledTimes(1); + expect(onMouseMove).toHaveBeenCalledTimes(1); + expect(onMouseUp).toHaveBeenCalledTimes(1); + }); + + it("calls onMouseUp on mouse leave", () => { + const onMouseUp = vi.fn(); + render(); + + const gestureLayer = screen.getByLabelText("Clique para ampliar"); + fireEvent.mouseLeave(gestureLayer); + + expect(onMouseUp).toHaveBeenCalledTimes(1); + }); + + it("calls touch handlers on gesture layer", () => { + const onTouchStart = vi.fn(); + const onTouchMove = vi.fn(); + const onTouchEnd = vi.fn(); + render( + + ); + + const gestureLayer = screen.getByLabelText("Clique para ampliar"); + fireEvent.touchStart(gestureLayer); + fireEvent.touchMove(gestureLayer); + fireEvent.touchEnd(gestureLayer); + + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(onTouchMove).toHaveBeenCalledTimes(1); + expect(onTouchEnd).toHaveBeenCalledTimes(1); + }); + + it("applies transform style based on scale and position", () => { + render( + + ); + + const image = screen.getByAltText("Imagem 1"); + expect(image.style.transform).toBe("scale(2) translate(50px, 25px)"); + }); + + it("disables transition when dragging", () => { + render( + + ); + + const image = screen.getByAltText("Imagem 1"); + expect(image.style.transition).toBe("none"); + }); + + it("enables smooth transition when not dragging", () => { + render( + + ); + + const image = screen.getByAltText("Imagem 1"); + expect(image.style.transition).toBe("transform 0.15s ease-out"); + }); + + it("shows disabled style for prev button when canGoPrev is false", () => { + render(); + + const prevButton = screen.getByLabelText("Foto anterior"); + expect(prevButton).toHaveClass("cursor-not-allowed"); + expect(prevButton).toHaveClass("text-white/20"); + }); + + it("shows enabled style for prev button when canGoPrev is true", () => { + render(); + + const prevButton = screen.getByLabelText("Foto anterior"); + expect(prevButton).not.toHaveClass("cursor-not-allowed"); + expect(prevButton).toHaveClass("text-white/70"); + }); + + it("shows disabled style for next button when canGoNext is false", () => { + render(); + + const nextButton = screen.getByLabelText("Próxima foto"); + expect(nextButton).toHaveClass("cursor-not-allowed"); + expect(nextButton).toHaveClass("text-white/20"); + }); +}); diff --git a/src/components/ui/Lightbox.jsx b/src/components/ui/Lightbox.jsx deleted file mode 100644 index 952df5a..0000000 --- a/src/components/ui/Lightbox.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useEffect, useRef, useCallback } from "react"; -import PropTypes from "prop-types"; -import { X, ChevronLeft, ChevronRight } from "lucide-react"; - -const Lightbox = ({ images, currentIndex, onClose, onNavigate }) => { - const touchStartX = useRef(null); - - const navigate = useCallback( - (direction) => { - if (direction === "prev") { - onNavigate(currentIndex > 0 ? currentIndex - 1 : images.length - 1); - } else { - onNavigate(currentIndex < images.length - 1 ? currentIndex + 1 : 0); - } - }, - [currentIndex, images.length, onNavigate] - ); - - // Swipe handlers for mobile - const handleTouchStart = (e) => { - touchStartX.current = e.touches[0].clientX; - }; - - const handleTouchEnd = (e) => { - if (touchStartX.current === null) return; - const diff = touchStartX.current - e.changedTouches[0].clientX; - - if (Math.abs(diff) > 50) { - navigate(diff > 0 ? "next" : "prev"); - } - touchStartX.current = null; - }; - - // Keyboard controls - useEffect(() => { - const handleKeyDown = (e) => { - if (e.key === "Escape") onClose(); - else if (e.key === "ArrowLeft") navigate("prev"); - else if (e.key === "ArrowRight") navigate("next"); - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [navigate, onClose]); - - return ( -
- {/* Backdrop - semantic button element */} - - - {/* Previous button */} - - - {/* Next button */} - - - {/* Image container */} -
- { -
-
- ); -}; - -Lightbox.propTypes = { - images: PropTypes.arrayOf( - PropTypes.shape({ - image: PropTypes.string.isRequired, - alt: PropTypes.string, - }) - ).isRequired, - currentIndex: PropTypes.number.isRequired, - onClose: PropTypes.func.isRequired, - onNavigate: PropTypes.func.isRequired, -}; - -export default Lightbox; diff --git a/src/components/ui/Lightbox.test.jsx b/src/components/ui/Lightbox.test.jsx deleted file mode 100644 index 53d362f..0000000 --- a/src/components/ui/Lightbox.test.jsx +++ /dev/null @@ -1,210 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import Lightbox from "./Lightbox"; - -const mockImages = [ - { image: "/image1.jpg", alt: "Image 1" }, - { image: "/image2.jpg", alt: "Image 2" }, - { image: "/image3.jpg", alt: "Image 3" }, -]; - -describe("Lightbox", () => { - let onCloseMock; - let onNavigateMock; - - beforeEach(() => { - onCloseMock = vi.fn(); - onNavigateMock = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - const renderLightbox = (currentIndex = 0) => { - return render( - - ); - }; - - it("renders the current image", () => { - renderLightbox(0); - const img = screen.getByRole("img"); - expect(img).toHaveAttribute("src", "/image1.jpg"); - expect(img).toHaveAttribute("alt", "Image 1"); - }); - - it("renders fallback alt text when alt is not provided", () => { - const imagesWithoutAlt = [{ image: "/image1.jpg" }]; - render( - - ); - const img = screen.getByRole("img"); - expect(img).toHaveAttribute("alt", "Projeto 1 de 1"); - }); - - it("renders navigation buttons", () => { - renderLightbox(); - expect(screen.getByLabelText("Foto anterior")).toBeInTheDocument(); - expect(screen.getByLabelText("Próxima foto")).toBeInTheDocument(); - expect(screen.getByLabelText("Fechar")).toBeInTheDocument(); - }); - - it("calls onClose when close button is clicked", () => { - renderLightbox(); - fireEvent.click(screen.getByLabelText("Fechar")); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("calls onClose when backdrop is clicked", () => { - renderLightbox(); - fireEvent.click(screen.getByLabelText("Fechar lightbox")); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("navigates to previous image when prev button is clicked", () => { - renderLightbox(1); - fireEvent.click(screen.getByLabelText("Foto anterior")); - expect(onNavigateMock).toHaveBeenCalledWith(0); - }); - - it("navigates to next image when next button is clicked", () => { - renderLightbox(1); - fireEvent.click(screen.getByLabelText("Próxima foto")); - expect(onNavigateMock).toHaveBeenCalledWith(2); - }); - - it("wraps to last image when navigating prev from first image", () => { - renderLightbox(0); - fireEvent.click(screen.getByLabelText("Foto anterior")); - expect(onNavigateMock).toHaveBeenCalledWith(2); // Last image index - }); - - it("wraps to first image when navigating next from last image", () => { - renderLightbox(2); - fireEvent.click(screen.getByLabelText("Próxima foto")); - expect(onNavigateMock).toHaveBeenCalledWith(0); // First image index - }); - - describe("keyboard navigation", () => { - it("closes lightbox on Escape key", () => { - renderLightbox(); - fireEvent.keyDown(document, { key: "Escape" }); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("navigates to previous image on ArrowLeft key", () => { - renderLightbox(1); - fireEvent.keyDown(document, { key: "ArrowLeft" }); - expect(onNavigateMock).toHaveBeenCalledWith(0); - }); - - it("navigates to next image on ArrowRight key", () => { - renderLightbox(1); - fireEvent.keyDown(document, { key: "ArrowRight" }); - expect(onNavigateMock).toHaveBeenCalledWith(2); - }); - - it("does not respond to other keys", () => { - renderLightbox(); - fireEvent.keyDown(document, { key: "Enter" }); - expect(onCloseMock).not.toHaveBeenCalled(); - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - }); - - describe("touch/swipe navigation", () => { - it("navigates to next image on swipe left", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - fireEvent.touchStart(backdrop, { - touches: [{ clientX: 300 }], - }); - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 100 }], - }); - - expect(onNavigateMock).toHaveBeenCalledWith(2); - }); - - it("navigates to previous image on swipe right", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - fireEvent.touchStart(backdrop, { - touches: [{ clientX: 100 }], - }); - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 300 }], - }); - - expect(onNavigateMock).toHaveBeenCalledWith(0); - }); - - it("does not navigate on small swipe", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - fireEvent.touchStart(backdrop, { - touches: [{ clientX: 200 }], - }); - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 180 }], - }); - - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - - it("does not navigate when touchStart was not recorded", () => { - renderLightbox(1); - const backdrop = screen.getByLabelText("Fechar lightbox"); - - // Only fire touchEnd without touchStart - fireEvent.touchEnd(backdrop, { - changedTouches: [{ clientX: 100 }], - }); - - expect(onNavigateMock).not.toHaveBeenCalled(); - }); - }); - - it("stops propagation when clicking navigation buttons", () => { - renderLightbox(); - - // Click prev button - should not trigger backdrop close - fireEvent.click(screen.getByLabelText("Foto anterior")); - expect(onNavigateMock).toHaveBeenCalled(); - // onClose should not be called from the backdrop click - expect(onCloseMock).toHaveBeenCalledTimes(0); - - onNavigateMock.mockClear(); - - // Click next button - should not trigger backdrop close - fireEvent.click(screen.getByLabelText("Próxima foto")); - expect(onNavigateMock).toHaveBeenCalled(); - expect(onCloseMock).toHaveBeenCalledTimes(0); - }); - - it("cleans up keyboard event listener on unmount", () => { - const removeEventListenerSpy = vi.spyOn(document, "removeEventListener"); - const { unmount } = renderLightbox(); - - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith( - "keydown", - expect.any(Function) - ); - }); -}); diff --git a/src/hooks/useFullscreenGallery.js b/src/hooks/useFullscreenGallery.js new file mode 100644 index 0000000..760e103 --- /dev/null +++ b/src/hooks/useFullscreenGallery.js @@ -0,0 +1,286 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +const ZOOM_LEVELS = [1, 1.5, 2.5, 4]; + +export const useFullscreenGallery = (images, carouselApi) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const containerRef = useRef(null); + const isDragging = useRef(false); + const hasMoved = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const touchStart = useRef({ x: 0, y: 0 }); + const pinchStart = useRef({ distance: 0, scale: 1 }); + + const isZoomed = scale > 1; + const canGoPrev = currentIndex > 0; + const canGoNext = currentIndex < images.length - 1; + + // Clamp position to keep image visible + const clampPosition = useCallback((x, y, currentScale) => { + const maxOffset = ((currentScale - 1) / currentScale) * 50; + const maxX = (window.innerWidth * maxOffset) / 100; + const maxY = (window.innerHeight * maxOffset) / 100; + return { + x: Math.min(Math.max(x, -maxX), maxX), + y: Math.min(Math.max(y, -maxY), maxY), + }; + }, []); + + // Open fullscreen + const open = useCallback((index) => { + setCurrentIndex(index); + setScale(1); + setPosition({ x: 0, y: 0 }); + + const container = containerRef.current; + if (container) { + if (container.requestFullscreen) { + container + .requestFullscreen() + .then(() => setIsFullscreen(true)) + .catch(() => {}); + } else if (container.webkitRequestFullscreen) { + container.webkitRequestFullscreen(); + setIsFullscreen(true); + } + } + }, []); + + // Close fullscreen + const close = useCallback(() => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + }, []); + + // Navigate with zoom reset + const goToPrev = useCallback(() => { + if (canGoPrev) { + setCurrentIndex((i) => i - 1); + setScale(1); + setPosition({ x: 0, y: 0 }); + } + }, [canGoPrev]); + + const goToNext = useCallback(() => { + if (canGoNext) { + setCurrentIndex((i) => i + 1); + setScale(1); + setPosition({ x: 0, y: 0 }); + } + }, [canGoNext]); + + // Handle fullscreen change - sync carousel when exiting + useEffect(() => { + const handleChange = () => { + if (!document.fullscreenElement && !document.webkitFullscreenElement) { + setIsFullscreen(false); + setScale(1); + setPosition({ x: 0, y: 0 }); + carouselApi?.scrollTo(currentIndex, true); + } + }; + document.addEventListener("fullscreenchange", handleChange); + document.addEventListener("webkitfullscreenchange", handleChange); + return () => { + document.removeEventListener("fullscreenchange", handleChange); + document.removeEventListener("webkitfullscreenchange", handleChange); + }; + }, [carouselApi, currentIndex]); + + // Keyboard navigation + useEffect(() => { + if (!isFullscreen) return; + + const handleKey = (e) => { + if (e.key === "ArrowLeft") goToPrev(); + else if (e.key === "ArrowRight") goToNext(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [isFullscreen, goToPrev, goToNext]); + + // Cycle to next zoom level + const cycleZoom = useCallback(() => { + const currentLevelIndex = ZOOM_LEVELS.findIndex((z) => scale <= z); + const nextIndex = + currentLevelIndex === -1 || currentLevelIndex === ZOOM_LEVELS.length - 1 + ? 0 + : currentLevelIndex + 1; + const newScale = ZOOM_LEVELS[nextIndex]; + + setScale(newScale); + if (newScale === 1) setPosition({ x: 0, y: 0 }); + }, [scale]); + + // Wheel zoom - use native event listener with passive: false to allow preventDefault + useEffect(() => { + const container = containerRef.current; + if (!container || !isFullscreen) return; + + const handleWheel = (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.3 : 0.3; + setScale((s) => { + const newScale = Math.min(Math.max(s + delta, 1), 4); + if (newScale === 1) setPosition({ x: 0, y: 0 }); + return newScale; + }); + }; + + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + }, [isFullscreen]); + + const handleImageClick = useCallback(() => { + if (!isFullscreen || hasMoved.current) return; + cycleZoom(); + }, [isFullscreen, cycleZoom]); + + const handleMouseDown = useCallback( + (e) => { + if (!isFullscreen) return; + hasMoved.current = false; + if (!isZoomed) return; + isDragging.current = true; + dragStart.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + }, + [isFullscreen, isZoomed, position] + ); + + const handleMouseMove = useCallback( + (e) => { + if (!isDragging.current) return; + hasMoved.current = true; + const newX = e.clientX - dragStart.current.x; + const newY = e.clientY - dragStart.current.y; + setPosition(clampPosition(newX, newY, scale)); + }, + [clampPosition, scale] + ); + + const handleMouseUp = useCallback(() => { + isDragging.current = false; + }, []); + + // Touch handlers + const handleTouchStart = useCallback( + (e) => { + if (!isFullscreen) return; + if (e.touches.length === 2) { + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + pinchStart.current = { distance: dist, scale }; + } else if (e.touches.length === 1) { + touchStart.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }; + hasMoved.current = false; + if (isZoomed) { + isDragging.current = true; + dragStart.current = { + x: e.touches[0].clientX - position.x, + y: e.touches[0].clientY - position.y, + }; + } + } + }, + [isFullscreen, scale, isZoomed, position] + ); + + const handleTouchMove = useCallback( + (e) => { + if (!isFullscreen) return; + if (e.touches.length === 2) { + hasMoved.current = true; + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + const newScale = Math.min( + Math.max( + pinchStart.current.scale * (dist / pinchStart.current.distance), + 1 + ), + 4 + ); + setScale(newScale); + if (newScale === 1) setPosition({ x: 0, y: 0 }); + } else if (isDragging.current && isZoomed) { + hasMoved.current = true; + const newX = e.touches[0].clientX - dragStart.current.x; + const newY = e.touches[0].clientY - dragStart.current.y; + setPosition(clampPosition(newX, newY, scale)); + } + }, + [isFullscreen, isZoomed, clampPosition, scale] + ); + + const handleTouchEnd = useCallback( + (e) => { + if (!isFullscreen) return; + + if (isDragging.current) { + isDragging.current = false; + return; + } + + // Swipe to navigate + if (!isZoomed && e.changedTouches.length === 1) { + const diff = touchStart.current.x - e.changedTouches[0].clientX; + if (Math.abs(diff) > 50) { + hasMoved.current = true; + if (diff > 0) goToNext(); + else goToPrev(); + return; + } + } + + // Tap to zoom + if (!hasMoved.current && e.changedTouches.length === 1) { + cycleZoom(); + } + }, + [isFullscreen, isZoomed, goToNext, goToPrev, cycleZoom] + ); + + return { + // State + isFullscreen, + currentIndex, + scale, + position, + isZoomed, + canGoPrev, + canGoNext, + containerRef, + isDragging, + + // Actions + open, + close, + goToPrev, + goToNext, + + // Handlers + handleImageClick, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + }; +}; diff --git a/src/hooks/useFullscreenGallery.test.js b/src/hooks/useFullscreenGallery.test.js new file mode 100644 index 0000000..89d5806 --- /dev/null +++ b/src/hooks/useFullscreenGallery.test.js @@ -0,0 +1,786 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useFullscreenGallery } from "./useFullscreenGallery"; + +const mockImages = [ + { id: 1, image: "/image1.jpg", alt: "Imagem 1" }, + { id: 2, image: "/image2.jpg", alt: "Imagem 2" }, + { id: 3, image: "/image3.jpg", alt: "Imagem 3" }, +]; + +const mockCarouselApi = { + scrollTo: vi.fn(), +}; + +// Helper to setup fullscreen state +const setupFullscreen = async (result, index = 0) => { + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(index); + }); + + // Simulate fullscreen being active + Object.defineProperty(document, "fullscreenElement", { + value: mockContainer, + writable: true, + configurable: true, + }); + + await act(async () => { + document.dispatchEvent(new Event("fullscreenchange")); + }); + + return mockContainer; +}; + +describe("useFullscreenGallery", () => { + let originalRequestFullscreen; + let originalExitFullscreen; + let originalFullscreenElement; + + beforeEach(() => { + originalRequestFullscreen = Element.prototype.requestFullscreen; + originalExitFullscreen = document.exitFullscreen; + originalFullscreenElement = Object.getOwnPropertyDescriptor(document, "fullscreenElement"); + + Element.prototype.requestFullscreen = vi.fn().mockResolvedValue(); + document.exitFullscreen = vi.fn().mockResolvedValue(); + + // Reset fullscreenElement + Object.defineProperty(document, "fullscreenElement", { + value: null, + writable: true, + configurable: true, + }); + + vi.clearAllMocks(); + }); + + afterEach(() => { + Element.prototype.requestFullscreen = originalRequestFullscreen; + document.exitFullscreen = originalExitFullscreen; + if (originalFullscreenElement) { + Object.defineProperty(document, "fullscreenElement", originalFullscreenElement); + } + }); + + it("initializes with default state", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + expect(result.current.isFullscreen).toBe(false); + expect(result.current.currentIndex).toBe(0); + expect(result.current.scale).toBe(1); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + expect(result.current.isZoomed).toBe(false); + expect(result.current.canGoPrev).toBe(false); + expect(result.current.canGoNext).toBe(true); + }); + + it("opens fullscreen at specified index", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(1); + }); + + expect(result.current.currentIndex).toBe(1); + expect(mockContainer.requestFullscreen).toHaveBeenCalled(); + }); + + it("closes fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.close(); + }); + + expect(document.exitFullscreen).toHaveBeenCalled(); + }); + + it("navigates to previous image", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(1); + }); + + act(() => { + result.current.goToPrev(); + }); + + expect(result.current.currentIndex).toBe(0); + }); + + it("navigates to next image", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.goToNext(); + }); + + expect(result.current.currentIndex).toBe(1); + }); + + it("does not navigate past first image", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(0); + }); + + act(() => { + result.current.goToPrev(); + }); + + expect(result.current.currentIndex).toBe(0); + }); + + it("does not navigate past last image", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(2); + }); + + act(() => { + result.current.goToNext(); + }); + + expect(result.current.currentIndex).toBe(2); + }); + + it("returns correct canGoPrev and canGoNext values", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + expect(result.current.canGoPrev).toBe(false); + expect(result.current.canGoNext).toBe(true); + + await act(async () => { + result.current.open(1); + }); + + expect(result.current.canGoPrev).toBe(true); + expect(result.current.canGoNext).toBe(true); + + await act(async () => { + result.current.open(2); + }); + + expect(result.current.canGoPrev).toBe(true); + expect(result.current.canGoNext).toBe(false); + }); + + it("resets scale and position when opening", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = vi.fn().mockResolvedValue(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(0); + }); + + expect(result.current.scale).toBe(1); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + }); + + it("cycles through zoom levels on image click", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + expect(result.current.scale).toBe(1); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(1.5); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(2.5); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(4); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles mouse drag when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in first + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.isZoomed).toBe(true); + + // Start drag + act(() => { + result.current.handleMouseDown({ clientX: 100, clientY: 100 }); + }); + + expect(result.current.isDragging.current).toBe(true); + + // Move + act(() => { + result.current.handleMouseMove({ clientX: 150, clientY: 150 }); + }); + + expect(result.current.position.x).not.toBe(0); + expect(result.current.position.y).not.toBe(0); + + // End drag + act(() => { + result.current.handleMouseUp(); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("does not click zoom if dragged", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in + act(() => { + result.current.handleImageClick(); + }); + + const initialScale = result.current.scale; + + // Simulate drag (mouseDown, mouseMove sets hasMoved) + act(() => { + result.current.handleMouseDown({ clientX: 100, clientY: 100 }); + result.current.handleMouseMove({ clientX: 200, clientY: 200 }); + result.current.handleMouseUp(); + }); + + // Click should not change zoom because hasMoved is true + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(initialScale); + }); + + it("handles touch start for drag when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in first + act(() => { + result.current.handleImageClick(); + }); + + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + expect(result.current.isDragging.current).toBe(true); + }); + + it("handles touch move for drag when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in + act(() => { + result.current.handleImageClick(); + }); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // Move touch + act(() => { + result.current.handleTouchMove({ + touches: [{ clientX: 150, clientY: 150 }], + }); + }); + + expect(result.current.position.x).not.toBe(0); + }); + + it("handles pinch zoom with two fingers", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Start pinch + act(() => { + result.current.handleTouchStart({ + touches: [ + { clientX: 100, clientY: 100 }, + { clientX: 200, clientY: 200 }, + ], + }); + }); + + // Spread fingers (pinch out = zoom in) + act(() => { + result.current.handleTouchMove({ + touches: [ + { clientX: 50, clientY: 50 }, + { clientX: 250, clientY: 250 }, + ], + }); + }); + + expect(result.current.scale).toBeGreaterThan(1); + }); + + it("handles swipe to navigate when not zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + expect(result.current.currentIndex).toBe(1); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 200, clientY: 100 }], + }); + }); + + // Swipe left (next) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 100 }], + }); + }); + + expect(result.current.currentIndex).toBe(2); + }); + + it("handles swipe right to go previous", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // Swipe right (previous) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 200 }], + }); + }); + + expect(result.current.currentIndex).toBe(0); + }); + + it("handles tap to zoom on touch", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // End touch at same position (tap) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 100 }], + }); + }); + + expect(result.current.scale).toBe(1.5); + }); + + it("syncs carousel when exiting fullscreen", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 2); + + // Exit fullscreen + Object.defineProperty(document, "fullscreenElement", { + value: null, + writable: true, + configurable: true, + }); + + await act(async () => { + document.dispatchEvent(new Event("fullscreenchange")); + }); + + expect(mockCarouselApi.scrollTo).toHaveBeenCalledWith(2, true); + expect(result.current.isFullscreen).toBe(false); + }); + + it("handles keyboard navigation in fullscreen", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + // Press ArrowRight + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); + }); + + expect(result.current.currentIndex).toBe(2); + + // Press ArrowLeft + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft" })); + }); + + expect(result.current.currentIndex).toBe(1); + }); + + it("handles mouse down correctly when zoomed", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + clientX: 100, + clientY: 100, + }; + + act(() => { + result.current.handleMouseDown(mockEvent); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles mouse up correctly", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.handleMouseUp(); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles image click when not fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.handleImageClick(); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles touch start with single touch", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + touches: [{ clientX: 100, clientY: 100 }], + }; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles touch start with two touches for pinch", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + touches: [ + { clientX: 100, clientY: 100 }, + { clientX: 200, clientY: 200 }, + ], + }; + + act(() => { + result.current.handleTouchStart(mockEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles touch move when not fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + touches: [{ clientX: 150, clientY: 150 }], + }; + + act(() => { + result.current.handleTouchMove(mockEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + it("handles touch end when not fullscreen", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + changedTouches: [{ clientX: 150 }], + }; + + act(() => { + result.current.handleTouchEnd(mockEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + it("exposes containerRef", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + expect(result.current.containerRef).toBeDefined(); + expect(result.current.containerRef.current).toBe(null); + }); + + it("exposes isDragging ref", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + expect(result.current.isDragging).toBeDefined(); + expect(result.current.isDragging.current).toBe(false); + }); + + it("handles mouse move when not dragging", () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockEvent = { + clientX: 200, + clientY: 200, + }; + + act(() => { + result.current.handleMouseMove(mockEvent); + }); + + expect(result.current.position).toEqual({ x: 0, y: 0 }); + }); + + it("supports webkit fullscreen API", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + const mockContainer = document.createElement("div"); + mockContainer.requestFullscreen = undefined; + mockContainer.webkitRequestFullscreen = vi.fn(); + result.current.containerRef.current = mockContainer; + + await act(async () => { + result.current.open(0); + }); + + expect(mockContainer.webkitRequestFullscreen).toHaveBeenCalled(); + }); + + it("supports webkit exit fullscreen API", () => { + document.exitFullscreen = undefined; + document.webkitExitFullscreen = vi.fn(); + + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + act(() => { + result.current.close(); + }); + + expect(document.webkitExitFullscreen).toHaveBeenCalled(); + }); + + it("resets position when cycling back to 1x zoom", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Cycle through all zoom levels back to 1 + act(() => { + result.current.handleImageClick(); // 1 -> 1.5 + }); + + act(() => { + result.current.handleImageClick(); // 1.5 -> 2.5 + }); + + act(() => { + result.current.handleImageClick(); // 2.5 -> 4 + }); + + act(() => { + result.current.handleImageClick(); // 4 -> 1 + }); + + expect(result.current.scale).toBe(1); + expect(result.current.position).toEqual({ x: 0, y: 0 }); + }); + + it("does not navigate on small swipes", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result, 1); + + // Start touch + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + // Small swipe (less than 50px threshold) + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 120 }], + }); + }); + + // Should stay on same image but zoom (tap) + expect(result.current.currentIndex).toBe(1); + }); + + it("handles touch end when dragging returns early", async () => { + const { result } = renderHook(() => + useFullscreenGallery(mockImages, mockCarouselApi) + ); + + await setupFullscreen(result); + + // Zoom in + act(() => { + result.current.handleImageClick(); + }); + + // Start drag + act(() => { + result.current.handleTouchStart({ + touches: [{ clientX: 100, clientY: 100 }], + }); + }); + + const initialScale = result.current.scale; + + // End while still dragging + act(() => { + result.current.handleTouchEnd({ + changedTouches: [{ clientX: 100 }], + }); + }); + + // Should not cycle zoom + expect(result.current.scale).toBe(initialScale); + }); +}); diff --git a/src/pages/ContactPage.jsx b/src/pages/ContactPage.jsx index d4faa9d..9431b8a 100644 --- a/src/pages/ContactPage.jsx +++ b/src/pages/ContactPage.jsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Textarea } from "@/components/ui/Textarea"; import ContactErrorDialog from "@/components/contact/ContactErrorDialog"; -import { SEO } from "@/components/common/SEO"; +import { Seo } from "@/components/common/Seo"; import { Form, FormControl, @@ -78,7 +78,7 @@ const ContactPage = () => { return (
- { return (
- { return (
- +
{ @@ -23,7 +23,7 @@ const NotFoundPage = () => { return (
- diff --git a/src/pages/PortfolioPage.jsx b/src/pages/PortfolioPage.jsx index aa9a1f6..ee01b44 100644 --- a/src/pages/PortfolioPage.jsx +++ b/src/pages/PortfolioPage.jsx @@ -7,46 +7,44 @@ import { CarouselNext, } from "@/components/ui/Carousel"; import { CTASection } from "@/components/common/CTASection"; -import Lightbox from "@/components/ui/Lightbox"; import SocialLinks from "@/components/common/SocialLinks"; -import { SEO } from "@/components/common/SEO"; +import { Seo } from "@/components/common/Seo"; import { portfolioImages } from "@/data/portfolioImages"; +import { useFullscreenGallery } from "@/hooks/useFullscreenGallery"; +import FullscreenViewer from "@/components/ui/FullscreenViewer"; const PortfolioPage = () => { const [api, setApi] = useState(null); const [current, setCurrent] = useState(0); - const [lightboxIndex, setLightboxIndex] = useState(null); - const isLightboxOpen = lightboxIndex !== null; + const gallery = useFullscreenGallery(portfolioImages, api); - // Keyboard controls for carousel + // Carousel keyboard controls (when not fullscreen) useEffect(() => { - if (isLightboxOpen || !api) return; + if (gallery.isFullscreen || !api) return; const handleKeyDown = (e) => { if (e.key === "ArrowLeft") api.scrollPrev(); else if (e.key === "ArrowRight") api.scrollNext(); - else if (e.key === "Enter") setLightboxIndex(current); + else if (e.key === "Enter") gallery.open(current); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [isLightboxOpen, api, current]); + }, [gallery.isFullscreen, api, current, gallery]); // Sync carousel state useEffect(() => { if (!api) return; - const onSelect = () => setCurrent(api.selectedScrollSnap()); onSelect(); api.on("select", onSelect); - return () => api.off("select", onSelect); }, [api]); return (
- {
); }; diff --git a/src/pages/PortfolioPage.test.jsx b/src/pages/PortfolioPage.test.jsx index 87ab205..5b6640f 100644 --- a/src/pages/PortfolioPage.test.jsx +++ b/src/pages/PortfolioPage.test.jsx @@ -1,6 +1,6 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import PortfolioPage from "./PortfolioPage"; const renderWithProviders = (component) => { @@ -8,6 +8,22 @@ const renderWithProviders = (component) => { }; describe("PortfolioPage", () => { + let originalRequestFullscreen; + let originalExitFullscreen; + + beforeEach(() => { + originalRequestFullscreen = Element.prototype.requestFullscreen; + originalExitFullscreen = document.exitFullscreen; + + Element.prototype.requestFullscreen = vi.fn().mockResolvedValue(); + document.exitFullscreen = vi.fn().mockResolvedValue(); + }); + + afterEach(() => { + Element.prototype.requestFullscreen = originalRequestFullscreen; + document.exitFullscreen = originalExitFullscreen; + }); + it("renders the page title and description", () => { renderWithProviders(); @@ -39,4 +55,45 @@ describe("PortfolioPage", () => { "https://www.facebook.com/chrisert.pt/" ); }); + + it("renders carousel navigation buttons", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /previous slide/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /next slide/i })).toBeInTheDocument(); + }); + + it("renders dot indicators for navigation", () => { + renderWithProviders(); + + const dotButtons = screen.getAllByRole("button", { name: /ir para projeto/i }); + expect(dotButtons.length).toBeGreaterThan(0); + }); + + it("renders CTA section", () => { + renderWithProviders(); + + expect(screen.getByText(/gostou do que viu/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /pedir orçamento/i })).toBeInTheDocument(); + }); + + it("renders fullscreen viewer container", () => { + renderWithProviders(); + + expect( + screen.getByLabelText("Visualizador de imagens em ecrã inteiro") + ).toBeInTheDocument(); + }); + + it("opens fullscreen when clicking on image", async () => { + renderWithProviders(); + + const imageButtons = screen.getAllByRole("button", { name: /ver projeto/i }); + + await act(async () => { + fireEvent.click(imageButtons[0]); + }); + + expect(Element.prototype.requestFullscreen).toHaveBeenCalled(); + }); }); diff --git a/src/pages/ServicesPage.jsx b/src/pages/ServicesPage.jsx index bda7003..6860c28 100644 --- a/src/pages/ServicesPage.jsx +++ b/src/pages/ServicesPage.jsx @@ -1,11 +1,11 @@ import { CTASection } from "@/components/common/CTASection"; -import { SEO } from "@/components/common/SEO"; +import { Seo } from "@/components/common/Seo"; import { services, eticsBenefits, processSteps } from "@/data/servicesData"; const ServicesPage = () => { return (
-