Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/github-pages-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
cache: "npm"

- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts

- name: Build
run: npm run build
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/netlify-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:

- name: Install and Build
run: |
npm ci
npm ci --ignore-scripts
npm run build

- name: Deploy to Netlify
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/sonarqube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
cache: "npm"

- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts

- name: Run tests
run: npm test
Expand Down
4 changes: 3 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,6 +14,7 @@ import NotFoundPage from "./pages/NotFoundPage";
function App() {
return (
<div className="flex flex-col min-h-screen">
<ScrollRestoration />
<div className="print:hidden">
<Navbar />
</div>
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
103 changes: 103 additions & 0 deletions src/components/common/SEO.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<SEO title="Contactos" />);
expect(document.title).toBe("Contactos | Chrisert");
});

it("renders default title when no title prop provided", () => {
render(<SEO />);
expect(document.title).toBe(
"Chrisert - Especialistas em ETICS e Isolamento Térmico"
);
});

it("renders meta description", () => {
render(<SEO description="Descrição personalizada" />);
const metaDescription = document.querySelector('meta[name="description"]');
expect(metaDescription).toHaveAttribute("content", "Descrição personalizada");
});

it("renders default description when not provided", () => {
render(<SEO />);
const metaDescription = document.querySelector('meta[name="description"]');
expect(metaDescription?.getAttribute("content")).toContain("ETICS");
});

it("renders meta keywords", () => {
render(<SEO keywords="isolamento, capoto" />);
const metaKeywords = document.querySelector('meta[name="keywords"]');
expect(metaKeywords).toHaveAttribute("content", "isolamento, capoto");
});

it("renders canonical URL with base URL", () => {
render(<SEO canonical="/contactos" />);
const canonicalLink = document.querySelector('link[rel="canonical"]');
expect(canonicalLink).toHaveAttribute(
"href",
"https://chrisert.pt/contactos"
);
});

it("renders default canonical URL when not provided", () => {
render(<SEO />);
const canonicalLink = document.querySelector('link[rel="canonical"]');
expect(canonicalLink).toHaveAttribute("href", "https://chrisert.pt");
});

it("renders Open Graph meta tags", () => {
render(
<SEO
title="Portfolio"
description="Os nossos trabalhos"
canonical="/portfolio"
/>
);

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(<SEO ogType="article" />);
const ogType = document.querySelector('meta[property="og:type"]');
expect(ogType).toHaveAttribute("content", "article");
});

it("renders custom ogImage", () => {
render(<SEO ogImage="https://example.com/image.jpg" />);
const ogImage = document.querySelector('meta[property="og:image"]');
expect(ogImage).toHaveAttribute("content", "https://example.com/image.jpg");
});

it("renders Twitter meta tags", () => {
render(<SEO title="FAQ" description="Perguntas frequentes" />);

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"
);
});
});
18 changes: 18 additions & 0 deletions src/components/common/ScrollRestoration.jsx
Original file line number Diff line number Diff line change
@@ -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;
68 changes: 68 additions & 0 deletions src/components/common/ScrollRestoration.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={[initialPath]}>
<ScrollRestoration />
</MemoryRouter>
);
};

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(
<MemoryRouter initialEntries={["/servicos"]}>
<ScrollRestoration />
</MemoryRouter>
);

expect(scrollToMock).toHaveBeenCalledTimes(1);

// Re-render with same path
rerender(
<MemoryRouter initialEntries={["/servicos"]}>
<ScrollRestoration />
</MemoryRouter>
);

// Should still be 1 because pathname didn't change
expect(scrollToMock).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -44,58 +44,59 @@ const Lightbox = ({ images, currentIndex, onClose, onNavigate }) => {
}, [navigate, onClose]);

return (
<>
{/* Backdrop - using button for accessibility */}
<div className="fixed inset-0 z-50 flex items-center justify-center animate-in fade-in duration-200">
{/* Backdrop - semantic button element */}
<button
type="button"
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center animate-in fade-in duration-200 cursor-default"
className="absolute inset-0 bg-black/95 cursor-default"
onClick={onClose}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
aria-label="Fechar lightbox"
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10"
aria-label="Fechar"
>
<X className="size-8" />
</button>
/>

<button
onClick={(e) => {
e.stopPropagation();
navigate("prev");
}}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white transition-colors z-10 p-2"
aria-label="Foto anterior"
>
<ChevronLeft className="size-10" />
</button>
{/* Close button */}
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10"
aria-label="Fechar"
>
<X className="size-8" />
</button>

<button
onClick={(e) => {
e.stopPropagation();
navigate("next");
}}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white transition-colors z-10 p-2"
aria-label="Próxima foto"
>
<ChevronRight className="size-10" />
</button>
{/* Previous button */}
<button
type="button"
onClick={() => navigate("prev")}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white transition-colors z-10 p-2"
aria-label="Foto anterior"
>
<ChevronLeft className="size-10" />
</button>

<figure className="aspect-3/4 max-h-[90vh] max-w-[90vw] overflow-hidden rounded-lg animate-in zoom-in-95 duration-200 block m-0">
<img
src={images[currentIndex].image}
alt={
images[currentIndex].alt ||
`Projeto ${currentIndex + 1} de ${images.length}`
}
className="w-full h-full object-cover pointer-events-none"
/>
</figure>
{/* Next button */}
<button
type="button"
onClick={() => navigate("next")}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white transition-colors z-10 p-2"
aria-label="Próxima foto"
>
<ChevronRight className="size-10" />
</button>
</>

{/* Image container */}
<figure className="relative z-10 aspect-3/4 max-h-[90vh] max-w-[90vw] overflow-hidden rounded-lg animate-in zoom-in-95 duration-200 block m-0">
<img
src={images[currentIndex].image}
alt={
images[currentIndex].alt ||
`Projeto ${currentIndex + 1} de ${images.length}`
}
className="w-full h-full object-cover"
/>
</figure>
</div>
);
};

Expand Down
Loading