Skip to content

Commit 7c2f258

Browse files
Merge pull request #9 from JoaoGabriellBR/feature/optimizing-application-seo
Feature/sitemap via code
2 parents 8582e26 + 8917f40 commit 7c2f258

File tree

12 files changed

+203
-143
lines changed

12 files changed

+203
-143
lines changed

app/[locale]/layout.tsx

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next";
1111
import { Analytics } from "@vercel/analytics/next";
1212
import Script from "next/script";
1313
import { SITE, type Locale } from "@/config/site";
14-
import {
15-
buildAlternates,
16-
buildOgImagePath,
17-
getLocalizedMeta,
18-
ogLocale,
19-
} from "@/lib/seo";
14+
import { ogLocale } from "@/lib/seo";
2015

2116
const poppins = Poppins({
2217
subsets: ["latin"],
@@ -29,16 +24,16 @@ type LayoutParams = { params: { locale: Locale } };
2924

3025
export function generateMetadata({ params }: LayoutParams): Metadata {
3126
const { locale } = params;
32-
const meta = getLocalizedMeta(locale, "home");
33-
const alternates = buildAlternates(locale, "/");
34-
const url = alternates.canonical;
35-
3627
return {
3728
metadataBase: SITE.metadataBase,
38-
title: meta.title,
39-
description: meta.description,
40-
keywords: meta.keywords,
41-
alternates,
29+
title: {
30+
default: SITE.siteName,
31+
template: `%s | ${SITE.siteName}`,
32+
},
33+
description: SITE.defaultDescription,
34+
twitter: {
35+
card: "summary_large_image",
36+
},
4237
robots: { index: true, follow: true },
4338
icons: {
4439
icon: "/favicon.ico",
@@ -48,18 +43,9 @@ export function generateMetadata({ params }: LayoutParams): Metadata {
4843
},
4944
// manifest: "/site.webmanifest",
5045
openGraph: {
51-
title: meta.title,
52-
description: meta.description,
53-
url,
5446
siteName: SITE.siteName,
5547
type: "website",
5648
locale: ogLocale[locale],
57-
images: [
58-
{
59-
url: buildOgImagePath("home", locale),
60-
alt: meta.title,
61-
},
62-
],
6349
},
6450
};
6551
}

app/robots.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { MetadataRoute } from "next";
2+
import { SITE } from "@/config/site";
3+
4+
export default function robots(): MetadataRoute.Robots {
5+
const base = SITE.metadataBase.toString().replace(/\/$/, "");
6+
return {
7+
rules: {
8+
userAgent: "*",
9+
allow: "/",
10+
},
11+
sitemap: `${base}/sitemap.xml`,
12+
host: base,
13+
};
14+
}
15+

app/sitemap.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { MetadataRoute } from "next";
2+
import { SITE } from "@/config/site";
3+
import { projects } from "@/utils/projects";
4+
5+
const staticPaths = [
6+
"",
7+
"/about",
8+
"/certifications",
9+
"/projects",
10+
"/contact",
11+
] as const;
12+
13+
export default function sitemap(): MetadataRoute.Sitemap {
14+
const now = new Date();
15+
16+
const entries: MetadataRoute.Sitemap = [];
17+
18+
for (const locale of SITE.locales) {
19+
// Static routes per locale
20+
for (const path of staticPaths) {
21+
const url = new URL(`/${locale}${path}`, SITE.metadataBase).toString();
22+
const priority = path === "" ? 1 : path === "/projects" ? 0.8 : 0.7;
23+
entries.push({
24+
url,
25+
lastModified: now,
26+
changeFrequency: "weekly",
27+
priority,
28+
});
29+
}
30+
31+
// Dynamic project detail pages per locale
32+
for (const p of projects) {
33+
const url = new URL(
34+
`/${locale}/projects/${encodeURIComponent(p.name)}`,
35+
SITE.metadataBase
36+
).toString();
37+
entries.push({
38+
url,
39+
lastModified: now,
40+
changeFrequency: "monthly",
41+
priority: 0.6,
42+
});
43+
}
44+
}
45+
46+
return entries;
47+
}
48+
49+
// Revalida o sitemap diariamente (24h)
50+
export const revalidate = 60 * 60 * 24;

components/page-with-loader.tsx

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use client";
2-
import { useEffect, useState, ReactNode, Suspense } from "react";
2+
import { useEffect, useRef, useState, ReactNode, Suspense } from "react";
33
import { AnimatePresence } from "framer-motion";
44
import { usePathname } from "@/i18n/navigation";
55
import Preloader from "./preloader";
@@ -9,79 +9,96 @@ type PageWithLoaderProps = {
99
children: ReactNode;
1010
};
1111

12+
type NavigatorWithConnection = Navigator & {
13+
connection?: {
14+
saveData?: boolean;
15+
};
16+
};
17+
18+
function prefersReducedMotion(): boolean {
19+
if (
20+
typeof window === "undefined" ||
21+
typeof window.matchMedia !== "function"
22+
) {
23+
return false;
24+
}
25+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
26+
}
27+
28+
function saveDataEnabled(): boolean {
29+
if (typeof navigator === "undefined") return false;
30+
const nav = navigator as NavigatorWithConnection;
31+
return Boolean(nav.connection?.saveData);
32+
}
33+
1234
export default function PageWithLoader({
1335
text,
1436
children,
1537
}: PageWithLoaderProps) {
1638
const pathname = usePathname();
17-
const [isPreloaderVisible, setIsPreloaderVisible] = useState(true);
18-
const [shouldShowGreetings, setShouldShowGreetings] = useState(false);
19-
const [isContentReady, setIsContentReady] = useState(false);
39+
// Inicialmente oculto para não bloquear o carregamento
40+
const [isPreloaderVisible, setIsPreloaderVisible] = useState<boolean>(false);
41+
const [shouldShowGreetings, setShouldShowGreetings] =
42+
useState<boolean>(false);
43+
const isInitialMount = useRef<boolean>(true);
2044

21-
// Gerencia a lógica de saudações na página inicial
45+
// Exibe saudações ocasionais apenas na home em navegação "dura" e respeita preferências
2246
useEffect(() => {
2347
const isHome = pathname === "/";
24-
const navEntries = performance.getEntriesByType(
25-
"navigation"
26-
) as PerformanceNavigationTiming[];
48+
const navEntries = performance.getEntriesByType("navigation");
2749
const isHardNavigation =
28-
navEntries.length && navEntries[0].type === "navigate";
50+
navEntries.length > 0 &&
51+
(navEntries[0] as PerformanceNavigationTiming).type === "navigate";
2952

3053
const GREETING_TIMEOUT_MINUTES = 30;
3154
const lastShown = sessionStorage.getItem("last-home-greeting");
3255
const now = Date.now();
33-
3456
const hasExpired =
3557
!lastShown ||
3658
now - parseInt(lastShown, 10) > GREETING_TIMEOUT_MINUTES * 60 * 1000;
3759

60+
const prefersReduced = prefersReducedMotion();
61+
const saveData = saveDataEnabled();
62+
63+
if (prefersReduced || saveData) {
64+
setShouldShowGreetings(false);
65+
setIsPreloaderVisible(false);
66+
return;
67+
}
68+
3869
if (isHome && isHardNavigation && hasExpired) {
3970
setShouldShowGreetings(true);
71+
setIsPreloaderVisible(true);
4072
sessionStorage.setItem("last-home-greeting", now.toString());
4173
}
4274
}, [pathname]);
4375

44-
// Gerencia a transição do preloader e conteúdo
76+
// Preloader curto em trocas internas de rota (não no primeiro render)
4577
useEffect(() => {
46-
// Inicia o carregamento do conteúdo imediatamente
47-
const contentLoadTimeout = setTimeout(() => {
48-
setIsContentReady(true);
49-
}, 100); // Pequeno delay para garantir que o React tenha tempo de montar o componente
50-
51-
// Configura o timer para remover o preloader
52-
const preloaderTimeout = setTimeout(
53-
() => {
54-
requestAnimationFrame(() => {
55-
document.body.style.cursor = "default";
56-
window.scrollTo(0, 0);
57-
setIsPreloaderVisible(false);
58-
});
59-
},
60-
shouldShowGreetings ? 2000 : 700
61-
);
62-
63-
return () => {
64-
clearTimeout(contentLoadTimeout);
65-
clearTimeout(preloaderTimeout);
66-
};
78+
if (isInitialMount.current) {
79+
isInitialMount.current = false;
80+
return;
81+
}
82+
if (shouldShowGreetings) return;
83+
setIsPreloaderVisible(true);
84+
const t = setTimeout(() => setIsPreloaderVisible(false), 350);
85+
return () => clearTimeout(t);
6786
}, [pathname, shouldShowGreetings]);
6887

6988
return (
7089
<>
7190
<AnimatePresence mode="wait">
7291
{isPreloaderVisible && (
73-
<Preloader text={text} showGreetings={shouldShowGreetings} />
92+
<Preloader
93+
text={text}
94+
showGreetings={shouldShowGreetings}
95+
onFinish={() => setIsPreloaderVisible(false)}
96+
/>
7497
)}
7598
</AnimatePresence>
7699

77-
<div
78-
style={{
79-
opacity: isPreloaderVisible ? 0 : 1,
80-
transition: "opacity 0.3s ease-in-out",
81-
visibility: isPreloaderVisible ? "hidden" : "visible",
82-
}}
83-
>
84-
<Suspense fallback={null}>{isContentReady && children}</Suspense>
100+
<div>
101+
<Suspense fallback={null}>{children}</Suspense>
85102
</div>
86103
</>
87104
);

components/pages/home.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ function BackgroundImage() {
103103
height={1100}
104104
alt="Personal photo"
105105
priority
106-
loading="eager"
106+
fetchPriority="high"
107+
sizes="100vw"
107108
placeholder="blur"
108109
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDABQODxIPDRQSEBIXFRQdHx4dHRsdHR4dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR3/2wBDAR4SEhwYHB4cHBwcHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR3/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
109110
className="opacity-80 dark:opacity-70 -scale-x-100 pointer-events-none"

components/preloader.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { opacity, preloader } from "@/utils/animations";
88
type PreloaderProps = {
99
text: string;
1010
showGreetings?: boolean;
11+
onFinish?: () => void;
1112
};
1213

1314
const greetings = [
@@ -39,11 +40,11 @@ const AnimatedText = memo(function AnimatedText({ text }: { text: string }) {
3940
);
4041
});
4142

42-
function Preloader({ text, showGreetings }: PreloaderProps) {
43+
function Preloader({ text, showGreetings, onFinish }: PreloaderProps) {
4344
const [index, setIndex] = useState(0);
4445
const [dimension, setDimension] = useState({ width: 0, height: 0 });
4546

46-
// Otimiza a medição das dimensões da janela
47+
// Medição responsiva da viewport para a animação
4748
useEffect(() => {
4849
const updateDimension = () => {
4950
requestAnimationFrame(() => {
@@ -56,36 +57,39 @@ function Preloader({ text, showGreetings }: PreloaderProps) {
5657

5758
updateDimension();
5859

59-
// Adiciona listener para redimensionamento apenas se necessário
6060
if (showGreetings) {
6161
window.addEventListener("resize", updateDimension);
6262
return () => window.removeEventListener("resize", updateDimension);
6363
}
6464
}, [showGreetings]);
6565

66-
// Otimiza a animação de saudações
66+
// Avança a sequência de saudações
6767
useEffect(() => {
6868
if (!showGreetings || index === greetings.length - 1) return;
69-
7069
const timer = setTimeout(
7170
() => {
72-
requestAnimationFrame(() => {
73-
setIndex((i) => i + 1);
74-
});
71+
requestAnimationFrame(() => setIndex((i) => i + 1));
7572
},
76-
index === 0 ? 1000 : 180
73+
index === 0 ? 600 : 180
7774
);
78-
7975
return () => clearTimeout(timer);
8076
}, [index, showGreetings]);
8177

82-
// Calcula os paths para a animação SVG
78+
// Encerra após a última saudação (com pequena pausa)
79+
useEffect(() => {
80+
if (!showGreetings) return;
81+
if (index === greetings.length - 1) {
82+
const t = setTimeout(() => onFinish?.(), 600);
83+
return () => clearTimeout(t);
84+
}
85+
}, [index, showGreetings, onFinish]);
86+
87+
// Paths do SVG da curva
8388
const initialPath = `M0 0 L${dimension.width} 0 L${dimension.width} ${
8489
dimension.height
8590
} Q${dimension.width / 2} ${dimension.height + 300} 0 ${
8691
dimension.height
8792
} L0 0`;
88-
8993
const targetPath = `M0 0 L${dimension.width} 0 L${dimension.width} ${
9094
dimension.height
9195
} Q${dimension.width / 2} ${dimension.height} 0 ${dimension.height} L0 0`;

components/project-details.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ function FullImageSection({ imageSrc }: { imageSrc: string }) {
234234
src={imageSrc}
235235
alt="Full screen mockup"
236236
fill
237+
sizes="100vw"
237238
className="rounded-[3rem] h-[90%]"
238239
loading="lazy"
239240
onError={(e) => {
@@ -283,11 +284,21 @@ function DesktopMockupsSection({ images }: { images: string[] }) {
283284
{images.map((src, index) => (
284285
<div
285286
key={index}
286-
role="img"
287+
className="relative w-full min-h-screen"
287288
aria-label={`Desktop application mockup ${index + 1}`}
288-
className="w-full min-h-screen bg-no-repeat bg-contain lg:bg-cover bg-center bg-fixed"
289-
style={{ backgroundImage: `url(${src})` }}
290-
/>
289+
>
290+
<MemoizedImage
291+
src={src}
292+
alt={`Desktop application mockup ${index + 1}`}
293+
fill
294+
sizes="100vw"
295+
className="object-contain lg:object-cover"
296+
loading="lazy"
297+
onError={(e) => {
298+
e.currentTarget.src = "/images/fallback.webp";
299+
}}
300+
/>
301+
</div>
291302
))}
292303
</section>
293304
);

components/projects.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ export default function ProjectsComponent({
8383
alt={project.title}
8484
width={600}
8585
height={600}
86-
priority
86+
loading="lazy"
87+
sizes="(max-width: 768px) 60vw, (max-width: 1200px) 33vw, 20vw"
8788
/>
8889
</div>
8990
))}

0 commit comments

Comments
 (0)