Repository: socialincome-san/public
Pull request #1079: Journal version 2
Author: login: Gavriil-Tzortzakis
Created: 2025-04-06T13:30:09Z | Merged: 2025-04-21T14:28:35Z



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Updated localized content across multiple languages for a richer reading experience.
  - Introduced new components for displaying quotes, article cards, and images with captions.
  - Launched revamped journal pages for articles, authors, and tags, featuring improved data loading and organization.
  - Added new export capabilities for typography components.

- **Style**
  - Enhanced badge styling with a refined background for better visual clarity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/shared/locales/de/website-journal.json b/shared/locales/de/website-journal.json
index 4b7cf5c159..ca28a67080 100644
--- a/shared/locales/de/website-journal.json
+++ b/shared/locales/de/website-journal.json
@@ -1,8 +1,10 @@
 {
 	"overview": {
+		"all": "Alle",
 		"title": "Journal",
 		"description": "News, Insights und Einblicke in die Arbeit von Social Income."
 	},
-	"written-by": "von",
-	"published": "publiziert"
+	"article": {
+		"keep-reading": "Weiterlesen"
+	}
 }
diff --git a/shared/locales/en/website-journal.json b/shared/locales/en/website-journal.json
index a3927f7633..851f7331c6 100644
--- a/shared/locales/en/website-journal.json
+++ b/shared/locales/en/website-journal.json
@@ -1,8 +1,11 @@
 {
 	"overview": {
+		"all": "All",
 		"title": "Journal",
-		"description": "Stories, insights and updates on our journey to create impact."
+		"description": "Stories, insights and updates on our journey to create impact.",
+		"editors": "Contributing Editors"
 	},
-	"written-by": "by",
-	"published": "published"
+	"article": {
+		"keep-reading": "Keep reading"
+	}
 }
diff --git a/shared/locales/fr/website-journal.json b/shared/locales/fr/website-journal.json
index ce8da6f319..36d96403f6 100644
--- a/shared/locales/fr/website-journal.json
+++ b/shared/locales/fr/website-journal.json
@@ -1,8 +1,10 @@
 {
 	"overview": {
+		"all": "Tous",
 		"title": "Journal",
 		"description": "Histoires, réflexions et nouvelles de notre parcours pour créer un impact."
 	},
-	"written-by": "par",
-	"published": "publié"
+	"article": {
+		"keep-reading": "À lire aussi"
+	}
 }
diff --git a/shared/locales/it/website-journal.json b/shared/locales/it/website-journal.json
index 45ee0c5ff8..577d209ae1 100644
--- a/shared/locales/it/website-journal.json
+++ b/shared/locales/it/website-journal.json
@@ -1,8 +1,10 @@
 {
 	"overview": {
+		"all": "Tutti",
 		"title": "Giornale",
 		"description": "Storie e aggiornamenti sul nostro percorso per creare un impatto."
 	},
-	"written-by": "da",
-	"published": "Pubblicato"
+	"article": {
+		"keep-reading": "Articoli correlati"
+	}
 }
diff --git a/shared/src/storyblok/journal.ts b/shared/src/storyblok/journal.ts
index 41377e8f69..107534d832 100644
--- a/shared/src/storyblok/journal.ts
+++ b/shared/src/storyblok/journal.ts
@@ -1,25 +1,49 @@
 import { ISbStoryData } from 'storyblok-js-client/src/interfaces';
 
+export enum StoryblokContentType {
+	Article = 'article',
+	Author = 'author',
+	Tag = 'topic',
+}
+
 export interface StoryblokImage {
 	alt: string;
 	filename: string;
+	id: number;
+}
+
+export interface StoryblokArticleType {
+	id: number;
+	value: string;
 }
 
