diff --git a/.github/workflows/github-pages-deploy.yml b/.github/workflows/github-pages-deploy.yml index 40f94c2..401cc57 100644 --- a/.github/workflows/github-pages-deploy.yml +++ b/.github/workflows/github-pages-deploy.yml @@ -8,7 +8,7 @@ on: concurrency: group: pages - cancel-in-progress: false + cancel-in-progress: true jobs: test: @@ -30,7 +30,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Build run: npm run build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0521f7c..83f5eb6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run linting run: npm run lint diff --git a/.github/workflows/netlify-deploy.yml b/.github/workflows/netlify-deploy.yml index 21e1f73..580a4e2 100644 --- a/.github/workflows/netlify-deploy.yml +++ b/.github/workflows/netlify-deploy.yml @@ -7,7 +7,7 @@ on: concurrency: group: netlify-production - cancel-in-progress: false + cancel-in-progress: true jobs: call-test: @@ -33,7 +33,7 @@ jobs: - name: Install and Build run: | - npm ci + npm ci --ignore-scripts npm run build - name: Deploy to Netlify diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index ce26975..6fb8b9a 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -24,7 +24,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run security audit run: npm audit --audit-level=moderate diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 5dd00b9..682e768 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -24,7 +24,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run tests with coverage run: npm run test:coverage diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72841e3..5dd9d9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run tests run: npm test diff --git a/eslint.config.js b/eslint.config.js index b8b8b7d..d89d552 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh"; import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(["dist"]), + globalIgnores(["dist", "coverage"]), { files: ["**/*.{js,jsx}"], extends: [ diff --git a/src/App.jsx b/src/App.jsx index 28770f6..a62e927 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,8 @@ import "./App.css"; import { Routes, Route } from "react-router-dom"; import { Navbar } from "@/components/layout/Navbar"; import Footer from "@/components/layout/Footer"; -import { BackToTop } from "@/components/ui/BackToTop"; +import { BackToTop } from "@/components/common/BackToTop"; +import { ScrollRestoration } from "@/components/common/ScrollRestoration"; import HomePage from "./pages/HomePage"; import ServicesPage from "./pages/ServicesPage"; import PortfolioPage from "./pages/PortfolioPage"; @@ -13,6 +14,7 @@ import NotFoundPage from "./pages/NotFoundPage"; function App() { return (
+
diff --git a/src/components/ui/BackToTop.jsx b/src/components/common/BackToTop.jsx similarity index 100% rename from src/components/ui/BackToTop.jsx rename to src/components/common/BackToTop.jsx diff --git a/src/components/ui/BackToTop.test.jsx b/src/components/common/BackToTop.test.jsx similarity index 100% rename from src/components/ui/BackToTop.test.jsx rename to src/components/common/BackToTop.test.jsx diff --git a/src/components/ui/CTASection.jsx b/src/components/common/CTASection.jsx similarity index 100% rename from src/components/ui/CTASection.jsx rename to src/components/common/CTASection.jsx diff --git a/src/components/SEO.jsx b/src/components/common/SEO.jsx similarity index 99% rename from src/components/SEO.jsx rename to src/components/common/SEO.jsx index dc7a651..af73b86 100644 --- a/src/components/SEO.jsx +++ b/src/components/common/SEO.jsx @@ -60,4 +60,5 @@ SEO.propTypes = { ogType: PropTypes.string, }; +export { SEO }; export default SEO; diff --git a/src/components/common/SEO.test.jsx b/src/components/common/SEO.test.jsx new file mode 100644 index 0000000..4a9ea4a --- /dev/null +++ b/src/components/common/SEO.test.jsx @@ -0,0 +1,103 @@ +import { render } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { SEO } from "./SEO"; + +describe("SEO", () => { + it("renders title with site name", () => { + render(); + expect(document.title).toBe("Contactos | Chrisert"); + }); + + it("renders default title when no title prop provided", () => { + render(); + expect(document.title).toBe( + "Chrisert - Especialistas em ETICS e Isolamento Térmico" + ); + }); + + it("renders meta description", () => { + render(); + const metaDescription = document.querySelector('meta[name="description"]'); + expect(metaDescription).toHaveAttribute("content", "Descrição personalizada"); + }); + + it("renders default description when not provided", () => { + render(); + const metaDescription = document.querySelector('meta[name="description"]'); + expect(metaDescription?.getAttribute("content")).toContain("ETICS"); + }); + + it("renders meta keywords", () => { + render(); + const metaKeywords = document.querySelector('meta[name="keywords"]'); + expect(metaKeywords).toHaveAttribute("content", "isolamento, capoto"); + }); + + it("renders canonical URL with base URL", () => { + render(); + const canonicalLink = document.querySelector('link[rel="canonical"]'); + expect(canonicalLink).toHaveAttribute( + "href", + "https://chrisert.pt/contactos" + ); + }); + + it("renders default canonical URL when not provided", () => { + render(); + const canonicalLink = document.querySelector('link[rel="canonical"]'); + expect(canonicalLink).toHaveAttribute("href", "https://chrisert.pt"); + }); + + it("renders Open Graph meta tags", () => { + render( + + ); + + const ogTitle = document.querySelector('meta[property="og:title"]'); + const ogDescription = document.querySelector( + 'meta[property="og:description"]' + ); + const ogUrl = document.querySelector('meta[property="og:url"]'); + const ogType = document.querySelector('meta[property="og:type"]'); + const ogSiteName = document.querySelector('meta[property="og:site_name"]'); + + expect(ogTitle).toHaveAttribute("content", "Portfolio | Chrisert"); + expect(ogDescription).toHaveAttribute("content", "Os nossos trabalhos"); + expect(ogUrl).toHaveAttribute("content", "https://chrisert.pt/portfolio"); + expect(ogType).toHaveAttribute("content", "website"); + expect(ogSiteName).toHaveAttribute("content", "Chrisert"); + }); + + it("renders custom ogType", () => { + render(); + const ogType = document.querySelector('meta[property="og:type"]'); + expect(ogType).toHaveAttribute("content", "article"); + }); + + it("renders custom ogImage", () => { + render(); + const ogImage = document.querySelector('meta[property="og:image"]'); + expect(ogImage).toHaveAttribute("content", "https://example.com/image.jpg"); + }); + + it("renders Twitter meta tags", () => { + render(); + + const twitterCard = document.querySelector('meta[name="twitter:card"]'); + const twitterTitle = document.querySelector('meta[name="twitter:title"]'); + const twitterDescription = document.querySelector( + 'meta[name="twitter:description"]' + ); + + expect(twitterCard).toHaveAttribute("content", "summary_large_image"); + expect(twitterTitle).toHaveAttribute("content", "FAQ | Chrisert"); + expect(twitterDescription).toHaveAttribute( + "content", + "Perguntas frequentes" + ); + }); +}); diff --git a/src/components/common/ScrollRestoration.jsx b/src/components/common/ScrollRestoration.jsx new file mode 100644 index 0000000..6437ecb --- /dev/null +++ b/src/components/common/ScrollRestoration.jsx @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +/** + * Component that resets scroll position on route change. + * Must be placed inside a Router component. + */ +export const ScrollRestoration = () => { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +}; + +export default ScrollRestoration; diff --git a/src/components/common/ScrollRestoration.test.jsx b/src/components/common/ScrollRestoration.test.jsx new file mode 100644 index 0000000..94f7a7a --- /dev/null +++ b/src/components/common/ScrollRestoration.test.jsx @@ -0,0 +1,68 @@ +import { render } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { ScrollRestoration } from "./ScrollRestoration"; + +// Helper to render with router at a specific path +const renderWithRouter = (initialPath = "/") => { + return render( + + + + ); +}; + +describe("ScrollRestoration", () => { + let scrollToMock; + + beforeEach(() => { + scrollToMock = vi.fn(); + window.scrollTo = scrollToMock; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders nothing (returns null)", () => { + const { container } = renderWithRouter("/"); + expect(container).toBeEmptyDOMElement(); + }); + + it("scrolls to top on initial render", () => { + renderWithRouter("/contactos"); + expect(scrollToMock).toHaveBeenCalledWith(0, 0); + }); + + it("scrolls to top when pathname changes", () => { + // Verify scroll is called for different paths + // Each render at a new path triggers scrollTo + renderWithRouter("/"); + expect(scrollToMock).toHaveBeenCalledWith(0, 0); + + // Reset and render at different path + scrollToMock.mockClear(); + renderWithRouter("/contactos"); + expect(scrollToMock).toHaveBeenCalledWith(0, 0); + }); + + it("does not scroll again if pathname remains the same", () => { + const { rerender } = render( + + + + ); + + expect(scrollToMock).toHaveBeenCalledTimes(1); + + // Re-render with same path + rerender( + + + + ); + + // Should still be 1 because pathname didn't change + expect(scrollToMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/portfolio/SocialLinks.jsx b/src/components/common/SocialLinks.jsx similarity index 100% rename from src/components/portfolio/SocialLinks.jsx rename to src/components/common/SocialLinks.jsx diff --git a/src/components/layout/Navbar.jsx b/src/components/layout/Navbar.jsx index cf9ba67..ac65c8f 100644 --- a/src/components/layout/Navbar.jsx +++ b/src/components/layout/Navbar.jsx @@ -112,7 +112,7 @@ export const Navbar = React.forwardRef( ref={combinedRef} style={{ colorScheme: "only light" }} className={cn( - "sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur @supports-[backdrop-filter]:bg-background/60 px-4 md:px-6 [&_*]:no-underline", + "sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur @supports-[backdrop-filter]:bg-background/60 px-4 md:px-6 **:no-underline", className )} {...props} @@ -138,8 +138,11 @@ export const Navbar = React.forwardRef( - {navigationLinks.map((link, index) => ( - + {navigationLinks.map((link) => ( + - {navigationLinks.map((link, index) => ( - + {navigationLinks.map((link) => ( + ({ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }), + [ + carouselRef, + api, + opts, + orientation, + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + ] + ); + return ( - -
+
{children} -
+
); } @@ -134,7 +146,6 @@ function CarouselItem({ className, ...props }) { return (
{ + const contextValue = React.useMemo( + () => ({ name: props.name }), + [props.name] + ); + return ( - + ); @@ -51,9 +56,10 @@ const FormItemContext = React.createContext({}); function FormItem({ className, ...props }) { const id = React.useId(); + const contextValue = React.useMemo(() => ({ id }), [id]); return ( - +
{ }, [navigate, onClose]); return ( - <> - {/* Backdrop - using button for accessibility */} +
+ {/* Backdrop - semantic button element */} + /> - + {/* Close button */} + - + {/* Previous button */} + -
- { -
+ {/* Next button */} + - + + {/* Image container */} +
+ { +
+
); }; diff --git a/src/components/ui/Lightbox.test.jsx b/src/components/ui/Lightbox.test.jsx new file mode 100644 index 0000000..53d362f --- /dev/null +++ b/src/components/ui/Lightbox.test.jsx @@ -0,0 +1,210 @@ +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/main.jsx b/src/main.jsx index 6cb119c..9cfa7ae 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -4,7 +4,7 @@ import { BrowserRouter } from "react-router-dom"; import "./App.css"; import App from "./App.jsx"; -// Use "/chrisert" for GitHub Pages, "/" for Netlify +// BASE_URL comes from vite.config base: use "/chrisert" for GitHub Pages, "/" for Netlify const basename = import.meta.env.BASE_URL.replace(/\/$/, "") || "/"; // Handle GitHub Pages SPA redirect @@ -12,7 +12,7 @@ const redirect = sessionStorage.getItem("redirect"); if (redirect) { sessionStorage.removeItem("redirect"); // Redirect to the original path the user requested - window.history.replaceState(null, "", redirect); + globalThis.history.replaceState(null, "", redirect); } createRoot(document.getElementById("root")).render( diff --git a/src/pages/ContactPage.jsx b/src/pages/ContactPage.jsx index a89d5c0..d4faa9d 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/SEO"; +import { SEO } from "@/components/common/SEO"; import { Form, FormControl, @@ -21,9 +21,7 @@ const contactFormSchema = z.object({ nome: z.string().min(2, { message: "O nome deve ter pelo menos 2 caracteres.", }), - email: z.string().email({ - message: "Por favor, insira um email válido.", - }), + email: z.email("Por favor, insira um email válido."), telefone: z .string() .min(9, { diff --git a/src/pages/FAQPage.jsx b/src/pages/FAQPage.jsx index bec2d6a..55128c0 100644 --- a/src/pages/FAQPage.jsx +++ b/src/pages/FAQPage.jsx @@ -1,11 +1,11 @@ -import { CTASection } from "@/components/ui/CTASection"; +import { CTASection } from "@/components/common/CTASection"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import SEO from "@/components/SEO"; +import { SEO } from "@/components/common/SEO"; import { faqCategories, mythsAndFacts } from "@/data/faqData"; const FAQPage = () => { @@ -44,10 +44,10 @@ const FAQPage = () => { collapsible className="border rounded-lg px-4" > - {category.questions.map((item, index) => ( + {category.questions.map((item) => ( {item.question} @@ -78,9 +78,9 @@ const FAQPage = () => {
- {mythsAndFacts.map((item, index) => ( + {mythsAndFacts.map((item) => (
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index c541062..354bafe 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,8 +1,8 @@ import { Link } from "react-router-dom"; import { Button } from "@/components/ui/Button"; import { Badge } from "@/components/ui/Badge"; -import { CTASection } from "@/components/ui/CTASection"; -import SEO from "@/components/SEO"; +import { CTASection } from "@/components/common/CTASection"; +import { SEO } from "@/components/common/SEO"; import { Award, Users, ThermometerSun } from "lucide-react"; const heroImage = new URL("/hero-work.jpg", import.meta.url).href; diff --git a/src/pages/NotFoundPage.jsx b/src/pages/NotFoundPage.jsx index c30d8da..34eb50c 100644 --- a/src/pages/NotFoundPage.jsx +++ b/src/pages/NotFoundPage.jsx @@ -1,7 +1,7 @@ import { Link } from "react-router-dom"; import { Button } from "@/components/ui/Button"; import { BarsScaleFadeIcon } from "@/components/ui/icons/BarsScaleFadeIcon"; -import SEO from "@/components/SEO"; +import { SEO } from "@/components/common/SEO"; import { useEffect, useState } from "react"; const NotFoundPage = () => { @@ -10,7 +10,7 @@ const NotFoundPage = () => { useEffect(() => { if (count <= 0) { - window.location.replace(basename); + globalThis.location.replace(basename); return; } diff --git a/src/pages/PortfolioPage.jsx b/src/pages/PortfolioPage.jsx index 915fe92..aa9a1f6 100644 --- a/src/pages/PortfolioPage.jsx +++ b/src/pages/PortfolioPage.jsx @@ -6,10 +6,10 @@ import { CarouselPrevious, CarouselNext, } from "@/components/ui/Carousel"; -import { CTASection } from "@/components/ui/CTASection"; -import Lightbox from "@/components/portfolio/Lightbox"; -import SocialLinks from "@/components/portfolio/SocialLinks"; -import SEO from "@/components/SEO"; +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 { portfolioImages } from "@/data/portfolioImages"; const PortfolioPage = () => { @@ -86,9 +86,9 @@ const PortfolioPage = () => { {/* Dot indicators */}
- {portfolioImages.map((_, index) => ( + {portfolioImages.map((project, index) => (