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
4 changes: 2 additions & 2 deletions .github/workflows/github-pages-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

concurrency:
group: pages
cancel-in-progress: false
cancel-in-progress: true

jobs:
test:
Expand All @@ -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
4 changes: 2 additions & 2 deletions .github/workflows/netlify-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

concurrency:
group: netlify-production
cancel-in-progress: false
cancel-in-progress: true

jobs:
call-test:
Expand All @@ -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
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
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.
1 change: 1 addition & 0 deletions src/components/SEO.jsx → src/components/common/SEO.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,5 @@ SEO.propTypes = {
ogType: PropTypes.string,
};

export { SEO };
export default SEO;
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);
});
});
13 changes: 8 additions & 5 deletions src/components/layout/Navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -138,8 +138,11 @@ export const Navbar = React.forwardRef(
<PopoverContent align="start" className="w-48 p-2">
<NavigationMenu className="max-w-none">
<NavigationMenuList className="flex-col items-start gap-1">
{navigationLinks.map((link, index) => (
<NavigationMenuItem key={index} className="w-full">
{navigationLinks.map((link) => (
<NavigationMenuItem
key={link.href}
className="w-full"
>
<Link
to={link.href}
className={cn(
Expand All @@ -160,8 +163,8 @@ export const Navbar = React.forwardRef(
) : (
<NavigationMenu className="flex">
<NavigationMenuList className="gap-1">
{navigationLinks.map((link, index) => (
<NavigationMenuItem key={index}>
{navigationLinks.map((link) => (
<NavigationMenuItem key={link.href}>
<Link
to={link.href}
className={cn(
Expand Down
45 changes: 28 additions & 17 deletions src/components/ui/Carousel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,30 +80,42 @@ function Carousel({
};
}, [api, onSelect]);

const contextValue = React.useMemo(
() => ({
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}),
[
carouselRef,
api,
opts,
orientation,
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
]
);

return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
<CarouselContext.Provider value={contextValue}>
<section
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
aria-label="Carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</section>
</CarouselContext.Provider>
);
}
Expand Down Expand Up @@ -134,7 +146,6 @@ function CarouselItem({ className, ...props }) {

return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
Expand Down
Loading