Skip to content

Commit 09131de

Browse files
Sanity (#202)
* feat(Blog):fix for clickable link in post details, fix for author details * feat(Blog):refactoring after removing author modal * feat(Blog): fix unified date format * feat(Blog): Fix for click-outside-to-close search, recommended posts are limited to 3 * feat(Blog): selectedAuthorData fixed * feat(Blog): Added description for /blog/[slug] metadata, Added Schema.org JSON‑LD for Article (BlogPosting) and BreadcrumbList , Added <time datetime> tags where blog dates renders
1 parent a6f6bef commit 09131de

7 files changed

Lines changed: 158 additions & 45 deletions

File tree

frontend/app/[locale]/blog/[slug]/PostDetails.tsx

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,15 @@ export default async function PostDetails({
187187
const post: Post | null = await client
188188
.withConfig({ useCdn: false })
189189
.fetch(query, {
190-
slug: slugParam,
191-
locale,
192-
});
190+
slug: slugParam,
191+
locale,
192+
});
193193
const recommendedAll: Post[] = await client
194194
.withConfig({ useCdn: false })
195195
.fetch(recommendedQuery, {
196-
slug: slugParam,
197-
locale,
198-
});
196+
slug: slugParam,
197+
locale,
198+
});
199199
const recommendedPosts = seededShuffle(
200200
recommendedAll,
201201
hashString(slugParam)
@@ -212,9 +212,71 @@ export default async function PostDetails({
212212
].filter(Boolean) as string[];
213213
const authorMeta = authorMetaParts.join(' · ');
214214
const categoryLabel = post.categories?.[0];
215+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;
216+
const postUrl = baseUrl
217+
? `${baseUrl}/${locale}/blog/${slugParam}`
218+
: null;
219+
const blogUrl = baseUrl ? `${baseUrl}/${locale}/blog` : null;
220+
const description = plainTextFromPortableText(post.body).slice(0, 160);
221+
const breadcrumbsJsonLd =
222+
blogUrl && postUrl
223+
? {
224+
'@context': 'https://schema.org',
225+
'@type': 'BreadcrumbList',
226+
itemListElement: [
227+
{
228+
'@type': 'ListItem',
229+
position: 1,
230+
name: tNav('blog'),
231+
item: blogUrl,
232+
},
233+
{
234+
'@type': 'ListItem',
235+
position: 2,
236+
name: post.title,
237+
item: postUrl,
238+
},
239+
],
240+
}
241+
: null;
242+
const articleJsonLd =
243+
postUrl
244+
? {
245+
'@context': 'https://schema.org',
246+
'@type': 'BlogPosting',
247+
headline: post.title,
248+
description: description || undefined,
249+
mainEntityOfPage: postUrl,
250+
url: postUrl,
251+
datePublished: post.publishedAt || undefined,
252+
author: post.author?.name
253+
? {
254+
'@type': 'Person',
255+
name: post.author.name,
256+
}
257+
: undefined,
258+
image: post.mainImage ? [post.mainImage] : undefined,
259+
}
260+
: null;
215261

216262
return (
217263
<main className="max-w-3xl mx-auto px-6 py-12">
264+
{breadcrumbsJsonLd && (
265+
<script
266+
type="application/ld+json"
267+
dangerouslySetInnerHTML={{
268+
__html: JSON.stringify(breadcrumbsJsonLd),
269+
}}
270+
/>
271+
)}
272+
{articleJsonLd && (
273+
<script
274+
type="application/ld+json"
275+
dangerouslySetInnerHTML={{
276+
__html: JSON.stringify(articleJsonLd),
277+
}}
278+
/>
279+
)}
218280
<div className="mb-6 relative left-1/2 right-1/2 w-screen -translate-x-1/2 px-6">
219281
<div className="mx-auto flex max-w-6xl justify-start">
220282
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
@@ -255,7 +317,11 @@ export default async function PostDetails({
255317
</Link>
256318
)}
257319
{authorName && post.publishedAt && <span>·</span>}
258-
{post.publishedAt && <span>{formatBlogDate(post.publishedAt)}</span>}
320+
{post.publishedAt && (
321+
<time dateTime={post.publishedAt}>
322+
{formatBlogDate(post.publishedAt)}
323+
</time>
324+
)}
259325
</div>
260326
)}
261327

@@ -328,30 +394,34 @@ export default async function PostDetails({
328394
/>
329395
</div>
330396
)}
331-
<h3 className="mt-4 text-lg font-semibold text-gray-900 transition group-hover:underline underline-offset-4 dark:text-gray-100">
332-
{item.title}
333-
</h3>
334-
{item.body && (
335-
<p className="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-2">
336-
{plainTextFromPortableText(item.body)}
337-
</p>
338-
)}
339-
{(item.author?.name || item.publishedAt) && (
340-
<div className="mt-auto pt-3 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
341-
{item.author?.image && (
342-
<span className="relative h-5 w-5 overflow-hidden rounded-full">
343-
<Image
344-
src={item.author.image}
397+
<h3 className="mt-4 text-lg font-semibold text-gray-900 transition group-hover:underline underline-offset-4 dark:text-gray-100">
398+
{item.title}
399+
</h3>
400+
{item.body && (
401+
<p className="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-2">
402+
{plainTextFromPortableText(item.body)}
403+
</p>
404+
)}
405+
{(item.author?.name || item.publishedAt) && (
406+
<div className="mt-auto pt-3 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
407+
{item.author?.image && (
408+
<span className="relative h-5 w-5 overflow-hidden rounded-full">
409+
<Image
410+
src={item.author.image}
345411
alt={item.author.name || 'Author'}
346412
fill
347413
className="object-cover"
348414
/>
349415
</span>
350416
)}
351417
{item.author?.name && <span>{item.author.name}</span>}
352-
{item.author?.name && item.publishedAt && <span>·</span>}
418+
{item.author?.name && item.publishedAt && (
419+
<span>·</span>
420+
)}
353421
{item.publishedAt && (
354-
<span>{formatBlogDate(item.publishedAt)}</span>
422+
<time dateTime={item.publishedAt}>
423+
{formatBlogDate(item.publishedAt)}
424+
</time>
355425
)}
356426
</div>
357427
)}

frontend/app/[locale]/blog/[slug]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ export async function generateMetadata({
2020
const { slug, locale } = await params;
2121

2222
const post = await client.fetch(
23-
groq`*[_type == "post" && slug.current == $slug][0]{ "title": coalesce(title[$locale], title.en, title) }`,
23+
groq`*[_type == "post" && slug.current == $slug][0]{
24+
"title": coalesce(title[$locale], title.en, title),
25+
"description": pt::text(coalesce(body[$locale], body.en, body))[0...160]
26+
}`,
2427
{ slug, locale }
2528
);
2629

2730
return {
2831
title: post?.title || 'Post',
32+
description: post?.description || undefined,
2933
};
3034
}
3135

frontend/app/[locale]/blog/category/[category]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ export default async function BlogCategoryPage({
124124
<span>{featuredPost.author.name}</span>
125125
)}
126126
{featuredPost.author?.name && featuredDate && <span>·</span>}
127-
{featuredDate && <span>{featuredDate}</span>}
127+
{featuredDate && featuredPost.publishedAt && (
128+
<time dateTime={featuredPost.publishedAt}>
129+
{featuredDate}
130+
</time>
131+
)}
128132
</div>
129133
<Link
130134
href={`/blog/${featuredPost.slug.current}`}

frontend/components/blog/BlogCard.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ export default function BlogCard({
136136
{(post.author?.name || categoryLabel) && formattedDate && (
137137
<span>·</span>
138138
)}
139-
{formattedDate && <span>{formattedDate}</span>}
139+
{formattedDate && post.publishedAt && (
140+
<time dateTime={post.publishedAt}>{formattedDate}</time>
141+
)}
140142
</div>
141143
)}
142144

frontend/components/blog/BlogCategoryLinks.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export function BlogCategoryLinks({
2626
const tNav = useTranslations('navigation');
2727
const pathname = usePathname();
2828

29-
// Helper function to get translated category label
3029
const getCategoryLabel = (categoryName: string): string => {
3130
const key = categoryName.toLowerCase() as
3231
| 'tech'

frontend/components/blog/BlogFilters.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,10 @@ export default function BlogFilters({
123123
norm: string;
124124
data?: Author;
125125
} | null>(null);
126-
const [authorProfile, setAuthorProfile] = useState<Author | null>(null);
126+
const [authorProfile, setAuthorProfile] = useState<{
127+
name: string;
128+
data: Author;
129+
} | null>(null);
127130
const [selectedCategory, setSelectedCategory] = useState<{
128131
name: string;
129132
norm: string;
@@ -148,7 +151,6 @@ export default function BlogFilters({
148151
router.replace(nextPath);
149152
};
150153

151-
// Helper function to get translated category label
152154
const getCategoryLabel = (categoryName: string): string => {
153155
const key = categoryName.toLowerCase() as 'tech' | 'career' | 'insights' | 'news' | 'growth';
154156
const categoryTranslations: Record<string, string> = {
@@ -224,7 +226,6 @@ export default function BlogFilters({
224226
didClearSearchRef.current = true;
225227
}, [pathname, router, searchParams, searchQuery]);
226228

227-
// categoryParam is handled via resolvedCategory to avoid state updates in effects.
228229

229230
const resolvedAuthor = useMemo(() => {
230231
const normParam = normalizeAuthor(authorParam);
@@ -242,13 +243,9 @@ export default function BlogFilters({
242243

243244
useEffect(() => {
244245
const name = resolvedAuthor?.name?.trim();
245-
if (!name) {
246-
setAuthorProfile(null);
247-
return;
248-
}
246+
if (!name) return;
249247

250248
let active = true;
251-
if (resolvedAuthor?.data) setAuthorProfile(resolvedAuthor.data);
252249

253250
fetch(
254251
`/api/blog-author?name=${encodeURIComponent(name)}&locale=${encodeURIComponent(
@@ -259,7 +256,7 @@ export default function BlogFilters({
259256
.then(response => (response.ok ? response.json() : null))
260257
.then((data: Author | null) => {
261258
if (!active) return;
262-
if (data) setAuthorProfile(data);
259+
if (data) setAuthorProfile({ name, data });
263260
})
264261
.catch(() => {
265262
if (!active) return;
@@ -268,7 +265,7 @@ export default function BlogFilters({
268265
return () => {
269266
active = false;
270267
};
271-
}, [locale, resolvedAuthor?.data, resolvedAuthor?.name]);
268+
}, [locale, resolvedAuthor?.name]);
272269

273270
const filteredPosts = useMemo(() => {
274271
return posts.filter(post => {
@@ -299,7 +296,12 @@ export default function BlogFilters({
299296
});
300297
}, [posts, resolvedAuthor, resolvedCategory, searchQueryLower]);
301298

302-
const selectedAuthorData = authorProfile || resolvedAuthor?.data || null;
299+
const selectedAuthorData = useMemo(() => {
300+
const resolvedName = resolvedAuthor?.name;
301+
if (!resolvedName) return null;
302+
if (authorProfile?.name === resolvedName) return authorProfile.data;
303+
return resolvedAuthor?.data || null;
304+
}, [authorProfile, resolvedAuthor?.data, resolvedAuthor?.name]);
303305
const authorBioText = useMemo(() => {
304306
return plainTextFromPortableText(selectedAuthorData?.bio);
305307
}, [selectedAuthorData]);
@@ -340,9 +342,12 @@ export default function BlogFilters({
340342
</p>
341343
{featuredPost.publishedAt && (
342344
<div className="mt-6 flex items-center justify-between text-xs tracking-[0.25em] text-gray-500 dark:text-gray-400">
343-
<span className="uppercase">
345+
<time
346+
dateTime={featuredPost.publishedAt}
347+
className="uppercase"
348+
>
344349
{formatBlogDate(featuredPost.publishedAt)}
345-
</span>
350+
</time>
346351
<Link
347352
href={`/blog/${featuredPost.slug.current}`}
348353
className="text-sm font-medium tracking-normal text-[var(--accent-primary)] transition hover:underline underline-offset-4"

frontend/components/blog/BlogHeaderSearch.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function BlogHeaderSearch() {
4343
const [items, setItems] = useState<PostSearchItem[]>([]);
4444
const [isLoading, setIsLoading] = useState(false);
4545
const inputRef = useRef<HTMLInputElement>(null);
46+
const containerRef = useRef<HTMLDivElement>(null);
4647
const router = useRouter();
4748
const debounceRef = useRef<number | null>(null);
4849

@@ -52,9 +53,22 @@ export function BlogHeaderSearch() {
5253
}, [open]);
5354

5455
useEffect(() => {
55-
if (!open || items.length || isLoading) return;
56+
if (!open) return;
57+
const handlePointerDown = (event: MouseEvent) => {
58+
if (!containerRef.current) return;
59+
if (!containerRef.current.contains(event.target as Node)) {
60+
setOpen(false);
61+
}
62+
};
63+
document.addEventListener('mousedown', handlePointerDown);
64+
return () => {
65+
document.removeEventListener('mousedown', handlePointerDown);
66+
};
67+
}, [open]);
68+
69+
useEffect(() => {
70+
if (!open || items.length || !isLoading) return;
5671
let active = true;
57-
setIsLoading(true);
5872
fetch(SEARCH_ENDPOINT, { cache: 'no-store' })
5973
.then(response => (response.ok ? response.json() : []))
6074
.then((result: PostSearchItem[]) => {
@@ -118,11 +132,23 @@ export function BlogHeaderSearch() {
118132
setOpen(false);
119133
};
120134

135+
const startLoading = () => {
136+
if (!items.length && !isLoading) {
137+
setIsLoading(true);
138+
}
139+
};
140+
121141
return (
122-
<div className="relative flex items-center">
142+
<div ref={containerRef} className="relative flex items-center">
123143
<button
124144
type="button"
125-
onClick={() => setOpen(prev => !prev)}
145+
onClick={() =>
146+
setOpen(prev => {
147+
const next = !prev;
148+
if (next) startLoading();
149+
return next;
150+
})
151+
}
126152
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
127153
aria-label="Search blog"
128154
>
@@ -148,7 +174,10 @@ export function BlogHeaderSearch() {
148174
value={value}
149175
onChange={event => {
150176
setValue(event.target.value);
151-
if (!open) setOpen(true);
177+
if (!open) {
178+
setOpen(true);
179+
startLoading();
180+
}
152181
}}
153182
onKeyDown={event => {
154183
if (event.key === 'Escape') setOpen(false);

0 commit comments

Comments
 (0)