-export interface StoryblokTopic {
+export interface StoryblokTag {
+	id: number;
 	value: string;
+	description: string;
+	displayInOverviewPage: boolean;
 }
 
 export interface StoryblokAuthor {
 	avatar: StoryblokImage;
 	fullName: string;
+	id: number;
+	firstName: string;
+	lastName: string;
+	displayInOverviewPage: boolean;
+	bio: string;
 }
 
 export interface StoryblokArticle {
-	default_full_slug: string;
+	id: number;
 	title: string;
 	content: any;
 	subtitle: string;
+	type: ISbStoryData<StoryblokArticleType>;
 	image: StoryblokImage;
-	topics: ISbStoryData<StoryblokTopic>[];
+	tags: ISbStoryData<StoryblokTag>[];
 	author: ISbStoryData<StoryblokAuthor>;
+	displayInOverviewPage: boolean;
+	leadText: string;
+	showRelativeArticles: boolean;
 }
diff --git a/ui/src/components/badge.tsx b/ui/src/components/badge.tsx
index 39f8748dae..444304c572 100644
--- a/ui/src/components/badge.tsx
+++ b/ui/src/components/badge.tsx
@@ -14,7 +14,7 @@ const badgeVariants = cva(
 				destructive: 'border-transparent bg-destructive text-destructive-foreground',
 				muted: 'border-muted-foreground text-muted-foreground',
 				accent: 'border-transparent bg-accent text-accent-foreground',
-				outline: 'text-primary border-primary',
+				outline: 'text-primary border-primary bg-white',
 				white: 'border-white bg-transparent text-white',
 				interactive:
 					'border-transparent bg-primary bg-opacity-10 text-primary hover:bg-opacity-100 hover:text-white focus:ring-2 focus:ring-ring focus:ring-offset-2',
diff --git a/ui/src/components/typography/index.ts b/ui/src/components/typography/index.ts
index 4bf9428b0e..e8aa62a23c 100644
--- a/ui/src/components/typography/index.ts
+++ b/ui/src/components/typography/index.ts
@@ -1,2 +1,3 @@
 export * from './links';
+export * from './quotedText';
 export * from './typography';
diff --git a/ui/src/components/typography/quotedText.tsx b/ui/src/components/typography/quotedText.tsx
new file mode 100644
index 0000000000..6031c5200f
--- /dev/null
+++ b/ui/src/components/typography/quotedText.tsx
@@ -0,0 +1,21 @@
+import { Typography } from './typography';
+
+export function QuotedText({ text, author, authorTitle }: { text: string; author: string; authorTitle?: string }) {
+	return (
+		<div className="w-full px-0 py-16">
+			<Typography className="m-0 p-0" weight="bold" color="foreground" size="5xl">
+				« {text} »
+			</Typography>
+			<div className="mt-4">
+				<Typography color="foreground" size="md" className="m-0 p-0">
+					{author}
+				</Typography>
+				{authorTitle && (
+					<Typography color="foreground" size="md" className="m-0 p-0">
+						{authorTitle}
+					</Typography>
+				)}
+			</div>
+		</div>
+	);
+}
diff --git a/website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts b/website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts
new file mode 100644
index 0000000000..1099329a20
--- /dev/null
+++ b/website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts
@@ -0,0 +1,254 @@
+import {
+	type StoryblokArticle,
+	StoryblokAuthor,
+	StoryblokContentType,
+	StoryblokTag,
+} from '@socialincome/shared/src/storyblok/journal';
+import { getStoryblokApi, ISbStory } from '@storyblok/react';
+import { DateTime } from 'luxon';
+import { draftMode } from 'next/headers';
+import { notFound } from 'next/navigation';
+import { ISbStories, ISbStoriesParams, ISbStoryData } from 'storyblok-js-client/src/interfaces';
+
+const STANDARD_ARTICLE_RELATIONS_TO_RESOLVE = ['article.author', 'article.tags', 'article.type'];
+const DEFAULT_LIMIT = 50;
+const NOT_FOUND = 404;
+const DEFAULT_LANGUAGE = 'en';
+const CONTENT = 'content';
+const STORIES_PATH = 'cdn/stories';
+
+export function getPublishedDateFormatted(date: string, lang: string) {
+	const dateObject = DateTime.fromISO(date).setLocale(lang);
+	return dateObject.isValid ? dateObject.toFormat('MMMM dd, yyyy') : '';
+}
+
+// During the development of Storyblok features or writing of new content, it is useful to use the draft version of the content.
+function addVersionParameter(properties: ISbStoriesParams): ISbStoriesParams {
+	return { ...properties, version: draftMode().isEnabled ? 'draft' : 'published' };
+}
+
+// Based on official documentation: https://www.storyblok.com/faq/image-dimensions-assets-js
+// format example: a.storyblok.com/f/51376/664x488/f4f9d1769c/visual-editor-features.jpg
+export function getDimensionsFromStoryblokImageUrl(url: string): { width: number; height: number } {
+	let dimensions = url.split('/')[5].split('x');
+	return {
+		width: Number(dimensions[0]),
+		height: Number(dimensions[1]),
+	};
+}
+
+export async function getAuthors(lang: string): Promise<ISbStories<StoryblokAuthor>> {
+	const params: ISbStoriesParams = {
+		language: lang,
+		content_type: StoryblokContentType.Author,
+		filter_query: {
+			displayInOverviewPage: {
+				is: true,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getTags(lang: string): Promise<ISbStories<StoryblokTag>> {
+	const params: ISbStoriesParams = {
+		language: lang,
+		content_type: StoryblokContentType.Tag,
+		filter_query: {
+			displayInOverviewPage: {
+				is: true,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getArticlesByTag(tagId: string, lang: string): Promise<ISbStories<StoryblokArticle>> {
+	const params: ISbStoriesParams = {
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		excluding_fields: CONTENT,
+		sort_by: 'first_published_at:desc',
+		content_type: StoryblokContentType.Article,
+		filter_query: {
+			tags: {
+				any_in_array: tagId,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getArticlesByAuthor(
+	authorId: string,
+	lang: string,
+	limit = DEFAULT_LIMIT,
+): Promise<ISbStories<StoryblokArticle>> {
+	const params: ISbStoriesParams = {
+		per_page: limit,
+		excluding_fields: CONTENT,
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		sort_by: 'first_published_at:desc',
+		content_type: StoryblokContentType.Article,
+		filter_query: {
+			author: {
+				in: authorId,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getOverviewArticles(
+	lang: string,
+	idsToIgnore: string | undefined = undefined,
+	limit = DEFAULT_LIMIT,
+): Promise<ISbStories<StoryblokArticle>> {
+	const params: ISbStoriesParams = {
+		per_page: limit,
+		excluding_fields: CONTENT,
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		sort_by: 'first_published_at:desc',
+		content_type: StoryblokContentType.Article,
+		filter_query: {
+			displayInOverviewPage: {
+				is: true,
+			},
+		},
+		...(idsToIgnore ? { excluding_ids: idsToIgnore } : {}),
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getTag(slug: string, lang: string): Promise<ISbStory<StoryblokTag>> {
+	return getWithFallback(
+		async (lang: string, slug: string): Promise<ISbStory<StoryblokTag>> => {
+			const params: ISbStoriesParams = {
+				language: lang,
+			};
+			return getStoryblokApi().get(`cdn/stories/tag/${slug}`, addVersionParameter(params));
+		},
+		lang,
+		slug,
+	);
+}
+
+export async function getAuthor(slug: string, lang: string): Promise<ISbStory<StoryblokAuthor>> {
+	return getWithFallback(
+		async (lang: string, slug: string): Promise<ISbStory<StoryblokAuthor>> => {
+			const params: ISbStoriesParams = {
+				language: lang,
+			};
+			return getStoryblokApi().get(`cdn/stories/author/${slug}`, addVersionParameter(params));
+		},
+		lang,
+		slug,
+	);
+}
+
+export async function getWithFallback<T>(
+	loader: (lang: string, slug: string) => Promise<T>,
+	lang: string,
+	slug: string,
+): Promise<T> {
+	try {
+		return loader(lang, slug);
+	} catch (error: any) {
+		if (error.status === NOT_FOUND) {
+			if (lang === DEFAULT_LANGUAGE) {
+				throw notFound();
+			}
+			return loader(DEFAULT_LANGUAGE, slug);
+		}
+		throw error;
+	}
+}
+
+function createRelativeArticlesFilter(tags: string[], authorId: string) {
+	return tags && tags.length
+		? {
+				__or: [
+					{
+						author: {
+							in: authorId,
+						},
+					},
+					{
+						tags: {
+							in_array: tags.join(','),
+						},
+					},
+				],
+			}
+		: {
+				author: {
+					in: authorId,
+				},
+			};
+}
+
+async function getRelativeArticlesByAuthorAndTags(
+	authorId: string,
+	tags: string[],
+	lang: string,
+	articleId: number,
+	numberOfArticles: number,
+): Promise<ISbStories<StoryblokArticle>> {
+	let filter = createRelativeArticlesFilter(tags, authorId);
+	const params: ISbStoriesParams = {
+		per_page: numberOfArticles,
+		excluding_fields: CONTENT,
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		sort_by: 'first_published_at:desc',
+		excluding_ids: articleId.toString(),
+		content_type: StoryblokContentType.Article,
+		filter_query: filter,
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+/*
+ * Returns relative articles by the same author or the same tag. It excludes the current article by {articleId}.
+ * If there are not enough articles, the latest articles of the overview pages are returned.
+ * */
+export async function getRelativeArticles(
+	authorId: string,
+	articleId: number,
+	tags: string[],
+	lang: string,
+	numberOfArticles: number,
+): Promise<ISbStoryData<StoryblokArticle>[]> {
+	const relatedArticlesResponse = await getRelativeArticlesByAuthorAndTags(
+		authorId,
+		tags,
+		lang,
+		articleId,
+		numberOfArticles,
+	);
+	let result = relatedArticlesResponse.data.stories;
+	if (result.length < numberOfArticles) {
+		const idsToIgnore = [...result.map((s) => s.id), articleId].join(',');
+		const remaining = numberOfArticles - result.length;
+		const overviewArticles = await getOverviewArticles(lang, idsToIgnore, remaining);
+		result = [...result, ...overviewArticles.data.stories];
+	}
+
+	return result;
+}
+
+export async function getArticle(lang: string, slug: string): Promise<ISbStory<StoryblokArticle>> {
+	return getWithFallback(
+		async (lang: string, slug: string): Promise<ISbStory<StoryblokArticle>> => {
+			const params: ISbStoriesParams = {
+				resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+				language: lang,
+			};
+			return getStoryblokApi().get(`cdn/stories/journal/${slug}`, addVersionParameter(params));
+		},
+		lang,
+		slug,
+	);
+}
diff --git a/website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx b/website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx
new file mode 100644
index 0000000000..1548d2dcec
--- /dev/null
+++ b/website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx
@@ -0,0 +1,60 @@
+import {
+	getDimensionsFromStoryblokImageUrl,
+	getPublishedDateFormatted,
+} from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { StoryblokArticle, StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { Typography } from '@socialincome/ui';
+import { ISbStoryData } from '@storyblok/react';
+import Image from 'next/image';
+import Link from 'next/link';
+
+export function StoryblokArticleCard(props: {
+	lang: string;
+	region: string;
+	blog: ISbStoryData<StoryblokArticle>;
+	author: ISbStoryData<StoryblokAuthor>;
+}) {
+	const { region, lang, blog, author } = props;
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(blog.content.image.filename);
+	return (
+		<Link href={`/${props.lang}/${props.region}/journal/${blog.slug!}`}>
+			<div className="mb-4 overflow-hidden transition-transform duration-200 hover:scale-[101%]">
+				<Image
+					src={blog.content.image.filename}
+					alt={blog.content.title}
+					width={dimensionsFromStoryblokImage.width}
+					height={dimensionsFromStoryblokImage.height}
+					className="h-60 w-full object-cover"
+				/>
+				<div className="mt-2 flex items-center justify-between">
+					<div className="flex items-center space-x-2">
+						<Typography weight="bold" size="sm" className="uppercase">
+							{blog.content.type?.content.value}
+						</Typography>
+					</div>
+					<Typography weight="normal" className="text-black">
+						{getPublishedDateFormatted(blog.first_published_at!, props.lang)}
+					</Typography>
+				</div>
+
+				<div className="mt-2 flex flex-grow flex-col">
+					<Typography size="2xl" className="mb-4 line-clamp-3 h-16 flex-grow" weight="medium">
+						{blog.content.title}
+					</Typography>
+					<div className="flex items-center justify-between">
+						<div className="flex items-center space-x-2">
+							<StoryblokAuthorImage size="large" author={author} region={region} lang={lang} />
+							<Typography className="ml-1" size="lg">
+								{blog.content.author.content.fullName}
+							</Typography>
+						</div>
+					</div>
+					<Typography size="md" weight="normal" className="mt-4 line-clamp-4 text-black">
+						{blog.content.leadText}
+					</Typography>
+				</div>
+			</div>
+		</Link>
+	);
+}
diff --git a/website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx b/website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx
index 3a1d09abe1..51db6691d4 100644
--- a/website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx
+++ b/website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx
@@ -1,15 +1,34 @@
-import type { StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { getDimensionsFromStoryblokImageUrl } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { ISbStoryData } from '@storyblok/react';
 import Image from 'next/image';
-import { ISbStoryData } from 'storyblok-js-client/src/interfaces';
 
-function StoryblokAuthorImage(props: { author: ISbStoryData<StoryblokAuthor> }) {
+const sizeClasses = {
+	small: 'h-8 w-8',
+	medium: 'h-12 w-12',
+	large: 'h-14 w-14',
+	'extra-large': 'h-24 w-24',
+};
+
+type Size = 'small' | 'medium' | 'large' | 'extra-large';
+
+function StoryblokAuthorImage(props: {
+	author: ISbStoryData<StoryblokAuthor>;
+	size?: Size;
+	className?: string;
+	lang: string;
+	region: string;
+}) {
+	const { author, size = 'medium', className = '' } = props;
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(author.content.avatar.filename);
+
 	return (
 		<Image
-			src={props.author.content.avatar.filename}
-			alt="author"
-			className="h-12 w-12 flex-none rounded-full object-cover object-top"
-			width={100}
-			height={100}
+			src={author.content.avatar.filename}
+			alt={author.content.fullName + '-avatar'}
+			className={`${sizeClasses[size]} flex-none rounded-full object-cover object-top ${className}`}
+			width={dimensionsFromStoryblokImage.width}
+			height={dimensionsFromStoryblokImage.height}
 		/>
 	);
 }
diff --git a/website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx b/website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx
new file mode 100644
index 0000000000..8ffeaedbb7
--- /dev/null
+++ b/website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx
@@ -0,0 +1,30 @@
+import { getDimensionsFromStoryblokImageUrl } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { Typography } from '@socialincome/ui';
+import Image from 'next/image';
+
+export function StoryblokImageWithCaption({
+	image,
+	caption,
+}: {
+	image: { filename: string; alt?: string };
+	caption: string;
+}) {
+	if (!image?.filename) return null;
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(image.filename);
+	return (
+		<div className="w-full px-0 py-8">
+			<Image
+				src={image.filename}
+				alt={image.alt || ''}
+				className={`m-0 h-auto w-full object-contain p-0`}
+				width={dimensionsFromStoryblokImage.width}
+				height={dimensionsFromStoryblokImage.height}
+			/>
+			{caption && (
+				<Typography size="xs" className="m-0 mt-2 pt-4 text-left">
+					{caption}
+				</Typography>
+			)}
+		</div>
+	);
+}
diff --git a/website/src/app/[lang]/[region]/(website)/journal/[...slug]/page.tsx b/website/src/app/[lang]/[region]/(website)/journal/[...slug]/page.tsx
deleted file mode 100644
index 4fb3a84437..0000000000
--- a/website/src/app/[lang]/[region]/(website)/journal/[...slug]/page.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
-import type { StoryblokArticle } from '@socialincome/shared/src/storyblok/journal';
-import { LanguageCode } from '@socialincome/shared/src/types/language';
-import { Translator } from '@socialincome/shared/src/utils/i18n';
-import { Badge, Typography } from '@socialincome/ui';
-import { getStoryblokApi, ISbStory } from '@storyblok/react';
-import { DateTime } from 'luxon';
-import { draftMode } from 'next/headers';
-import Image from 'next/image';
-import { notFound } from 'next/navigation';
-import { ISbStoriesParams } from 'storyblok-js-client/src/interfaces';
-import { render } from 'storyblok-rich-text-react-renderer';
-
-export const revalidate = 900;
-
-const NOT_FOUND = 404;
-
-const DEFAULT_LANGUAGE = 'en';
-
-async function loadArticle(lang: string, slug: string[]): Promise<ISbStory<StoryblokArticle>> {
-	const params: ISbStoriesParams = {
-		...(draftMode().isEnabled ? { version: 'draft' } : {}),
-		resolve_relations: ['article.author', 'article.topics'],
-		language: lang,
-	};
-	return await getStoryblokApi().get(`cdn/stories/journal/${slug?.join('/')}`, params);
-}
-
-async function loadArticleWithFallbackToDefaultLanguage(
-	lang: string,
-	slug: string[],
-): Promise<ISbStory<StoryblokArticle>> {
-	try {
-		return await loadArticle(lang, slug);
-	} catch (error: any) {
-		if (error.status === NOT_FOUND) {
-			if (lang === DEFAULT_LANGUAGE) {
-				throw notFound();
-			}
-			return await loadArticle(DEFAULT_LANGUAGE, slug);
-		}
-		throw error;
-	}
-}
-
-function getPublishedDateFormatted(date: string, lang: string) {
-	const dateObject = DateTime.fromISO(date).setLocale(lang);
-	return dateObject.isValid ? dateObject.toFormat('MMMM dd, yyyy') : '';
-}
-
-export default async function Page(props: { params: { slug: string[]; lang: LanguageCode; region: string } }) {
-	const lang = props.params.lang;
-
-	const translator = await Translator.getInstance({
-		language: lang,
-		namespaces: ['website-journal'],
-	});
-
-	const loadArticleResponse = await loadArticleWithFallbackToDefaultLanguage(props.params.lang, props.params.slug);
-	const articleData = loadArticleResponse.data.story.content;
-	const author = articleData.author;
-
-	return (
-		<div className="blog w-full justify-center">
-			<div className="bg-primary flex flex-col md:min-h-screen md:flex-row">
-				<div className="md:order-2 md:w-1/2">
-					<Image
-						src={articleData.image.filename}
-						alt={articleData.image?.alt}
-						className="w-full object-cover md:h-screen"
-						width={900}
-						height={700}
-					/>
-				</div>
-				<div className="flex flex-col items-center justify-center p-8 text-center md:order-1 md:w-1/2 md:items-start md:text-left lg:p-16">
-					<div className="flex flex-wrap justify-center gap-2 md:justify-start">
-						{articleData.topics?.map((topic) => (
-							<Badge key={topic.slug} variant="white" className="mb-2">
-								{topic.content.value}
-							</Badge>
-						))}
-					</div>
-					<Typography className="mt-4" color="accent" size="5xl">
-						{articleData.title}
-					</Typography>
-					<div className="mt-7 flex items-center space-x-4">
-						<StoryblokAuthorImage author={author} />
-						<div className="text-left">
-							<Typography color="popover" size="sm">
-								{translator.t('published')}{' '}
-								{getPublishedDateFormatted(loadArticleResponse.data.story.published_at!, lang)}
-							</Typography>
-							<Typography color="popover" size="sm">
-								{translator.t('written-by')}
-								<Typography as="span" color={'accent'} className="ml-1">
-									{author.content.fullName}
-								</Typography>
-							</Typography>
-						</div>
-					</div>
-				</div>
-			</div>
-
-			<div className="prose mx-auto my-10 max-w-2xl content-center p-4 sm:p-6">{render(articleData.content)}</div>
-		</div>
-	);
-}
diff --git a/website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx b/website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx
new file mode 100644
index 0000000000..d5ef91ac4f
--- /dev/null
+++ b/website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx
@@ -0,0 +1,153 @@
+import {
+	getArticle,
+	getDimensionsFromStoryblokImageUrl,
+	getPublishedDateFormatted,
+	getRelativeArticles,
+} from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { StoryblokImageWithCaption } from '@/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption';
+import { StoryblokArticle, StoryblokAuthor, StoryblokTag } from '@socialincome/shared/src/storyblok/journal';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { Badge, QuotedText, Separator, Typography } from '@socialincome/ui';
+import { ISbStoryData } from '@storyblok/react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { render } from 'storyblok-rich-text-react-renderer';
+
+export const revalidate = 900;
+
+function renderWrapper(articleData: StoryblokArticle) {
+	return render(articleData.content, {
+		blokResolvers: {
+			['quotedText']: (props: any) => <QuotedText {...props} />,
+			['imageWithCaption']: (props: any) => <StoryblokImageWithCaption {...props} />,
+		},
+	});
+}
+
+function badgeWithLink(lang: string, region: string, tag: ISbStoryData<StoryblokTag>, variant: 'outline' | 'default') {
+	return (
+		<Link href={`/${lang}/${region}/journal/tag/${tag.slug}`}>
+			<Badge key={tag.slug} variant={variant} className="mt-6">
+				{tag.content.value}
+			</Badge>
+		</Link>
+	);
+}
+
+const NUMBER_OF_RELATIVE_ARTICLES = 3;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+
+	const articleResponse = await getArticle(lang, slug);
+	const articleData = articleResponse.data.story.content;
+	const author: ISbStoryData<StoryblokAuthor> = articleData.author;
+
+	const blogs = await getRelativeArticles(
+		author.uuid,
+		articleResponse.data.story.id,
+		articleData.tags?.map((it) => it.uuid),
+		lang,
+		NUMBER_OF_RELATIVE_ARTICLES,
+	);
+
+	let translator = await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(articleData.image.filename);
+	return (
+		<div>
+			<div className="blog w-full justify-center">
+				<div className="bg-primary flex flex-col md:min-h-screen md:flex-row">
+					<div className="md:order-2 md:w-1/2">
+						<Image
+							src={articleData.image.filename}
+							alt={articleData.image?.alt}
+							className="w-full object-cover md:h-screen"
+							width={dimensionsFromStoryblokImage.width}
+							height={dimensionsFromStoryblokImage.height}
+						/>
+					</div>
+					<div className="flex flex-col justify-center p-8 text-left md:order-1 md:w-1/2 md:items-start lg:p-16">
+						<div className="flex flex-wrap justify-start gap-2">
+							<Typography
+								weight="medium"
+								color="popover"
+								size="lg"
+								key={articleData.type?.content.id}
+								className="uppercase"
+							>
+								{articleData.type?.content.value}
+							</Typography>
+							<Typography size="lg" weight="normal" color="popover" className="ml-4">
+								{getPublishedDateFormatted(articleResponse.data.story.first_published_at!, lang)}
+							</Typography>
+						</div>
+						<Typography weight="medium" className="mt-8" color="accent" size="5xl">
+							{articleData.title}
+						</Typography>
+
+						<Link href={`/${lang}/${region}/journal/author/${author.slug}`}>
+							<div className="mt-8 flex items-center space-x-4">
+								<StoryblokAuthorImage size="large" author={author} lang={lang} region={region} />
+								<Typography size="lg" as="span" color="popover" className="ml-1">
+									{author.content.fullName}
+								</Typography>
+							</div>
+						</Link>
+
+						<div className="mt-4 flex flex-wrap justify-start gap-2">
+							{articleData.tags?.map((tag) => badgeWithLink(lang, region, tag, 'outline'))}
+						</div>
+					</div>
+				</div>
+
+				<div className="prose mx-auto my-4 max-w-2xl content-center p-4 sm:p-6">
+					<Typography weight="bold" size="2xl">
+						{articleData.leadText}
+					</Typography>
+					<Typography as="div" className="text-black [&_a]:font-normal">
+						{renderWrapper(articleData)}
+					</Typography>
+					<Separator />
+
+					<div className="mt-4 flex flex-wrap justify-start gap-2">
+						{articleData.tags?.map((tag) => badgeWithLink(lang, region, tag, 'default'))}
+					</div>
+
+					<Link href={`/${lang}/${region}/journal/author/${author.slug}`} className="no-underline">
+						<div className="mt-5 flex items-center space-x-4">
+							<StoryblokAuthorImage size="large" author={author} lang={lang} region={region} />
+							<Typography size="lg" as="span" className="ml-1" color="foreground">
+								{author.content.fullName}
+							</Typography>
+						</div>
+					</Link>
+				</div>
+
+				{articleData.showRelativeArticles && (
+					<div>
+						<Typography size="4xl" className="text-center">
+							{translator.t('article.keep-reading')}
+						</Typography>
+						<div className="mb-10 mt-3 grid grid-cols-1 content-center justify-center gap-4 p-10 md:pl-20 md:pr-20 lg:grid-cols-3">
+							{blogs.map((blog) => (
+								<StoryblokArticleCard
+									key={blog.uuid}
+									lang={lang}
+									region={region}
+									blog={blog}
+									author={blog.content.author}
+								/>
+							))}
+						</div>
+					</div>
+				)}
+			</div>
+		</div>
+	);
+}
diff --git a/website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx b/website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
new file mode 100644
index 0000000000..66380cd069
--- /dev/null
+++ b/website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
@@ -0,0 +1,47 @@
+import { getArticlesByAuthor, getAuthor } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { BaseContainer, Typography } from '@socialincome/ui';
+
+export const revalidate = 900;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+	const author = (await getAuthor(slug, lang)).data.story;
+
+	const blogsResponse = await getArticlesByAuthor(author.uuid, lang);
+
+	const blogs = blogsResponse.data.stories;
+	await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	return (
+		<BaseContainer>
+			<div className="mx-auto mb-20 mt-8 flex max-w-6xl justify-center gap-4">
+				<StoryblokAuthorImage
+					author={author}
+					size="extra-large"
+					className="h-24 w-24 rounded-full object-cover"
+					lang={lang}
+					region={region}
+				/>
+
+				<div>
+					<Typography weight="bold" size="4xl">
+						{author.content.fullName}
+					</Typography>
+					<Typography className="mt-2 text-black">{author.content.bio}</Typography>
+				</div>
+			</div>
+
+			<div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
+				{blogs.map((blog) => (
+					<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={author} />
+				))}
+			</div>
+		</BaseContainer>
+	);
+}
diff --git a/website/src/app/[lang]/[region]/(website)/journal/page.tsx b/website/src/app/[lang]/[region]/(website)/journal/page.tsx
index 5af96e20d6..5fea8883be 100644
--- a/website/src/app/[lang]/[region]/(website)/journal/page.tsx
+++ b/website/src/app/[lang]/[region]/(website)/journal/page.tsx
@@ -1,34 +1,22 @@
 import { DefaultPageProps } from '@/app/[lang]/[region]';
+import { getAuthors, getOverviewArticles, getTags } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
 import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
-import { StoryblokArticle } from '@socialincome/shared/src/storyblok/journal';
 import { Translator } from '@socialincome/shared/src/utils/i18n';
-import { BaseContainer, Card, CardContent, Typography } from '@socialincome/ui';
-import { getStoryblokApi } from '@storyblok/react';
-import { DateTime } from 'luxon';
-import Image from 'next/image';
+import { Badge, BaseContainer, Typography } from '@socialincome/ui';
 import Link from 'next/link';
-import { ISbStories, ISbStoriesParams } from 'storyblok-js-client/src/interfaces';
 
 export const revalidate = 900;
 
-function getPublishedDateFormatted(date: string) {
-	const dateObject = DateTime.fromISO(date);
-	return dateObject.isValid ? dateObject.toFormat('dd/MM/yyyy') : '';
-}
-
-async function loadOverviewBlogs(lang: string): Promise<ISbStories<StoryblokArticle>> {
-	const params: ISbStoriesParams = {
-		resolve_relations: ['article.author', 'article.topics'],
-		language: lang,
-		with_tag: 'overview',
-		sort_by: 'created_at:desc',
-	};
-	return await getStoryblokApi().get(`cdn/stories`, params);
-}
-
 export default async function Page({ params: { lang, region } }: DefaultPageProps) {
-	const overviewBlogsResponse = await loadOverviewBlogs(lang);
-	const blogObjects = overviewBlogsResponse.data.stories;
+	const [blogsResponse, authorsResponse, tagsResponse] = await Promise.all([
+		getOverviewArticles(lang),
+		getAuthors(lang),
+		getTags(lang),
+	]);
+	const blogs = blogsResponse.data.stories;
+	const authors = authorsResponse.data.stories;
+	const tags = tagsResponse.data.stories;
 	const translator = await Translator.getInstance({
 		language: lang,
 		namespaces: ['website-journal'],
@@ -36,39 +24,46 @@ export default async function Page({ params: { lang, region } }: DefaultPageProp
 
 	return (
 		<BaseContainer>
-			<Typography weight="bold" className="text-center" size="3xl">
+			<Typography weight="bold" className="text-center" size="5xl">
 				{translator.t('overview.title')}
 			</Typography>
-			<Typography className="mt-5 text-center" size="xl">
+			<Typography className="mt-8 text-center text-black" size="xl">
 				{translator.t('overview.description')}
 			</Typography>
-			<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
-				{blogObjects.map((blog) => (
-					<Link href={`/${lang}/${region}/${blog.default_full_slug!}`} key={blog.content.title}>
-						<Card className="overflow-hidden transition-transform duration-300 hover:scale-[102%]">
-							<Image
-								src={blog.content.image.filename}
-								alt={blog.content.title}
-								width={600}
-								height={400}
-								className="h-48 w-full object-cover"
-							/>
-							<CardContent className="flex flex-grow flex-col p-6">
-								<Typography size="xl" className="mb-4 line-clamp-2 h-14 flex-grow font-medium">
-									{blog.content.title}
-								</Typography>
-								<div className="mt-auto flex items-center justify-between">
-									<div className="flex items-center space-x-2">
-										<StoryblokAuthorImage author={blog.content.author} />
-										<Typography>{blog.content.author.content.fullName}</Typography>
-									</div>
-									<Typography>{getPublishedDateFormatted(blog.published_at!)}</Typography>
-								</div>
-							</CardContent>
-						</Card>
+			<Typography className="mb-4 mt-12 text-center" size="3xl" weight="medium">
+				{translator.t('overview.editors')}
+			</Typography>
+			<div className="mx-auto mb-10 grid max-w-lg grid-cols-[repeat(auto-fit,minmax(60px,1fr))] place-items-center gap-2">
+				{authors.map((author, index) => (
+					<div key={index} className="flex flex-col justify-center text-center">
+						<Link href={`/${lang}/${region}/journal/author/${author.slug}`}>
+							<StoryblokAuthorImage className="mb-1" author={author} size="extra-large" lang={lang} region={region} />
+
+							<Typography>{author.content.firstName}</Typography>
+							<Typography>{author.content.lastName}</Typography>
+						</Link>
+					</div>
+				))}
+			</div>
+
+			<div className="mt-16 flex grid-cols-3 justify-start gap-1">
+				<Badge size="md" key={'overview'} variant="default" className="mb-2">
+					{translator.t('overview.all')}
+				</Badge>
+				{tags.map((tag) => (
+					<Link key={tag.slug} href={`/${lang}/${region}/journal/tag/${tag.slug}`}>
+						<Badge size="md" variant="outline" className="mb-2">
+							{tag.content.value}
+						</Badge>
 					</Link>
 				))}
 			</div>
+
+			<div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
+				{blogs.map((blog) => (
+					<StoryblokArticleCard lang={lang} region={region} blog={blog} author={blog.content.author} key={blog.uuid} />
+				))}
+			</div>
 		</BaseContainer>
 	);
 }
diff --git a/website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx b/website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx
new file mode 100644
index 0000000000..9a2fada245
--- /dev/null
+++ b/website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx
@@ -0,0 +1,37 @@
+import { getArticlesByTag, getTag } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { BaseContainer, Typography } from '@socialincome/ui';
+
+export const revalidate = 900;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+	const tag = (await getTag(slug, lang)).data.story;
+	const blogsResponse = await getArticlesByTag(tag.uuid, lang);
+
+	const blogs = blogsResponse.data.stories;
+	await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	return (
+		<BaseContainer>
+			<div className="mx-auto mb-20 mt-8 flex max-w-6xl justify-center gap-4">
+				<div>
+					<Typography weight="bold" size="4xl">
+						{tag.content.value}
+					</Typography>
+					<Typography className="mt-2 text-black">{tag.content.description}</Typography>
+				</div>
+			</div>
+
+			<div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
+				{blogs.map((blog) => (
+					<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={blog.content.author} />
+				))}
+			</div>
+		</BaseContainer>
+	);
+}
diff --git a/website/src/app/api/preview/route.ts b/website/src/app/api/preview/route.ts
index 90b412e7e2..0660b78287 100644
--- a/website/src/app/api/preview/route.ts
+++ b/website/src/app/api/preview/route.ts
@@ -1,10 +1,12 @@
 import { cookies, draftMode } from 'next/headers';
 import { redirect, RedirectType } from 'next/navigation';
 
-const ALLOWED_SLUGS_PREFIXES = ['journal'];
+const ALLOWED_SLUGS_PREFIXES = ['journal', 'author', 'tag'];
 const DEFAULT_LANGUAGE = 'en';
 const ALLOWED_LANGUAGES = ['en', 'it', 'fr', 'de'];
 const DRAFT_MODE_COOKIE_NAME = '__prerender_bypass';
+const JOURNAL = 'journal';
+const DEFAULT_REGION = 'int';
 
 function getLanguage(slug: string | null) {
 	if (slug) {
@@ -66,5 +68,6 @@ export async function GET(request: Request) {
 		return new Response('Invalid token', { status: 401 });
 	}
 	enableDraftModeAndAdaptCookie();
-	redirect(`/${lang}/int/${slug}`, RedirectType.push);
+	const path = slug!.startsWith(JOURNAL) ? slug : `${JOURNAL}/${slug}`;
+	redirect(`/${lang}/${DEFAULT_REGION}/${path}`, RedirectType.push);
 }


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 14**

<details>
<summary>🧹 Nitpick comments (18)</summary><blockquote>

<details>
<summary>ui/src/components/typography/quotedText.tsx (1)</summary><blockquote>

`1-21`: **New QuotedText component looks good but could be improved**

The component effectively structures quoted text with appropriate typography and layout. The component renders the quote with proper attribution.

Consider two improvements:
1. Use semantic HTML `<blockquote>` element for better accessibility
2. Verify the large vertical padding (py-16 = 64px) is intentional


```diff
-<div className="w-full px-0 py-16">
+<blockquote className="w-full px-0 py-16">
    <Typography className="m-0 p-0" weight="bold" color="foreground" size="5xl">
        « {text} »
    </Typography>
    <div className="mt-4">
        <Typography color="foreground" size="md" className="m-0 p-0">
            {author}
        </Typography>
        {authorTitle && (
            <Typography color="foreground" size="md" className="m-0 p-0">
                {authorTitle}
            </Typography>
        )}
    </div>
-</div>
+</blockquote>
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx (2)</summary><blockquote>

`30-30`: **Fix invalid Tailwind CSS class.**

`grid-cols-0` is not a valid Tailwind CSS class and should be removed.


```diff
-			<div className="grid-cols-0 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+			<div className="mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
```

---

`31-34`: **Add empty state handling for no blogs.**

The component should handle the case when no blogs are returned.


```diff
+			{blogs.length === 0 && (
+				<Typography className="col-span-full text-center">No articles found for this tag.</Typography>
+			)}
 			{blogs.map((blog) => (
 				<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={blog.content.author} />
 			))}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx (2)</summary><blockquote>

`12-14`: **Implement proper error handling for dimension extraction.**

The component doesn't handle cases where `getDimensionsFromStoryblokImageUrl` might fail.


```diff
-	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(image.filename);
+	let dimensionsFromStoryblokImage = { width: 800, height: 600 }; // Default fallback dimensions
+	try {
+		dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(image.filename);
+	} catch (error) {
+		console.error('Error extracting image dimensions:', error);
+	}
```

---

`19-19`: **Unnecessary template literal.**

The className uses backticks without string interpolation.


```diff
-				className={`m-0 h-auto w-full object-contain p-0`}
+				className="m-0 h-auto w-full object-contain p-0"
```

</blockquote></details>
<details>
<summary>website/src/app/api/preview/route.ts (1)</summary><blockquote>

`71-72`: **Improved redirection logic but uses non-null assertion.**

The redirection logic has been improved, but the code uses a non-null assertion (slug!) which should be avoided.


```diff
-	const path = slug!.startsWith(JOURNAL) ? slug : `${JOURNAL}/${slug}`;
+	// At this point slug is validated and not null due to validateSlug check
+	const path = slug.startsWith(JOURNAL) ? slug : `${JOURNAL}/${slug}`;
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx (1)</summary><blockquote>

`20-39`: **Add error handling for image dimensions and date formatting.**

The component makes assumptions about the availability of certain data without proper error handling.


```diff
-	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(blog.content.image.filename);
+	let dimensionsFromStoryblokImage = { width: 800, height: 600 }; // Default fallback dimensions
+	try {
+		dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(blog.content.image.filename);
+	} catch (error) {
+		console.error('Error extracting image dimensions:', error);
+	}

// Later in the code:
-						{getPublishedDateFormatted(blog.first_published_at!, props.lang)}
+						{blog.first_published_at ? getPublishedDateFormatted(blog.first_published_at, props.lang) : ''}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx (5)</summary><blockquote>

`24-25`: **Add type safety to custom renderers**

The `blokResolvers` are using `any` type for component props, which bypasses TypeScript's type checking. Consider creating proper interfaces for these components' props.

```diff
-			['quotedText']: (props: any) => <QuotedText {...props} />,
-			['imageWithCaption']: (props: any) => <StoryblokImageWithCaption {...props} />,
+			['quotedText']: (props: QuotedTextProps) => <QuotedText {...props} />,
+			['imageWithCaption']: (props: ImageWithCaptionProps) => <StoryblokImageWithCaption {...props} />,
```

---

`45-55`: **Consider parallel data fetching for improved performance**

The article and related articles are fetched sequentially. Consider using Promise.all to fetch them in parallel for better performance.

```diff
-	const articleResponse = await getArticle(lang, slug);
-	const articleData = articleResponse.data.story.content;
-	const author: ISbStoryData<StoryblokAuthor> = articleData.author;
-
-	const blogs = await getRelativeArticles(
-		author.uuid,
-		articleResponse.data.story.id,
-		articleData.tags?.map((it) => it.uuid),
-		lang,
-		NUMBER_OF_RELATIVE_ARTICLES,
-	);
+	const [articleResponse, translator] = await Promise.all([
+		getArticle(lang, slug),
+		Translator.getInstance({
+			language: lang,
+			namespaces: ['website-journal'],
+		})
+	]);
+	
+	const articleData = articleResponse.data.story.content;
+	const author: ISbStoryData<StoryblokAuthor> = articleData.author;
+	
+	const blogs = await getRelativeArticles(
+		author.uuid,
+		articleResponse.data.story.id,
+		articleData.tags?.map((it) => it.uuid),
+		lang,
+		NUMBER_OF_RELATIVE_ARTICLES,
+	);
```

---

`57-62`: **Merge parallel fetching with translator**

The translator instantiation can be included in the Promise.all suggestion above to further optimize data fetching.

---

`94-101`: **Remove duplicated author information section**

The author information appears twice in the UI. Consider refactoring to use a shared component or function to avoid repetition.



Also applies to: 122-129

---

`132-149`: **Add empty state for related articles**

Consider showing a message when there are no related articles but showRelativeArticles is true.

```diff
	{articleData.showRelativeArticles && (
		<div>
			<Typography size="4xl" className="text-center">
				{translator.t('article.keep-reading')}
			</Typography>
			<div className="mb-10 mt-3 grid grid-cols-1 content-center justify-center gap-4 p-10 md:pl-20 md:pr-20 lg:grid-cols-3">
+				{blogs.length === 0 ? (
+					<Typography className="text-center col-span-3">
+						{translator.t('article.no-related-articles')}
+					</Typography>
+				) : (
					{blogs.map((blog) => (
						<StoryblokArticleCard
							key={blog.uuid}
							lang={lang}
							region={region}
							blog={blog}
							author={blog.content.author}
						/>
					))}
+				)}
			</div>
		</div>
	)}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx (1)</summary><blockquote>

`27-32`: **Add quality property to Image component**

Consider adding a quality property to the Image component for better image optimization.

```diff
	<Image
		src={author.content.avatar.filename}
		alt={author.content.fullName + '-avatar'}
		className={`${sizeClasses[size]} flex-none rounded-full object-cover object-top ${className}`}
		width={dimensionsFromStoryblokImage.width}
		height={dimensionsFromStoryblokImage.height}
+		quality={85}
	/>
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/page.tsx (1)</summary><blockquote>

`36-47`: **Improve accessibility for author links**

Add aria-label to author links for better accessibility.

```diff
	<Link 
		href={`/${lang}/${region}/journal/author/${author.slug}`}
+		aria-label={`View articles by ${author.content.fullName}`}
	>
```

</blockquote></details>
<details>
<summary>shared/src/storyblok/journal.ts (3)</summary><blockquote>

`3-7`: **Align enum value with interface name**

The enum uses 'topic' for Tag type, while the interface is named 'StoryblokTag'. This inconsistency could be confusing.

```diff
export enum StoryblokContentType {
	Article = 'article',
	Author = 'author',
-	Tag = 'topic',
+	Tag = 'tag',
}
```

---

`40-40`: **Add type safety to content property**

The `content` property in StoryblokArticle is typed as `any`, which reduces type safety. Consider creating a more specific type.

```diff
-	content: any;
+	content: StoryblokRichText;

+// Add this interface elsewhere in the file
+export interface StoryblokRichText {
+	document: {
+		content: Array<{
+			type: string;
+			content?: StoryblokRichText['document']['content'];
+			text?: string;
+			marks?: Array<{ type: string }>;
+		}>;
+		nodeType: string;
+	};
+}
```

---

`1-49`: **Add JSDoc comments to interfaces**

All interfaces lack documentation. Add JSDoc comments to explain their purpose and usage.

```diff
+/**
+ * Enum representing the different content types in Storyblok
+ */
export enum StoryblokContentType {
	Article = 'article',
	Author = 'author',
	Tag = 'topic',
}

+/**
+ * Interface representing an image in Storyblok
+ */
export interface StoryblokImage {
	alt: string;
	filename: string;
	id: number;
}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary><blockquote>

`169-170`: **Consider optional chaining.**
If `tags` can be undefined or null, using `tags?.length` is safer and more idiomatic.

```diff
- return tags && tags.length
+ return tags?.length
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Biome (1.9.4)</summary>

[error] 170-170: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2ea7a7225bfd46741ac9671bc521e81083121539 and c6ae009de21ecbb1cde08a2f6fa76d64f4ea666e.

</details>

<details>
<summary>📒 Files selected for processing (18)</summary>

* `shared/locales/de/website-journal.json` (1 hunks)
* `shared/locales/en/website-journal.json` (1 hunks)
* `shared/locales/fr/website-journal.json` (1 hunks)
* `shared/locales/it/website-journal.json` (1 hunks)
* `shared/src/storyblok/journal.ts` (1 hunks)
* `ui/src/components/badge.tsx` (1 hunks)
* `ui/src/components/typography/index.ts` (1 hunks)
* `ui/src/components/typography/quotedText.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/[...slug]/page.tsx` (0 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx` (1 hunks)
* `website/src/app/api/preview/route.ts` (2 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (1)</summary>

* website/src/app/[lang]/[region]/(website)/journal/[...slug]/page.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (5)</summary>

<details>
<summary>website/src/app/api/preview/route.ts (1)</summary><blockquote>

<details>
<summary>shared/src/firebase/index.ts (1)</summary>

* `DEFAULT_REGION` (1-1)

</details>

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx (2)</summary><blockquote>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary>

* `getDimensionsFromStoryblokImageUrl` (32-38)

</details>
<details>
<summary>ui/src/components/typography/typography.tsx (1)</summary>

* `Typography` (69-96)

</details>

</blockquote></details>
<details>
<summary>ui/src/components/typography/quotedText.tsx (1)</summary><blockquote>

<details>
<summary>ui/src/components/typography/typography.tsx (1)</summary>

* `Typography` (69-96)

</details>

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx (4)</summary><blockquote>

<details>
<summary>shared/src/storyblok/journal.ts (3)</summary>

* `StoryblokArticle` (37-49)
* `StoryblokTag` (20-25)
* `StoryblokAuthor` (27-35)

</details>
<details>
<summary>shared/src/types/language.ts (1)</summary>

* `LanguageCode` (4-4)

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (4)</summary>

* `getArticle` (242-254)
* `getRelativeArticles` (217-240)
* `getDimensionsFromStoryblokImageUrl` (32-38)
* `getPublishedDateFormatted` (20-23)

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx (1)</summary>

* `StoryblokArticleCard` (12-60)

</details>

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary><blockquote>

<details>
<summary>shared/src/storyblok/journal.ts (3)</summary>

* `StoryblokAuthor` (27-35)
* `StoryblokTag` (20-25)
* `StoryblokArticle` (37-49)

</details>

</blockquote></details>

</details><details>
<summary>🪛 Biome (1.9.4)</summary>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts</summary>

[error] 170-170: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

---

[error] 32-32: Don't use 'String' as a type.

Use lowercase primitives for consistency.
Safe fix: Use 'string' instead


(lint/complexity/noBannedTypes)

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (2)</summary>

* GitHub Check: Deploy admin on Firebase (social-income-staging) / Deploy admin on Firebase
* GitHub Check: Test website

</details>

<details>
<summary>🔇 Additional comments (17)</summary><blockquote>

<details>
<summary>shared/locales/it/website-journal.json (2)</summary>

`3-3`: **New localization key added.**

The "all" key with value "Tutti" has been added to the Italian localization, which aligns with similar additions in other language files.

---

`7-9`: **New article section added with related content label.**

A new "article" section with the "keep-reading" key has been added for related articles functionality. The translation "Articoli correlati" is appropriate for the Italian context.

</details>
<details>
<summary>shared/locales/fr/website-journal.json (2)</summary>

`3-3`: **New localization key added.**

The "all" key with value "Tous" has been added to the French localization, maintaining consistency with other language files.

---

`7-9`: **New article section added with related content label.**

A new "article" section with the "keep-reading" key has been added. The French translation "À lire aussi" is appropriate and aligns with the functionality in other language files.

</details>
<details>
<summary>shared/locales/en/website-journal.json (3)</summary>

`3-3`: **New localization key added.**

The "all" key with value "All" has been added to the English localization, consistent with the pattern in other language files.

---

`6-6`: **Added editors localization key.**

The "editors" key with value "Contributing Editors" has been added to the English localization. This key appears to be unique to the English file among those reviewed.

---

`8-10`: **New article section added with related content label.**

A new "article" section with the "keep-reading" key has been added, following the same pattern as other language files.

</details>
<details>
<summary>ui/src/components/typography/index.ts (1)</summary>

`2-2`: **New typography component export added.**

Added export for the QuotedText component, which according to the summary is a new component for displaying quotes with author attribution.

</details>
<details>
<summary>ui/src/components/badge.tsx (1)</summary>

`17-17`: **Added white background to outline variant**

Adding `bg-white` to the outline variant ensures consistent appearance on different backgrounds where this badge might be placed.

</details>
<details>
<summary>shared/locales/de/website-journal.json (1)</summary>

`3-9`: **Translation keys added match functionality updates**

The new keys "all" and "keep-reading" align with journal UI updates. The removal of "written-by" and "published" keys (mentioned in the summary) suggests a change in how author information is displayed.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx (1)</summary>

`8-8`: **Sensible revalidation period**

The 15-minute revalidation period (900 seconds) is appropriate for content that doesn't change frequently.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx (1)</summary>

`1-8`: **Appropriate imports and setup for the tag page component.**

The imports and revalidation setup are correctly structured for this Next.js page component.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx (1)</summary>

`1-11`: **Good component structure with appropriate props.**

The component has a clean interface with well-defined props.

</details>
<details>
<summary>website/src/app/api/preview/route.ts (1)</summary>

`4-9`: **Expanded slug prefixes and added new constants.**

The ALLOWED_SLUGS_PREFIXES has been expanded to include 'author' and 'tag', and two new constants have been added.


Consider documenting the purpose of these constants with comments for better code maintenance.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx (2)</summary>

`1-11`: **Well-structured imports for the new component.**

All necessary dependencies are correctly imported.

---

`12-19`: **Complete and well-typed props interface.**

The component props are well-defined with appropriate types.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary>

`20-23`: **Date formatting approach looks solid.**
No immediate issues found, and the usage of Luxon is appropriate. Ensure your localization strings match the desired format of your target audiences.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:02:35Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/page.tsx (1)</summary><blockquote>

`12-16`: **[Duplicate comment] Missing explicit error handling.**

As previously noted, using `Promise.all()` for these API calls may cause an uncaught error if any call fails. Consider wrapping them in a try/catch if needed.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary><blockquote>

`169-190`: **Use optional chaining.**

Per static analysis, replace `tags && tags.length` with `tags?.length` to simplify checks and reduce potential pitfalls.


```diff
-function createRelativeArticlesFilter(tags: string[], authorId: string) {
-  return tags && tags.length
+function createRelativeArticlesFilter(tags: string[], authorId: string) {
+  return tags?.length
    ? {
      __or: [
        ...
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Biome (1.9.4)</summary>

[error] 170-170: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c6ae009de21ecbb1cde08a2f6fa76d64f4ea666e and 6f31c0c7dff5bdc775bcc2dae05528b2f46ae752.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
* website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary><blockquote>

<details>
<summary>shared/src/storyblok/journal.ts (3)</summary>

* `StoryblokAuthor` (27-35)
* `StoryblokTag` (20-25)
* `StoryblokArticle` (37-49)

</details>

</blockquote></details>

</details><details>
<summary>🪛 Biome (1.9.4)</summary>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts</summary>

[error] 170-170: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (3)</summary>

* GitHub Check: Test website
* GitHub Check: Prettify
* GitHub Check: Security checks (typescript)

</details>

<details>
<summary>🔇 Additional comments (18)</summary><blockquote>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (13)</summary>

`20-23`: **Looks good!**

This date formatting logic is straightforward and effectively uses Luxon for localized formatting.

---

`26-28`: **Draft mode inclusion is clear.**

Adding the version parameter based on `draftMode().isEnabled` is a neat approach for toggling between draft and published data.

---

`40-51`: **Fetching authors is straightforward.**

The filtering logic for `displayInOverviewPage` is clear, and the usage of `getStoryblokApi` is consistent with the rest of the file.

---

`53-64`: **Similar approach for fetching tags.**

No issues detected; parameters and filtering align well with the project’s conventions.

---

`66-81`: **Fetching articles by tag.**

Looks good—resolving relations, sorting by `first_published_at`, and applying the correct filter query are all appropriate.

---

`82-101`: **Fetching articles by author.**

The design is consistent with `getArticlesByTag`, and the pagination limit is a helpful touch.

---

`103-123`: **Consider wrapping `getOverviewArticles` in a fallback if needed.**

Currently, if `getOverviewArticles` fails (e.g., network error), the error may bubble up. Verify whether silent failure is acceptable or if you want to wrap it in `getWithFallback` or a try/catch block.

---

`125-136`: **The fallback approach for getting a single tag is neat.**

Using `getWithFallback` here ensures a robust fallback mechanism for 404 scenarios.

---

`138-149`: **Same fallback strategy for author.**

No concerns, the code is consistent with `getTag`.

---

`151-167`: **Graceful fallback approach.**

Returning `notFound()` or retrying with `en` is a sound way to handle non-existent content.

---

`192-211`: **Retrieving articles by author and tags.**

Implementation with a dynamic filter is clear, and the usage of `addVersionParameter` remains consistent.

---

`217-240`: **Relative articles fallback is handled well.**

Fetching additional articles if not enough related ones are found provides a good user experience.

---

`242-254`: **Fetching a single article with fallback.**

Mirrors the pattern used for tags and authors. No issues found.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/page.tsx (5)</summary>

`2-2`: **Import statements look good.**

Bringing in these APIs and UI components is consistent with the established import structure.



Also applies to: 6-6

---

`27-30`: **Typography changes are fine.**

Increasing the heading size and refining text classes improves readability.

---

`34-39`: **Authors grid.**

Good usage of dynamic mapping with a fallback slug-based link. The structure is clean, and the new grid approach is clear.

---

`50-57`: **Tags with badges.**

The new tag listing utilizes badges nicely. The key usage with `tag.slug` is correct for React lists.

---

`62-66`: **Listing blog articles in a grid.**

Structure is responsive, and usage of `StoryblokArticleCard` is consistent. No issues here.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:09:57Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>♻️ Duplicate comments (8)</summary><blockquote>

<details>
<summary>website/src/app/api/preview/route.ts (1)</summary><blockquote>

`8-9`: _⚠️ Potential issue_

**Rename the conflicting constant.**

The `DEFAULT_REGION` constant conflicts with another one in `shared/src/firebase/index.ts` which has a different value ('europe-west6'). This can lead to confusion and potential bugs.




```diff
const JOURNAL = 'journal';
-const DEFAULT_REGION = 'int';
+const DEFAULT_WEBSITE_REGION = 'int';
```

And update its usage:

```diff
-redirect(`/${lang}/${DEFAULT_REGION}/${path}`, RedirectType.push);
+redirect(`/${lang}/${DEFAULT_WEBSITE_REGION}/${path}`, RedirectType.push);
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx (1)</summary><blockquote>

`9-19`: _⚠️ Potential issue_

**Add error handling for API calls.**

The component makes API calls without any error handling. If either request fails, it would cause an unhandled runtime error.




```diff
export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
	const { slug, lang, region } = props.params;
-	const tag = (await getTag(slug, lang)).data.story;
-	const blogsResponse = await getArticlesByTag(tag.uuid, lang);
-
-	const blogs = blogsResponse.data.stories;
+	try {
+		const tag = (await getTag(slug, lang)).data.story;
+		const blogsResponse = await getArticlesByTag(tag.uuid, lang);
+		
+		const blogs = blogsResponse.data.stories;
+		await Translator.getInstance({
+			language: lang,
+			namespaces: ['website-journal'],
+		});
+		
+		return (
+			<BaseContainer>
+				<div className="mx-auto mb-20 mt-8 flex max-w-6xl justify-center gap-4">
+					<div>
+						<Typography weight="bold" size="4xl">
+							{tag.content.value}
+						</Typography>
+						<Typography className="mt-2 text-black">{tag.content.description}</Typography>
+					</div>
+				</div>
+
+				<div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
+					{blogs.map((blog) => (
+						<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={blog.content.author} />
+					))}
+				</div>
+			</BaseContainer>
+		);
+	} catch (error) {
+		console.error('Error fetching tag data:', error);
+		return <BaseContainer><Typography>Failed to load content</Typography></BaseContainer>;
+	}
}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx (1)</summary><blockquote>

`49-49`: _⚠️ Potential issue_

**Handle potential null values in nested objects.**

The component accesses nested properties without checking if they exist.




```diff
-							{blog.content.author.content.fullName}
+							{blog.content.author?.content?.fullName || 'Unknown Author'}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx (2)</summary><blockquote>

`15-21`: **Remove unused props from component signature.**

The `lang` and `region` props are required but not used within the component.




```diff
function StoryblokAuthorImage(props: {
	author: ISbStoryData<StoryblokAuthor>;
	size?: Size;
	className?: string;
-	lang: string;
-	region: string;
}) {
```

---

`23-23`: _🛠️ Refactor suggestion_

**Add error handling for image dimensions.**

Add fallback dimensions if getDimensionsFromStoryblokImageUrl fails to extract them from the filename.




```diff
-	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(author.content.avatar.filename);
+	let dimensionsFromStoryblokImage;
+	try {
+		dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(author.content.avatar.filename);
+	} catch (error) {
+		dimensionsFromStoryblokImage = { width: 100, height: 100 }; // Default dimensions
+		console.error('Failed to get author image dimensions:', error);
+	}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx (1)</summary><blockquote>

`61-61`: _🛠️ Refactor suggestion_

**Add fallback handling for image dimensions.**  
`getDimensionsFromStoryblokImageUrl` may return invalid dimensions if the URL doesn’t match the expected pattern. A try/catch or fallback dimension can improve stability.  

```diff
- const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(articleData.image.filename);
+ let dimensionsFromStoryblokImage;
+ try {
+   dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(articleData.image.filename);
+ } catch {
+   dimensionsFromStoryblokImage = { width: 1200, height: 800 };
+ }
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/page.tsx (1)</summary><blockquote>

`12-16`: **Use try/catch for concurrent API calls.**  
A previous comment also addressed this: missing error handling can cause unexpected breakages if fetching `blogsResponse`, `authorsResponse`, or `tagsResponse` fails.

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary><blockquote>

`30-38`: **Validate URL format in `getDimensionsFromStoryblokImageUrl`.**  
If the URL doesn’t contain the expected `664x488` segment, this could throw. A fallback dimension or error handling would be safer.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>website/src/app/api/preview/route.ts (1)</summary><blockquote>

`71-72`: **Ensure path is correctly constructed.**

The logic to form the redirection path has been improved, but consider adding a safety check for the `slug` variable before using it.



```diff
-const path = slug!.startsWith(JOURNAL) ? slug : `${JOURNAL}/${slug}`;
+const path = slug ? (slug.startsWith(JOURNAL) ? slug : `${JOURNAL}/${slug}`) : JOURNAL;
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx (1)</summary><blockquote>

`37-37`: **Add null check for first_published_at.**

Using the non-null assertion operator (!) is risky. Add proper null checking.



```diff
-						{getPublishedDateFormatted(blog.first_published_at!, props.lang)}
+						{blog.first_published_at ? getPublishedDateFormatted(blog.first_published_at, props.lang) : ''}
```

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx (2)</summary><blockquote>

`19-19`: **Consider externalizing the revalidation interval.**  
Using a hardcoded value of 900 for `revalidate` is fine for now, but you might want to store this in a config or use an environment variable for clarity and maintainability.

---

`42-60`: **Consider adding error handling for API calls.**  
If `getArticle` or `getRelativeArticles` fails, the current code might break at runtime. You could wrap these calls in a try/catch or show a fallback UI.

</blockquote></details>
<details>
<summary>shared/src/storyblok/journal.ts (1)</summary><blockquote>

`3-8`: **Check naming consistency for enum values.**  
`StoryblokContentType.Tag = 'topic'` might be confusing if you primarily use “tag” elsewhere. Consider making them align for clarity.

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary><blockquote>

`169-190`: **Optional chain suggestion (Biome hint).**  
`if (tags && tags.length)` works fine, but an optional chain like `if (tags?.length)` could be used. Consider whether it improves readability.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Biome (1.9.4)</summary>

[error] 170-170: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6f31c0c7dff5bdc775bcc2dae05528b2f46ae752 and 87694e813f5d078d4f3e94896afbe6b68f195d3f.

</details>

<details>
<summary>📒 Files selected for processing (12)</summary>

* `shared/src/storyblok/journal.ts` (1 hunks)
* `ui/src/components/typography/index.ts` (1 hunks)
* `ui/src/components/typography/quotedText.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/page.tsx` (1 hunks)
* `website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx` (1 hunks)
* `website/src/app/api/preview/route.ts` (2 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (4)</summary>

* ui/src/components/typography/index.ts
* ui/src/components/typography/quotedText.tsx
* website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
* website/src/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (4)</summary>

<details>
<summary>website/src/app/api/preview/route.ts (1)</summary><blockquote>

<details>
<summary>shared/src/firebase/index.ts (1)</summary>

* `DEFAULT_REGION` (1-1)

</details>

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx (3)</summary><blockquote>

<details>
<summary>shared/src/storyblok/journal.ts (3)</summary>

* `StoryblokArticle` (37-49)
* `StoryblokTag` (20-25)
* `StoryblokAuthor` (27-35)

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (2)</summary>

* `getArticle` (242-254)
* `getRelativeArticles` (217-240)

</details>
<details>
<summary>shared/src/utils/i18n.ts (1)</summary>

* `Translator` (20-65)

</details>

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx (2)</summary><blockquote>

<details>
<summary>shared/src/storyblok/journal.ts (2)</summary>

* `StoryblokArticle` (37-49)
* `StoryblokAuthor` (27-35)

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (2)</summary>

* `getDimensionsFromStoryblokImageUrl` (32-38)
* `getPublishedDateFormatted` (20-23)

</details>

</blockquote></details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (1)</summary><blockquote>

<details>
<summary>shared/src/storyblok/journal.ts (3)</summary>

* `StoryblokAuthor` (27-35)
* `StoryblokTag` (20-25)
* `StoryblokArticle` (37-49)

</details>

</blockquote></details>

</details><details>
<summary>🪛 Biome (1.9.4)</summary>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts</summary>

[error] 170-170: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (18)</summary><blockquote>

<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx (4)</summary>

`21-28`: **Validate shape of custom blok props.**  
The `renderWrapper` function’s `blokResolvers` are straightforward, but ensure that any custom props (e.g., for `quotedText` or `imageWithCaption`) match the expected shape to avoid rendering issues.

---

`30-38`: **Badge linking logic looks good.**  
The `badgeWithLink` function cleanly handles tags with links and badges.

---

`76-105`: **Ensure article type is defined.**  
`articleData.type?.content.value` is displayed in the UI. If `type` is missing in certain CMS entries, you could encounter undefined issues.

---

`132-149`: **Relative articles logic is clear.**  
The conditional addition of overview articles when there aren’t enough related ones is well-designed.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/page.tsx (4)</summary>

`28-32`: **Text and translation usage looks good.**  
Displaying the title and description with `translator.t` is concise and consistent.

---

`36-47`: **Clean layout for author listing.**  
The grid approach for rendering authors is clear and maintains a good user flow.

---

`49-60`: **Badge list for tags is properly implemented.**  
This snippet nicely uses `variant="outline"` for tags, which improves the visual distinction from the “all” badge.

---

`62-66`: **Solid grid layout for blog articles.**  
The columns and spacing choices (1→2→3 columns) scale nicely across breakpoints.

</details>
<details>
<summary>shared/src/storyblok/journal.ts (5)</summary>

`12-13`: **Additional image ID property.**  
Adding `id: number` to `StoryblokImage` is straightforward and aligns well with new references.

---

`15-18`: **New `StoryblokArticleType` interface.**  
Looks good for referencing distinct article types or categories.

---

`20-25`: **Extended `StoryblokTag` interface**  
The `description` and `displayInOverviewPage` properties are helpful for distinguishing front-end usage.

---

`30-35`: **Additional fields in `StoryblokAuthor`.**  
`firstName`, `lastName`, `displayInOverviewPage`, and `bio` should help with more detailed author pages.

---

`38-49`: **Enhanced `StoryblokArticle` structure.**  
Replacing `topics` with `tags` and adding `id`, `leadText`, and `showRelativeArticles` provide better scoping for article details.

</details>
<details>
<summary>website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts (5)</summary>

`20-23`: **Graceful date formatting fallback is good.**  
Returning an empty string if the date is invalid avoids runtime exceptions.

---

`40-51`: **Filtering authors by `displayInOverviewPage`.**  
This is a clean approach for controlling author visibility in your overview.

---

`125-136`: **getTag fallback logic is well implemented.**  
Using `getWithFallback` ensures consistent 404 handling and fallback to the default language if missing.

---

`151-167`: **Fallback logic in `getWithFallback` is solid.**  
Nicely avoids custom error handling duplication, ensuring consistent 404 fallback behavior.

---

`242-254`: **Article fetch fallback is consistent.**  
The usage of `getWithFallback` for dealing with not-found articles ensures consistent error-handling patterns.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:42:42Z
user: login: ssandino
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-13T20:21:16Z
user: login: mkue
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T14:28:25Z

Review comments:
diff_hunk: @@ -0,0 +1,47 @@
+import { getArticlesByAuthor, getAuthor } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { BaseContainer, Typography } from '@socialincome/ui';
+
+export const revalidate = 900;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+	const author = (await getAuthor(slug, lang)).data.story;
+
+	const blogsResponse = await getArticlesByAuthor(author.uuid, lang);
+
+	const blogs = blogsResponse.data.stories;
+	await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	return (
+		<BaseContainer>
+			<div className="mx-auto mb-20 mt-8 flex max-w-6xl justify-center gap-4">
+				<StoryblokAuthorImage
+					author={author}
+					size="extra-large"
+					className="h-24 w-24 rounded-full object-cover"
+					lang={lang}
+					region={region}
+				/>
+
+				<div>
+					<Typography weight="bold" size="4xl">
+						{author.content.fullName}
+					</Typography>
+					<Typography className="mt-2 text-black">{author.content.bio}</Typography>
+				</div>
+			</div>
+
+			<div className="grid-cols-0 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+				{blogs.map((blog) => (
+					<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={author} />
+				))}
+			</div>
+		</BaseContainer>
+	);
+}
path: website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Missing page title for SEO**

The component doesn't set a page title, which is important for SEO.


Consider adding a title tag in a metadata export:

```typescript
export async function generateMetadata({ params }: { params: { slug: string; lang: LanguageCode } }) {
  const { slug, lang } = params;
  const author = (await getAuthor(slug, lang)).data.story;
  
  return {
    title: `${author.content.fullName} | Journal`,
    description: author.content.bio,
  };
}
```

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import { getArticlesByAuthor, getAuthor } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { BaseContainer, Typography } from '@socialincome/ui';
+
+export const revalidate = 900;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+	const author = (await getAuthor(slug, lang)).data.story;
+
+	const blogsResponse = await getArticlesByAuthor(author.uuid, lang);
+
+	const blogs = blogsResponse.data.stories;
+	await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	return (
+		<BaseContainer>
+			<div className="mx-auto mb-20 mt-8 flex max-w-6xl justify-center gap-4">
+				<StoryblokAuthorImage
+					author={author}
+					size="extra-large"
+					className="h-24 w-24 rounded-full object-cover"
+					lang={lang}
+					region={region}
+				/>
+
+				<div>
+					<Typography weight="bold" size="4xl">
+						{author.content.fullName}
+					</Typography>
+					<Typography className="mt-2 text-black">{author.content.bio}</Typography>
+				</div>
+			</div>
+
+			<div className="grid-cols-0 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
path: website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Grid columns configuration issue**

The grid starts with `grid-cols-0` which is unusual and may not work as expected. Typically, the smallest would be `grid-cols-1`.

```diff
-<div className="grid-cols-0 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+<div className="grid-cols-1 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
			<div className="grid-cols-1 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import { getArticlesByAuthor, getAuthor } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { BaseContainer, Typography } from '@socialincome/ui';
+
+export const revalidate = 900;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+	const author = (await getAuthor(slug, lang)).data.story;
+
+	const blogsResponse = await getArticlesByAuthor(author.uuid, lang);
+
+	const blogs = blogsResponse.data.stories;
+	await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	return (
+		<BaseContainer>
+			<div className="mx-auto mb-20 mt-8 flex max-w-6xl justify-center gap-4">
+				<StoryblokAuthorImage
+					author={author}
+					size="extra-large"
+					className="h-24 w-24 rounded-full object-cover"
+					lang={lang}
+					region={region}
+				/>
+
+				<div>
+					<Typography weight="bold" size="4xl">
+						{author.content.fullName}
+					</Typography>
+					<Typography className="mt-2 text-black">{author.content.bio}</Typography>
+				</div>
+			</div>
+
+			<div className="grid-cols-0 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+				{blogs.map((blog) => (
+					<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={author} />
+				))}
+			</div>
path: website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add empty state handling**

The component doesn't handle the case where an author has no articles.

```diff
-{blogs.map((blog) => (
-	<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={author} />
-))}
+{blogs.length > 0 ? (
+	blogs.map((blog) => (
+		<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={author} />
+	))
+) : (
+	<div className="col-span-3 text-center py-8">
+		<Typography size="lg">No articles found for this author.</Typography>
+	</div>
+)}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
				{blogs.length > 0 ? (
					blogs.map((blog) => (
						<StoryblokArticleCard key={blog.uuid} lang={lang} region={region} blog={blog} author={author} />
					))
				) : (
					<div className="col-span-3 text-center py-8">
						<Typography size="lg">No articles found for this author.</Typography>
					</div>
				)}
			</div>
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import { getArticlesByAuthor, getAuthor } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { BaseContainer, Typography } from '@socialincome/ui';
+
+export const revalidate = 900;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+	const author = (await getAuthor(slug, lang)).data.story;
+
+	const blogsResponse = await getArticlesByAuthor(author.uuid, lang);
+
+	const blogs = blogsResponse.data.stories;
+	await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
path: website/src/app/[lang]/[region]/(website)/journal/author/[slug]/page.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Missing error handling for API requests**

The page fetches author and article data but doesn't handle potential API failures.

```diff
export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
	const { slug, lang, region } = props.params;
-	const author = (await getAuthor(slug, lang)).data.story;
-
-	const blogsResponse = await getArticlesByAuthor(author.uuid, lang);
+	try {
+		const authorResponse = await getAuthor(slug, lang);
+		const author = authorResponse.data.story;
+
+		if (!author) {
+			return <div>Author not found</div>;
+		}
+
+		const blogsResponse = await getArticlesByAuthor(author.uuid, lang);
+		const blogs = blogsResponse.data.stories;

-	const blogs = blogsResponse.data.stories;
	await Translator.getInstance({
		language: lang,
		namespaces: ['website-journal'],
	});
```


> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { getArticlesByTag, getTag } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { BaseContainer, Typography } from '@socialincome/ui';
+
+export const revalidate = 900;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+	const tag = (await getTag(slug, lang)).data.story;
+	const blogsResponse = await getArticlesByTag(tag.uuid, lang);
+
+	const blogs = blogsResponse.data.stories;
+	await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	return (
path: website/src/app/[lang]/[region]/(website)/journal/tag/[slug]/page.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add error handling for the API calls.**

The page component makes two API calls without error handling. If either request fails, it would cause an unhandled runtime error.


```diff
-	const tag = (await getTag(slug, lang)).data.story;
-	const blogsResponse = await getArticlesByTag(tag.uuid, lang);
+	try {
+		const tag = (await getTag(slug, lang)).data.story;
+		const blogsResponse = await getArticlesByTag(tag.uuid, lang);
+		
+		const blogs = blogsResponse.data.stories;
+		await Translator.getInstance({
+			language: lang,
+			namespaces: ['website-journal'],
+		});
+	} catch (error) {
+		console.error('Error fetching tag data:', error);
+		// Consider returning an error UI component
+		return <BaseContainer><Typography>Failed to load content</Typography></BaseContainer>;
+	}
```


> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,10 +1,12 @@
 import { cookies, draftMode } from 'next/headers';
 import { redirect, RedirectType } from 'next/navigation';
 
-const ALLOWED_SLUGS_PREFIXES = ['journal'];
+const ALLOWED_SLUGS_PREFIXES = ['journal', 'author', 'tag'];
 const DEFAULT_LANGUAGE = 'en';
 const ALLOWED_LANGUAGES = ['en', 'it', 'fr', 'de'];
 const DRAFT_MODE_COOKIE_NAME = '__prerender_bypass';
+const JOURNAL = 'journal';
+const DEFAULT_REGION = 'int';
path: website/src/app/api/preview/route.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Potential constant naming conflict.**

The `DEFAULT_REGION` constant is defined here as 'int', but there's another constant with the same name in shared/src/firebase/index.ts set to 'europe-west6'.


It's important to verify that this won't cause conflicts in the codebase. Consider renaming for clarity:

```diff
-const DEFAULT_REGION = 'int';
+const DEFAULT_WEBSITE_REGION = 'int';
```

---

🏁 Script executed:

```shell
#!/bin/bash
# Search for other uses of DEFAULT_REGION constant
rg "DEFAULT_REGION" --type ts
```

Length of output: 1759

---



</details>

**Action Required: Rename Conflicting Constant in Preview Route**

The grep results confirm a naming conflict between the locally defined `DEFAULT_REGION` in `website/src/app/api/preview/route.ts` (set to `'int'`) and the shared `DEFAULT_REGION` (set to `'europe-west6'`). This can lead to maintenance issues or unintended behavior.

- **File:** `website/src/app/api/preview/route.ts`
- **Current Code:**
  ```ts
  const DEFAULT_REGION = 'int';
  ```
- **Proposed Change:**
  ```diff
  -const DEFAULT_REGION = 'int';
  +const DEFAULT_WEBSITE_REGION = 'int';
  ```
- **Action:** Rename the local constant to `DEFAULT_WEBSITE_REGION` and update its references in that file accordingly.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+import {
+	getDimensionsFromStoryblokImageUrl,
+	getPublishedDateFormatted,
+} from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { StoryblokArticle, StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { Typography } from '@socialincome/ui';
+import { ISbStoryData } from '@storyblok/react';
+import Image from 'next/image';
+import Link from 'next/link';
+
+export function StoryblokArticleCard(props: {
+	lang: string;
+	region: string;
+	blog: ISbStoryData<StoryblokArticle>;
+	author: ISbStoryData<StoryblokAuthor>;
+}) {
+	const { region, lang, blog, author } = props;
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(blog.content.image.filename);
+	return (
+		<Link href={`/${props.lang}/${props.region}/journal/${blog.slug!}`}>
+			<div className="mb-4 overflow-hidden transition-transform duration-200 hover:scale-[101%]">
+				<Image
+					src={blog.content.image.filename}
+					alt={blog.content.title}
+					width={dimensionsFromStoryblokImage.width}
+					height={dimensionsFromStoryblokImage.height}
+					className="h-60 w-full object-cover"
+				/>
+				<div className="mt-2 flex items-center justify-between">
+					<div className="flex items-center space-x-2">
+						<Typography weight="bold" size="sm" className="uppercase">
+							{blog.content.type?.content.value}
+						</Typography>
+					</div>
+					<Typography weight="normal" className="text-black">
+						{getPublishedDateFormatted(blog.first_published_at!, props.lang)}
+					</Typography>
+				</div>
+
+				<div className="mt-2 flex flex-grow flex-col">
+					<Typography size="2xl" className="mb-4 line-clamp-3 h-16 flex-grow" weight="medium">
+						{blog.content.title}
+					</Typography>
+					<div className="flex items-center justify-between">
+						<div className="flex items-center space-x-2">
+							<StoryblokAuthorImage size="large" author={author} region={region} lang={lang} />
+							<Typography className="ml-1" size="lg">
+								{blog.content.author.content.fullName}
+							</Typography>
+						</div>
+					</div>
+					<Typography size="md" weight="normal" className="mt-4 line-clamp-4 text-black">
+						{blog.content.leadText}
+					</Typography>
+				</div>
path: website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Handle potential null values in nested objects.**

The component accesses nested properties without checking if they exist.


```diff
-							{blog.content.author.content.fullName}
+							{blog.content.author?.content?.fullName || 'Unknown Author'}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
				<div className="mt-2 flex flex-grow flex-col">
					<Typography size="2xl" className="mb-4 line-clamp-3 h-16 flex-grow" weight="medium">
						{blog.content.title}
					</Typography>
					<div className="flex items-center justify-between">
						<div className="flex items-center space-x-2">
							<StoryblokAuthorImage size="large" author={author} region={region} lang={lang} />
							<Typography className="ml-1" size="lg">
								{blog.content.author?.content?.fullName || 'Unknown Author'}
							</Typography>
						</div>
					</div>
					<Typography size="md" weight="normal" className="mt-4 line-clamp-4 text-black">
						{blog.content.leadText}
					</Typography>
				</div>
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,153 @@
+import {
+	getArticle,
+	getDimensionsFromStoryblokImageUrl,
+	getPublishedDateFormatted,
+	getRelativeArticles,
+} from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { StoryblokImageWithCaption } from '@/app/[lang]/[region]/(website)/journal/StoryblokImageWithCaption';
+import { StoryblokArticle, StoryblokAuthor, StoryblokTag } from '@socialincome/shared/src/storyblok/journal';
+import { LanguageCode } from '@socialincome/shared/src/types/language';
+import { Translator } from '@socialincome/shared/src/utils/i18n';
+import { Badge, QuotedText, Separator, Typography } from '@socialincome/ui';
+import { ISbStoryData } from '@storyblok/react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { render } from 'storyblok-rich-text-react-renderer';
+
+export const revalidate = 900;
+
+function renderWrapper(articleData: StoryblokArticle) {
+	return render(articleData.content, {
+		blokResolvers: {
+			['quotedText']: (props: any) => <QuotedText {...props} />,
+			['imageWithCaption']: (props: any) => <StoryblokImageWithCaption {...props} />,
+		},
+	});
+}
+
+function badgeWithLink(lang: string, region: string, tag: ISbStoryData<StoryblokTag>, variant: 'outline' | 'default') {
+	return (
+		<Link href={`/${lang}/${region}/journal/tag/${tag.slug}`}>
+			<Badge key={tag.slug} variant={variant} className="mt-6">
+				{tag.content.value}
+			</Badge>
+		</Link>
+	);
+}
+
+const NUMBER_OF_RELATIVE_ARTICLES = 3;
+
+export default async function Page(props: { params: { slug: string; lang: LanguageCode; region: string } }) {
+	const { slug, lang, region } = props.params;
+
+	const articleResponse = await getArticle(lang, slug);
+	const articleData = articleResponse.data.story.content;
+	const author: ISbStoryData<StoryblokAuthor> = articleData.author;
+
+	const blogs = await getRelativeArticles(
+		author.uuid,
+		articleResponse.data.story.id,
+		articleData.tags?.map((it) => it.uuid),
+		lang,
+		NUMBER_OF_RELATIVE_ARTICLES,
+	);
+
+	let translator = await Translator.getInstance({
+		language: lang,
+		namespaces: ['website-journal'],
+	});
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(articleData.image.filename);
+	return (
+		<div>
+			<div className="blog w-full justify-center">
+				<div className="bg-primary flex flex-col md:min-h-screen md:flex-row">
+					<div className="md:order-2 md:w-1/2">
+						<Image
+							src={articleData.image.filename}
+							alt={articleData.image?.alt}
+							className="w-full object-cover md:h-screen"
+							width={dimensionsFromStoryblokImage.width}
+							height={dimensionsFromStoryblokImage.height}
+						/>
path: website/src/app/[lang]/[region]/(website)/journal/[slug]/page.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add error handling for image dimensions**

There's no fallback if getDimensionsFromStoryblokImageUrl fails. Consider adding error handling or default dimensions.

```diff
-	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(articleData.image.filename);
+	let dimensionsFromStoryblokImage;
+	try {
+		dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(articleData.image.filename);
+	} catch (error) {
+		dimensionsFromStoryblokImage = { width: 1200, height: 800 }; // Default dimensions
+		console.error('Failed to get image dimensions:', error);
+	}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
// Assuming this is placed appropriately before the JSX below
let dimensionsFromStoryblokImage;
try {
    dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(articleData.image.filename);
} catch (error) {
    dimensionsFromStoryblokImage = { width: 1200, height: 800 }; // Default dimensions
    console.error('Failed to get image dimensions:', error);
}

<Image
    src={articleData.image.filename}
    alt={articleData.image?.alt}
    className="w-full object-cover md:h-screen"
    width={dimensionsFromStoryblokImage.width}
    height={dimensionsFromStoryblokImage.height}
/>
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,15 +1,34 @@
-import type { StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { getDimensionsFromStoryblokImageUrl } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { ISbStoryData } from '@storyblok/react';
 import Image from 'next/image';
-import { ISbStoryData } from 'storyblok-js-client/src/interfaces';
 
-function StoryblokAuthorImage(props: { author: ISbStoryData<StoryblokAuthor> }) {
+const sizeClasses = {
+	small: 'h-8 w-8',
+	medium: 'h-12 w-12',
+	large: 'h-14 w-14',
+	'extra-large': 'h-24 w-24',
+};
+
+type Size = 'small' | 'medium' | 'large' | 'extra-large';
+
+function StoryblokAuthorImage(props: {
+	author: ISbStoryData<StoryblokAuthor>;
+	size?: Size;
+	className?: string;
+	lang: string;
+	region: string;
+}) {
path: website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Remove unused props from component signature**

The `lang` and `region` props are required but not used within the component.

```diff
function StoryblokAuthorImage(props: {
	author: ISbStoryData<StoryblokAuthor>;
	size?: Size;
	className?: string;
-	lang: string;
-	region: string;
}) {
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
function StoryblokAuthorImage(props: {
	author: ISbStoryData<StoryblokAuthor>;
	size?: Size;
	className?: string;
}) {
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 87694e8
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,14 +1,34 @@
+import { getDimensionsFromStoryblokImageUrl } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { ISbStoryData } from '@storyblok/react';
 import Image from 'next/image';
-import { ISbStoryData } from 'storyblok-js-client/src/interfaces';
 
-function StoryblokAuthorImage(props: { author: ISbStoryData<StoryblokAuthor> }) {
+const sizeClasses = {
+	small: 'h-8 w-8',
+	medium: 'h-12 w-12',
+	large: 'h-14 w-14',
+	'extra-large': 'h-24 w-24',
+};
+
+type Size = 'small' | 'medium' | 'large' | 'extra-large';
+
+function StoryblokAuthorImage(props: {
+	author: ISbStoryData<StoryblokAuthor>;
+	size?: Size;
+	className?: string;
+	lang: string;
+	region: string;
+}) {
+	const { author, size = 'medium', className = '' } = props;
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(author.content.avatar.filename);
+
path: website/src/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add error handling for image dimensions**

Add fallback dimensions if getDimensionsFromStoryblokImageUrl fails to extract them from the filename.

```diff
-	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(author.content.avatar.filename);
+	let dimensionsFromStoryblokImage;
+	try {
+		dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(author.content.avatar.filename);
+	} catch (error) {
+		dimensionsFromStoryblokImage = { width: 100, height: 100 }; // Default dimensions
+		console.error('Failed to get author image dimensions:', error);
+	}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
	let dimensionsFromStoryblokImage;
	try {
		dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(author.content.avatar.filename);
	} catch (error) {
		dimensionsFromStoryblokImage = { width: 100, height: 100 }; // Default dimensions
		console.error('Failed to get author image dimensions:', error);
	}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,74 +1,69 @@
 import { DefaultPageProps } from '@/app/[lang]/[region]';
+import { getAuthors, getOverviewArticles, getTags } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
 import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
-import { StoryblokArticle } from '@socialincome/shared/src/storyblok/journal';
 import { Translator } from '@socialincome/shared/src/utils/i18n';
-import { BaseContainer, Card, CardContent, Typography } from '@socialincome/ui';
-import { getStoryblokApi } from '@storyblok/react';
-import { DateTime } from 'luxon';
-import Image from 'next/image';
+import { Badge, BaseContainer, Typography } from '@socialincome/ui';
 import Link from 'next/link';
-import { ISbStories, ISbStoriesParams } from 'storyblok-js-client/src/interfaces';
 
 export const revalidate = 900;
 
-function getPublishedDateFormatted(date: string) {
-	const dateObject = DateTime.fromISO(date);
-	return dateObject.isValid ? dateObject.toFormat('dd/MM/yyyy') : '';
-}
-
-async function loadOverviewBlogs(lang: string): Promise<ISbStories<StoryblokArticle>> {
-	const params: ISbStoriesParams = {
-		resolve_relations: ['article.author', 'article.topics'],
-		language: lang,
-		with_tag: 'overview',
-		sort_by: 'created_at:desc',
-	};
-	return await getStoryblokApi().get(`cdn/stories`, params);
-}
-
 export default async function Page({ params: { lang, region } }: DefaultPageProps) {
-	const overviewBlogsResponse = await loadOverviewBlogs(lang);
-	const blogObjects = overviewBlogsResponse.data.stories;
+	const [blogsResponse, authorsResponse, tagsResponse] = await Promise.all([
+		getOverviewArticles(lang),
+		getAuthors(lang),
+		getTags(lang),
+	]);
+	const blogs = blogsResponse.data.stories;
+	const authors = authorsResponse.data.stories;
+	const tags = tagsResponse.data.stories;
 	const translator = await Translator.getInstance({
 		language: lang,
 		namespaces: ['website-journal'],
 	});
 
 	return (
 		<BaseContainer>
-			<Typography weight="bold" className="text-center" size="3xl">
+			<Typography weight="bold" className="text-center" size="5xl">
 				{translator.t('overview.title')}
 			</Typography>
-			<Typography className="mt-5 text-center" size="xl">
+			<Typography className="mt-8 text-center text-black" size="xl">
 				{translator.t('overview.description')}
 			</Typography>
-			<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
-				{blogObjects.map((blog) => (
-					<Link href={`/${lang}/${region}/${blog.default_full_slug!}`} key={blog.content.title}>
-						<Card className="overflow-hidden transition-transform duration-300 hover:scale-[102%]">
-							<Image
-								src={blog.content.image.filename}
-								alt={blog.content.title}
-								width={600}
-								height={400}
-								className="h-48 w-full object-cover"
-							/>
-							<CardContent className="flex flex-grow flex-col p-6">
-								<Typography size="xl" className="mb-4 line-clamp-2 h-14 flex-grow font-medium">
-									{blog.content.title}
-								</Typography>
-								<div className="mt-auto flex items-center justify-between">
-									<div className="flex items-center space-x-2">
-										<StoryblokAuthorImage author={blog.content.author} />
-										<Typography>{blog.content.author.content.fullName}</Typography>
-									</div>
-									<Typography>{getPublishedDateFormatted(blog.published_at!)}</Typography>
-								</div>
-							</CardContent>
-						</Card>
+			<Typography className="mb-4 mt-12 text-center" size="3xl" weight="medium">
+				{translator.t('overview.editors')}
+			</Typography>
+			<div className="mx-auto mb-10 grid max-w-lg grid-cols-[repeat(auto-fit,minmax(60px,1fr))] place-items-center gap-2">
+				{authors.map((author, index) => (
+					<div key={index} className="flex flex-col justify-center text-center">
+						<Link href={`/${lang}/${region}/journal/author/${author.slug}`}>
+							<StoryblokAuthorImage className="mb-1" author={author} size="extra-large" lang={lang} region={region} />
+
+							<Typography>{author.content.firstName}</Typography>
+							<Typography>{author.content.lastName}</Typography>
+						</Link>
+					</div>
+				))}
+			</div>
+
+			<div className="mt-16 flex grid-cols-3 justify-start gap-1">
+				<Badge size="md" key={'overview'} variant="default" className="mb-2">
+					{translator.t('overview.all')}
+				</Badge>
+				{tags.map((tag) => (
+					<Link key={tag.slug} href={`/${lang}/${region}/journal/tag/${tag.slug}`}>
+						<Badge size="md" variant="outline" className="mb-2">
+							{tag.content.value}
+						</Badge>
 					</Link>
 				))}
 			</div>
+
+			<div className="grid-cols-0 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
path: website/src/app/[lang]/[region]/(website)/journal/page.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Fix incorrect grid column specification**

The `grid-cols-0` class is invalid. Change to `grid-cols-1` for the mobile view.

```diff
-	<div className="grid-cols-0 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+	<div className="grid-cols-1 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
			<div className="grid-cols-1 mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,74 +1,69 @@
 import { DefaultPageProps } from '@/app/[lang]/[region]';
+import { getAuthors, getOverviewArticles, getTags } from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import { StoryblokArticleCard } from '@/app/[lang]/[region]/(website)/journal/StoryblokArticle';
 import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
-import { StoryblokArticle } from '@socialincome/shared/src/storyblok/journal';
 import { Translator } from '@socialincome/shared/src/utils/i18n';
-import { BaseContainer, Card, CardContent, Typography } from '@socialincome/ui';
-import { getStoryblokApi } from '@storyblok/react';
-import { DateTime } from 'luxon';
-import Image from 'next/image';
+import { Badge, BaseContainer, Typography } from '@socialincome/ui';
 import Link from 'next/link';
-import { ISbStories, ISbStoriesParams } from 'storyblok-js-client/src/interfaces';
 
 export const revalidate = 900;
 
-function getPublishedDateFormatted(date: string) {
-	const dateObject = DateTime.fromISO(date);
-	return dateObject.isValid ? dateObject.toFormat('dd/MM/yyyy') : '';
-}
-
-async function loadOverviewBlogs(lang: string): Promise<ISbStories<StoryblokArticle>> {
-	const params: ISbStoriesParams = {
-		resolve_relations: ['article.author', 'article.topics'],
-		language: lang,
-		with_tag: 'overview',
-		sort_by: 'created_at:desc',
-	};
-	return await getStoryblokApi().get(`cdn/stories`, params);
-}
-
 export default async function Page({ params: { lang, region } }: DefaultPageProps) {
-	const overviewBlogsResponse = await loadOverviewBlogs(lang);
-	const blogObjects = overviewBlogsResponse.data.stories;
+	const [blogsResponse, authorsResponse, tagsResponse] = await Promise.all([
+		getOverviewArticles(lang),
+		getAuthors(lang),
+		getTags(lang),
+	]);
path: website/src/app/[lang]/[region]/(website)/journal/page.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add error handling for API calls**

The code doesn't handle potential API failures. Consider adding try-catch blocks for these API calls.

```diff
-	const [blogsResponse, authorsResponse, tagsResponse] = await Promise.all([
-		getOverviewArticles(lang),
-		getAuthors(lang),
-		getTags(lang),
-	]);
+	let blogsResponse, authorsResponse, tagsResponse;
+	try {
+		[blogsResponse, authorsResponse, tagsResponse] = await Promise.all([
+			getOverviewArticles(lang),
+			getAuthors(lang),
+			getTags(lang),
+		]);
+	} catch (error) {
+		console.error('Failed to fetch journal data:', error);
+		// Handle error state appropriately
+		return <div>Failed to load journal content. Please try again later.</div>;
+	}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
	let blogsResponse, authorsResponse, tagsResponse;
	try {
		[blogsResponse, authorsResponse, tagsResponse] = await Promise.all([
			getOverviewArticles(lang),
			getAuthors(lang),
			getTags(lang),
		]);
	} catch (error) {
		console.error('Failed to fetch journal data:', error);
		// Handle error state appropriately
		return <div>Failed to load journal content. Please try again later.</div>;
	}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,254 @@
+import {
+	type StoryblokArticle,
+	StoryblokAuthor,
+	StoryblokContentType,
+	StoryblokTag,
+} from '@socialincome/shared/src/storyblok/journal';
+import { getStoryblokApi, ISbStory } from '@storyblok/react';
+import { DateTime } from 'luxon';
+import { draftMode } from 'next/headers';
+import { notFound } from 'next/navigation';
+import { ISbStories, ISbStoriesParams, ISbStoryData } from 'storyblok-js-client/src/interfaces';
+
+const STANDARD_ARTICLE_RELATIONS_TO_RESOLVE = ['article.author', 'article.tags', 'article.type'];
+const DEFAULT_LIMIT = 50;
+const NOT_FOUND = 404;
+const DEFAULT_LANGUAGE = 'en';
+const CONTENT = 'content';
+const STORIES_PATH = 'cdn/stories';
+
+export function getPublishedDateFormatted(date: string, lang: string) {
+	const dateObject = DateTime.fromISO(date).setLocale(lang);
+	return dateObject.isValid ? dateObject.toFormat('MMMM dd, yyyy') : '';
+}
+
+// During the development of Storyblok features or writing of new content, it is useful to use the draft version of the content.
+function addVersionParameter(properties: ISbStoriesParams): ISbStoriesParams {
+	return { ...properties, version: draftMode().isEnabled ? 'draft' : 'published' };
+}
+
+// Based on official documentation: https://www.storyblok.com/faq/image-dimensions-assets-js
+// format example: a.storyblok.com/f/51376/664x488/f4f9d1769c/visual-editor-features.jpg
+export function getDimensionsFromStoryblokImageUrl(url: String): { width: number; height: number } {
path: website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Use lowercase 'string' instead of 'String' type.**
This avoids confusion between the primitive type and the wrapper object type in TypeScript.

```diff
- export function getDimensionsFromStoryblokImageUrl(url: String): { width: number; height: number } {
+ export function getDimensionsFromStoryblokImageUrl(url: string): { width: number; height: number } {
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
export function getDimensionsFromStoryblokImageUrl(url: string): { width: number; height: number } {
`````

</details>

<!-- suggestion_end -->

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Biome (1.9.4)</summary>

[error] 32-32: Don't use 'String' as a type.

Use lowercase primitives for consistency.
Safe fix: Use 'string' instead


(lint/complexity/noBannedTypes)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 6f31c0c
created_at: 2025-04-13T12:02:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,254 @@
+import {
+	type StoryblokArticle,
+	StoryblokAuthor,
+	StoryblokContentType,
+	StoryblokTag,
+} from '@socialincome/shared/src/storyblok/journal';
+import { getStoryblokApi, ISbStory } from '@storyblok/react';
+import { DateTime } from 'luxon';
+import { draftMode } from 'next/headers';
+import { notFound } from 'next/navigation';
+import { ISbStories, ISbStoriesParams, ISbStoryData } from 'storyblok-js-client/src/interfaces';
+
+const STANDARD_ARTICLE_RELATIONS_TO_RESOLVE = ['article.author', 'article.tags', 'article.type'];
+const DEFAULT_LIMIT = 50;
+const NOT_FOUND = 404;
+const DEFAULT_LANGUAGE = 'en';
+const CONTENT = 'content';
+const STORIES_PATH = 'cdn/stories';
+
+export function getPublishedDateFormatted(date: string, lang: string) {
+	const dateObject = DateTime.fromISO(date).setLocale(lang);
+	return dateObject.isValid ? dateObject.toFormat('MMMM dd, yyyy') : '';
+}
+
+// During the development of Storyblok features or writing of new content, it is useful to use the draft version of the content.
+function addVersionParameter(properties: ISbStoriesParams): ISbStoriesParams {
+	return { ...properties, version: draftMode().isEnabled ? 'draft' : 'published' };
+}
+
+// Based on official documentation: https://www.storyblok.com/faq/image-dimensions-assets-js
+// format example: a.storyblok.com/f/51376/664x488/f4f9d1769c/visual-editor-features.jpg
+export function getDimensionsFromStoryblokImageUrl(url: String): { width: number; height: number } {
+	let dimensions = url.split('/')[5].split('x');
+	return {
+		width: Number(dimensions[0]),
+		height: Number(dimensions[1]),
+	};
+}
+
+export async function getAuthors(lang: string): Promise<ISbStories<StoryblokAuthor>> {
+	const params: ISbStoriesParams = {
+		language: lang,
+		content_type: StoryblokContentType.Author,
+		filter_query: {
+			displayInOverviewPage: {
+				is: true,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getTags(lang: string): Promise<ISbStories<StoryblokTag>> {
+	const params: ISbStoriesParams = {
+		language: lang,
+		content_type: StoryblokContentType.Tag,
+		filter_query: {
+			displayInOverviewPage: {
+				is: true,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getArticlesByTag(tagId: string, lang: string): Promise<ISbStories<StoryblokArticle>> {
+	const params: ISbStoriesParams = {
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		excluding_fields: CONTENT,
+		sort_by: 'first_published_at:desc',
+		content_type: StoryblokContentType.Article,
+		filter_query: {
+			tags: {
+				any_in_array: tagId,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getArticlesByAuthor(
+	authorId: string,
+	lang: string,
+	limit = DEFAULT_LIMIT,
+): Promise<ISbStories<StoryblokArticle>> {
+	const params: ISbStoriesParams = {
+		per_page: limit,
+		excluding_fields: CONTENT,
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		sort_by: 'first_published_at:desc',
+		content_type: StoryblokContentType.Article,
+		filter_query: {
+			author: {
+				in: authorId,
+			},
+		},
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getOverviewArticles(
+	lang: string,
+	idsToIgnore: string | undefined = undefined,
+	limit = DEFAULT_LIMIT,
+): Promise<ISbStories<StoryblokArticle>> {
+	const params: ISbStoriesParams = {
+		per_page: limit,
+		excluding_fields: CONTENT,
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		sort_by: 'first_published_at:desc',
+		content_type: StoryblokContentType.Article,
+		filter_query: {
+			displayInOverviewPage: {
+				is: true,
+			},
+		},
+		...(idsToIgnore ? { excluding_ids: idsToIgnore } : {}),
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+export async function getTag(slug: string, lang: string): Promise<ISbStory<StoryblokTag>> {
+	return getWithFallback(
+		async (lang: string, slug: string): Promise<ISbStory<StoryblokTag>> => {
+			const params: ISbStoriesParams = {
+				language: lang,
+			};
+			return getStoryblokApi().get(`cdn/stories/tag/${slug}`, addVersionParameter(params));
+		},
+		lang,
+		slug,
+	);
+}
+
+export async function getAuthor(slug: string, lang: string): Promise<ISbStory<StoryblokAuthor>> {
+	return getWithFallback(
+		async (lang: string, slug: string): Promise<ISbStory<StoryblokAuthor>> => {
+			const params: ISbStoriesParams = {
+				language: lang,
+			};
+			return getStoryblokApi().get(`cdn/stories/author/${slug}`, addVersionParameter(params));
+		},
+		lang,
+		slug,
+	);
+}
+
+export async function getWithFallback<T>(
+	loader: (lang: string, slug: string) => Promise<T>,
+	lang: string,
+	slug: string,
+): Promise<T> {
+	try {
+		return loader(lang, slug);
+	} catch (error: any) {
+		if (error.status === NOT_FOUND) {
+			if (lang === DEFAULT_LANGUAGE) {
+				throw notFound();
+			}
+			return loader(DEFAULT_LANGUAGE, slug);
+		}
+		throw error;
+	}
+}
+
+function createRelativeArticlesFilter(tags: string[], authorId: string) {
+	return tags && tags.length
+		? {
+				__or: [
+					{
+						author: {
+							in: authorId,
+						},
+					},
+					{
+						tags: {
+							in_array: tags.join(','),
+						},
+					},
+				],
+			}
+		: {
+				author: {
+					in: authorId,
+				},
+			};
+}
+
+async function getRelativeArticlesByAuthorAndTags(
+	authorId: string,
+	tags: string[],
+	lang: string,
+	articleId: number,
+	numberOfArticles: number,
+): Promise<ISbStories<StoryblokArticle>> {
+	let filter = createRelativeArticlesFilter(tags, authorId);
+	const params: ISbStoriesParams = {
+		per_page: numberOfArticles,
+		excluding_fields: CONTENT,
+		resolve_relations: STANDARD_ARTICLE_RELATIONS_TO_RESOLVE,
+		language: lang,
+		sort_by: 'first_published_at:desc',
+		excluding_ids: articleId.toString(),
+		content_type: StoryblokContentType.Article,
+		filter_query: filter,
+	};
+	return getStoryblokApi().get(STORIES_PATH, addVersionParameter(params));
+}
+
+/*
+ * Returns relative articles by the same author or the same tag. It excludes the current article by {articleId}.
+ * If there are not enough articles, the latest articles of the overview pages are returned.
+ * */
+export async function getRelativeArticles(
+	authorId: string,
+	articleId: number,
+	tags: string[],
+	lang: string,
+	numberOfArticles: number,
+): Promise<ISbStoryData<StoryblokArticle>[]> {
+	const relatedArticlesResponse = await getRelativeArticlesByAuthorAndTags(
+		authorId,
+		tags,
+		lang,
+		articleId,
+		numberOfArticles,
+	);
+	let result = relatedArticlesResponse.data.stories;
+	if (result.length < numberOfArticles) {
+		const idsToIgnore = [...result.map((s) => s.id), articleId].join(',');
+		const remaining = numberOfArticles - result.length;
+		const overviewArticles = await getOverviewArticles(lang, idsToIgnore, remaining);
+		result = [...result, ...overviewArticles.data.stories];
+	}
+
+	return result;
+}
path: website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>❓ Verification inconclusive</summary>

**Verify error handling in fallback scenarios.**
Be aware that if `getOverviewArticles` fails, an unhandled error might bubble up. Wrap it in a try/catch if needed, or confirm that it's acceptable to let it propagate.

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Search for any existing try/catch or error handling for getOverviewArticles invocation in the codebase.
fd getOverviewArticles --exec rg -A 5 'getOverviewArticles'
```

Length of output: 59

---



</details>

**Action Required: Introduce error handling for the getOverviewArticles fallback**

It appears that the invocation of `getOverviewArticles` in `website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts` (lines 217–240) isn’t wrapped in a try/catch block. If this function fails, the error may propagate unexpectedly. Please consider one of the following actions:

- **Wrap the call in a try/catch block** to handle potential errors gracefully.
- **Confirm that propagating the error is intentional** and that there’s a global error boundary or other error management strategy in place.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:02:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,254 @@
+import {
+	type StoryblokArticle,
+	StoryblokAuthor,
+	StoryblokContentType,
+	StoryblokTag,
+} from '@socialincome/shared/src/storyblok/journal';
+import { getStoryblokApi, ISbStory } from '@storyblok/react';
+import { DateTime } from 'luxon';
+import { draftMode } from 'next/headers';
+import { notFound } from 'next/navigation';
+import { ISbStories, ISbStoriesParams, ISbStoryData } from 'storyblok-js-client/src/interfaces';
+
+const STANDARD_ARTICLE_RELATIONS_TO_RESOLVE = ['article.author', 'article.tags', 'article.type'];
+const DEFAULT_LIMIT = 50;
+const NOT_FOUND = 404;
+const DEFAULT_LANGUAGE = 'en';
+const CONTENT = 'content';
+const STORIES_PATH = 'cdn/stories';
+
+export function getPublishedDateFormatted(date: string, lang: string) {
+	const dateObject = DateTime.fromISO(date).setLocale(lang);
+	return dateObject.isValid ? dateObject.toFormat('MMMM dd, yyyy') : '';
+}
+
+// During the development of Storyblok features or writing of new content, it is useful to use the draft version of the content.
+function addVersionParameter(properties: ISbStoriesParams): ISbStoriesParams {
+	return { ...properties, version: draftMode().isEnabled ? 'draft' : 'published' };
+}
+
+// Based on official documentation: https://www.storyblok.com/faq/image-dimensions-assets-js
+// format example: a.storyblok.com/f/51376/664x488/f4f9d1769c/visual-editor-features.jpg
+export function getDimensionsFromStoryblokImageUrl(url: string): { width: number; height: number } {
+	let dimensions = url.split('/')[5].split('x');
+	return {
+		width: Number(dimensions[0]),
+		height: Number(dimensions[1]),
+	};
+}
+
path: website/src/app/[lang]/[region]/(website)/journal/StoryblokApi.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Validate the URL for unexpected formats.**

If the provided URL doesn’t match the expected structure, splitting by `x` may cause a runtime error. Consider adding safety checks or returning fallback dimensions.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:09:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+import {
+	getDimensionsFromStoryblokImageUrl,
+	getPublishedDateFormatted,
+} from '@/app/[lang]/[region]/(website)/journal/StoryblokApi';
+import StoryblokAuthorImage from '@/app/[lang]/[region]/(website)/journal/StoryblokAuthorImage';
+import { StoryblokArticle, StoryblokAuthor } from '@socialincome/shared/src/storyblok/journal';
+import { Typography } from '@socialincome/ui';
+import { ISbStoryData } from '@storyblok/react';
+import Image from 'next/image';
+import Link from 'next/link';
+
+export function StoryblokArticleCard(props: {
+	lang: string;
+	region: string;
+	blog: ISbStoryData<StoryblokArticle>;
+	author: ISbStoryData<StoryblokAuthor>;
+}) {
+	const { region, lang, blog, author } = props;
+	const dimensionsFromStoryblokImage = getDimensionsFromStoryblokImageUrl(blog.content.image.filename);
+	return (
+		<Link href={`/${props.lang}/${props.region}/journal/${blog.slug!}`}>
+			<div className="mb-4 overflow-hidden transition-transform duration-200 hover:scale-[101%]">
+				<Image
+					src={blog.content.image.filename}
+					alt={blog.content.title}
+					width={dimensionsFromStoryblokImage.width}
+					height={dimensionsFromStoryblokImage.height}
+					className="h-60 w-full object-cover"
+				/>
+				<div className="mt-2 flex items-center justify-between">
+					<div className="flex items-center space-x-2">
+						<Typography weight="bold" size="sm" className="uppercase">
+							{blog.content.type?.content.value}
+						</Typography>
path: website/src/app/[lang]/[region]/(website)/journal/StoryblokArticle.tsx
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Add null checks for nested properties.**

The code accesses nested properties without verifying their existence, which could cause runtime errors.



```diff
-							{blog.content.type?.content.value}
+							{blog.content.type?.content?.value || ''}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
							{blog.content.type?.content?.value || ''}
						</Typography>
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T12:42:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-06T13:30:13Z
body: [vc]: #wfJxIG/vXwYK7UpeVeE6CBzlXHZa7vcp17+MFyMvLyo=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJwdWJsaWMiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc29jaWFsLWluY29tZS9wdWJsaWMvNnlKZFdWZ1FmcFlMV3prYlZzczdtR1BtVUY5ayIsInByZXZpZXdVcmwiOiJwdWJsaWMtZ2l0LWdhdnJpaWwtam91cm5hbHYyLXNvY2lhbC1pbmNvbWUudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJwdWJsaWMtZ2l0LWdhdnJpaWwtam91cm5hbHYyLXNvY2lhbC1pbmNvbWUudmVyY2VsLmFwcCJ9LCJyb290RGlyZWN0b3J5IjpudWxsfV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **public** | ✅ Ready ([Inspect](https://vercel.com/social-income/public/6yJdWVgQfpYLWzkbVss7mGPmUF9k)) | [Visit Preview](https://public-git-gavriil-journalv2-social-income.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/public-git-gavriil-journalv2-social-income.vercel.app?via=pr-comment-feedback-link) | Apr 13, 2025 0:34am |


author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-04-06T13:30:16Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough
The changes update localization JSON files for multiple languages by adding keys like `"all"` and `"keep-reading"` while removing `"written-by"` and `"published"`. The Storyblok journal module is enhanced with new enums and interfaces, and data models are updated. UI components receive style updates and new exports/components (e.g., `QuotedText`). Several new page components for articles, authors, and tags are introduced or restructured, and the API preview route now handles slug redirection with additional conditions.

## Changes

| File(s) | Changes Summary |
|---------|-----------------|
| `shared/locales/{de,en,fr,it}/website-journal.json` | Added new keys (`"all"`, `"keep-reading"`, and `"editors"` in English) under relevant sections; removed `"written-by"` and `"published"` entries. |
| `shared/src/storyblok/journal.ts` | Added new enum (`StoryblokContentType`) and interface (`StoryblokArticleType`); renamed `StoryblokTopic` to `StoryblokTag` and updated multiple interfaces to include new properties and remove obsolete ones. |
| `ui/src/components/(badge.tsx, typography/index.ts, typography/quotedText.tsx)` | Updated `badge` component’s outline variant (added `bg-white`); added export for `quotedText`; introduced new `QuotedText` functional component. |
| `website/src/app/[lang]/[region]/(website)/journal/*` | Introduced and updated multiple files: new API client (`StoryblokApi.ts`); new page components for articles (`[slug]/page.tsx`), authors (`author/[slug]/page.tsx`), and tags (`tag/[slug]/page.tsx`); updated overview page to concurrently fetch articles, authors, and tags; removed obsolete `[...slug]/page.tsx`; added new components (`StoryblokArticleCard`, `StoryblokImageWithCaption`) and enhanced image and author components. |
| `website/src/app/api/preview/route.ts` | Expanded allowed slug prefixes; added `JOURNAL` and `DEFAULT_REGION` constants; refined redirection logic to conditionally prefix non-journal slugs. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Client
    participant PreviewAPI
    Client->>PreviewAPI: GET /preview?slug=...
    PreviewAPI->>PreviewAPI: Check if slug starts with "journal"
    alt Slug starts with "journal"
        PreviewAPI->>Client: Redirect to /{lang}/int/{slug}
    else
        PreviewAPI->>Client: Redirect to /{lang}/int/journal/{slug}
    end
```

```mermaid
sequenceDiagram
    participant Client
    participant JournalPage
    participant StoryblokAPI
    Client->>JournalPage: Request article page
    JournalPage->>StoryblokAPI: getArticle(lang, slug)
    StoryblokAPI-->>JournalPage: Article data
    JournalPage->>StoryblokAPI: getRelativeArticles(authorId, articleId, tags, lang)
    StoryblokAPI-->>JournalPage: Related articles data
    JournalPage->>Client: Render page with article, author, and tag information
```

</details>

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡💬 Agentic Chat (Pro Plan, General Availability)</summary>
> 
> - We're introducing multi-step agentic chat in review comments and issue comments, within and outside of PR's. This feature enhances review and issue discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments and add commits to existing pull requests.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=socialincome-san/public&utm_content=1079):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-06T13:36:25Z
body: Visit the preview URL for this PR (updated for commit 87694e8):

[https://si-admin-staging--pr1079-gavriil-journal-v2-0gdg5j9v.web.app](https://si-admin-staging--pr1079-gavriil-journal-v2-0gdg5j9v.web.app)

<sub>(expires Sun, 20 Apr 2025 12:36:00 GMT)</sub>

<sub>🔥 via [Firebase Hosting GitHub Action](https://github.com/marketplace/actions/deploy-to-firebase-hosting) 🌎</sub>

<sub>Sign: b7b0969384059dce6ea8fad1ee1d1737e54e6676</sub>
author_association: NONE
user: login: ssandino
created_at: 2025-04-13T20:22:07Z
body: @mkue ready for review 
author_association: MEMBER
user: login: mkue
created_at: 2025-04-21T14:28:11Z
body: super nice! looks very clean👌🏼
author_association: CONTRIBUTOR