diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx
index 611cc811..d3be7b4d 100644
--- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx
+++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx
@@ -187,15 +187,15 @@ export default async function PostDetails({
const post: Post | null = await client
.withConfig({ useCdn: false })
.fetch(query, {
- slug: slugParam,
- locale,
- });
+ slug: slugParam,
+ locale,
+ });
const recommendedAll: Post[] = await client
.withConfig({ useCdn: false })
.fetch(recommendedQuery, {
- slug: slugParam,
- locale,
- });
+ slug: slugParam,
+ locale,
+ });
const recommendedPosts = seededShuffle(
recommendedAll,
hashString(slugParam)
@@ -212,9 +212,71 @@ export default async function PostDetails({
].filter(Boolean) as string[];
const authorMeta = authorMetaParts.join(' · ');
const categoryLabel = post.categories?.[0];
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;
+ const postUrl = baseUrl
+ ? `${baseUrl}/${locale}/blog/${slugParam}`
+ : null;
+ const blogUrl = baseUrl ? `${baseUrl}/${locale}/blog` : null;
+ const description = plainTextFromPortableText(post.body).slice(0, 160);
+ const breadcrumbsJsonLd =
+ blogUrl && postUrl
+ ? {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ {
+ '@type': 'ListItem',
+ position: 1,
+ name: tNav('blog'),
+ item: blogUrl,
+ },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: post.title,
+ item: postUrl,
+ },
+ ],
+ }
+ : null;
+ const articleJsonLd =
+ postUrl
+ ? {
+ '@context': 'https://schema.org',
+ '@type': 'BlogPosting',
+ headline: post.title,
+ description: description || undefined,
+ mainEntityOfPage: postUrl,
+ url: postUrl,
+ datePublished: post.publishedAt || undefined,
+ author: post.author?.name
+ ? {
+ '@type': 'Person',
+ name: post.author.name,
+ }
+ : undefined,
+ image: post.mainImage ? [post.mainImage] : undefined,
+ }
+ : null;
return (
+ {breadcrumbsJsonLd && (
+
+ )}
+ {articleJsonLd && (
+
+ )}
@@ -255,7 +317,11 @@ export default async function PostDetails({
)}
{authorName && post.publishedAt && ·}
- {post.publishedAt && {formatBlogDate(post.publishedAt)}}
+ {post.publishedAt && (
+
+ )}
)}
@@ -328,20 +394,20 @@ export default async function PostDetails({
/>
)}
-
- {item.title}
-
- {item.body && (
-
- {plainTextFromPortableText(item.body)}
-
- )}
- {(item.author?.name || item.publishedAt) && (
-
- {item.author?.image && (
-
-
+ {item.title}
+
+ {item.body && (
+
+ {plainTextFromPortableText(item.body)}
+
+ )}
+ {(item.author?.name || item.publishedAt) && (
+
+ {item.author?.image && (
+
+
)}
{item.author?.name && {item.author.name}}
- {item.author?.name && item.publishedAt && ·}
+ {item.author?.name && item.publishedAt && (
+ ·
+ )}
{item.publishedAt && (
- {formatBlogDate(item.publishedAt)}
+
)}
)}
diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx
index 32901f1d..c4721b0f 100644
--- a/frontend/app/[locale]/blog/[slug]/page.tsx
+++ b/frontend/app/[locale]/blog/[slug]/page.tsx
@@ -20,12 +20,16 @@ export async function generateMetadata({
const { slug, locale } = await params;
const post = await client.fetch(
- groq`*[_type == "post" && slug.current == $slug][0]{ "title": coalesce(title[$locale], title.en, title) }`,
+ groq`*[_type == "post" && slug.current == $slug][0]{
+ "title": coalesce(title[$locale], title.en, title),
+ "description": pt::text(coalesce(body[$locale], body.en, body))[0...160]
+ }`,
{ slug, locale }
);
return {
title: post?.title || 'Post',
+ description: post?.description || undefined,
};
}
diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx
index 05296614..dc0971b8 100644
--- a/frontend/app/[locale]/blog/category/[category]/page.tsx
+++ b/frontend/app/[locale]/blog/category/[category]/page.tsx
@@ -124,7 +124,11 @@ export default async function BlogCategoryPage({
{featuredPost.author.name}
)}
{featuredPost.author?.name && featuredDate && ·}
- {featuredDate && {featuredDate}}
+ {featuredDate && featuredPost.publishedAt && (
+
+ )}
·
)}
- {formattedDate &&
{formattedDate}}
+ {formattedDate && post.publishedAt && (
+
+ )}
)}
diff --git a/frontend/components/blog/BlogCategoryLinks.tsx b/frontend/components/blog/BlogCategoryLinks.tsx
index e3207821..755d5198 100644
--- a/frontend/components/blog/BlogCategoryLinks.tsx
+++ b/frontend/components/blog/BlogCategoryLinks.tsx
@@ -26,7 +26,6 @@ export function BlogCategoryLinks({
const tNav = useTranslations('navigation');
const pathname = usePathname();
- // Helper function to get translated category label
const getCategoryLabel = (categoryName: string): string => {
const key = categoryName.toLowerCase() as
| 'tech'
diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx
index 53ab044c..689aae91 100644
--- a/frontend/components/blog/BlogFilters.tsx
+++ b/frontend/components/blog/BlogFilters.tsx
@@ -123,7 +123,10 @@ export default function BlogFilters({
norm: string;
data?: Author;
} | null>(null);
- const [authorProfile, setAuthorProfile] = useState(null);
+ const [authorProfile, setAuthorProfile] = useState<{
+ name: string;
+ data: Author;
+ } | null>(null);
const [selectedCategory, setSelectedCategory] = useState<{
name: string;
norm: string;
@@ -148,7 +151,6 @@ export default function BlogFilters({
router.replace(nextPath);
};
- // Helper function to get translated category label
const getCategoryLabel = (categoryName: string): string => {
const key = categoryName.toLowerCase() as 'tech' | 'career' | 'insights' | 'news' | 'growth';
const categoryTranslations: Record = {
@@ -224,7 +226,6 @@ export default function BlogFilters({
didClearSearchRef.current = true;
}, [pathname, router, searchParams, searchQuery]);
- // categoryParam is handled via resolvedCategory to avoid state updates in effects.
const resolvedAuthor = useMemo(() => {
const normParam = normalizeAuthor(authorParam);
@@ -242,13 +243,9 @@ export default function BlogFilters({
useEffect(() => {
const name = resolvedAuthor?.name?.trim();
- if (!name) {
- setAuthorProfile(null);
- return;
- }
+ if (!name) return;
let active = true;
- if (resolvedAuthor?.data) setAuthorProfile(resolvedAuthor.data);
fetch(
`/api/blog-author?name=${encodeURIComponent(name)}&locale=${encodeURIComponent(
@@ -259,7 +256,7 @@ export default function BlogFilters({
.then(response => (response.ok ? response.json() : null))
.then((data: Author | null) => {
if (!active) return;
- if (data) setAuthorProfile(data);
+ if (data) setAuthorProfile({ name, data });
})
.catch(() => {
if (!active) return;
@@ -268,7 +265,7 @@ export default function BlogFilters({
return () => {
active = false;
};
- }, [locale, resolvedAuthor?.data, resolvedAuthor?.name]);
+ }, [locale, resolvedAuthor?.name]);
const filteredPosts = useMemo(() => {
return posts.filter(post => {
@@ -299,7 +296,12 @@ export default function BlogFilters({
});
}, [posts, resolvedAuthor, resolvedCategory, searchQueryLower]);
- const selectedAuthorData = authorProfile || resolvedAuthor?.data || null;
+ const selectedAuthorData = useMemo(() => {
+ const resolvedName = resolvedAuthor?.name;
+ if (!resolvedName) return null;
+ if (authorProfile?.name === resolvedName) return authorProfile.data;
+ return resolvedAuthor?.data || null;
+ }, [authorProfile, resolvedAuthor?.data, resolvedAuthor?.name]);
const authorBioText = useMemo(() => {
return plainTextFromPortableText(selectedAuthorData?.bio);
}, [selectedAuthorData]);
@@ -340,9 +342,12 @@ export default function BlogFilters({
{featuredPost.publishedAt && (
-
+
+
([]);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef
(null);
+ const containerRef = useRef(null);
const router = useRouter();
const debounceRef = useRef(null);
@@ -52,9 +53,22 @@ export function BlogHeaderSearch() {
}, [open]);
useEffect(() => {
- if (!open || items.length || isLoading) return;
+ if (!open) return;
+ const handlePointerDown = (event: MouseEvent) => {
+ if (!containerRef.current) return;
+ if (!containerRef.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handlePointerDown);
+ return () => {
+ document.removeEventListener('mousedown', handlePointerDown);
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || items.length || !isLoading) return;
let active = true;
- setIsLoading(true);
fetch(SEARCH_ENDPOINT, { cache: 'no-store' })
.then(response => (response.ok ? response.json() : []))
.then((result: PostSearchItem[]) => {
@@ -118,11 +132,23 @@ export function BlogHeaderSearch() {
setOpen(false);
};
+ const startLoading = () => {
+ if (!items.length && !isLoading) {
+ setIsLoading(true);
+ }
+ };
+
return (
-
+