Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
725d234
feat(Blog):fix for clickable link in post details, fix for author det…
KomrakovaAnna Jan 24, 2026
6e8561d
feat(Blog):refactoring after removing author modal
KomrakovaAnna Jan 24, 2026
0334ea5
feat(Blog):mergin develop
KomrakovaAnna Jan 24, 2026
7bf3768
feat(Blog): fix unified date format
KomrakovaAnna Jan 24, 2026
d60fbe8
feat(Blog): Fix for click-outside-to-close search, recommended posts…
KomrakovaAnna Jan 25, 2026
c836b11
merging develop
KomrakovaAnna Jan 25, 2026
705f075
feat(Blog): selectedAuthorData fixed
KomrakovaAnna Jan 25, 2026
69a8190
feat(Blog): Added description for /blog/[slug] metadata, Added Schema…
KomrakovaAnna Jan 25, 2026
f628c8c
feat(Blog): fix hover social links, fixed duplication not found search
KomrakovaAnna Jan 25, 2026
a2fa0b6
Merge branch 'develop' into sanity
KomrakovaAnna Jan 25, 2026
42d1a62
feat(Blog): Added: breadcrumbs to the post details page and updated t…
KomrakovaAnna Jan 25, 2026
faab3bd
feat(Blog): Added: breadcrumbs to the post details page and updated t…
KomrakovaAnna Jan 25, 2026
102f427
merging develop
KomrakovaAnna Jan 25, 2026
91f05d8
feat(Blog): Added scroll on the main blog page on filtering by author…
KomrakovaAnna Jan 25, 2026
d73087a
Merge branch 'develop' into sanity
KomrakovaAnna Jan 25, 2026
9347874
feat(Blog): Changed image size on the post details page
KomrakovaAnna Jan 25, 2026
2591e67
Merge branch 'develop' into sanity
KomrakovaAnna Jan 25, 2026
a04e8bb
feat(Blog): added tests
KomrakovaAnna Jan 26, 2026
83d54a6
added package-lock
KomrakovaAnna Jan 26, 2026
f0a1419
feat(Blog): fix for big post on the post page, added tests
KomrakovaAnna Jan 26, 2026
fd582c2
feat(Blog): resolving comments
KomrakovaAnna Jan 26, 2026
f0144a3
Merge branch 'develop' into sanity
KomrakovaAnna Jan 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions frontend/components/blog/BlogFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ export default function BlogFilters({
}, [selectedAuthorData]);

return (
<div className="mt-8">
<div className="mt-4">
{!resolvedAuthor && featuredPost && (
<section className="mb-12">
<div className="grid gap-8 md:grid-cols-[1.3fr_1fr] md:items-stretch lg:grid-cols-[1.4fr_1fr]">
Expand All @@ -400,21 +400,23 @@ export default function BlogFilters({
</div>
</Link>
)}
<div className="pt-2 flex flex-col h-full">
<div className="relative flex flex-col h-full pt-8">
{featuredPost.categories?.[0] && (
<div className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--accent-primary)] -mt-2">
<div className="absolute top-0 left-0 text-xs font-bold uppercase tracking-[0.2em] text-[var(--accent-primary)]">
{featuredPost.categories[0]}
</div>
)}
<Link
href={`/blog/${featuredPost.slug.current}`}
className="mt-3 block text-3xl font-semibold leading-tight text-gray-900 transition hover:underline underline-offset-4 dark:text-gray-100 md:text-4xl"
>
{featuredPost.title}
</Link>
<p className="mt-4 text-base leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-3 whitespace-pre-line">
{plainTextExcerpt(featuredPost.body)}
</p>
<div className="my-auto">
<Link
href={`/blog/${featuredPost.slug.current}`}
className="mt-3 block text-3xl font-semibold leading-tight text-gray-900 transition hover:underline underline-offset-4 dark:text-gray-100 md:text-4xl"
>
{featuredPost.title}
</Link>
<p className="mt-4 text-base leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-3 whitespace-pre-line">
{plainTextExcerpt(featuredPost.body)}
</p>
</div>
{featuredPost.publishedAt && (
<div className="mt-auto pt-8 flex items-center justify-between text-xs tracking-[0.25em] text-gray-500 dark:text-gray-400">
<time
Expand Down
80 changes: 80 additions & 0 deletions frontend/components/tests/blog/blog-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import type { Author, Post } from '@/components/blog/BlogFilters';
import BlogCard from '@/components/blog/BlogCard';

vi.mock('next/image', () => ({
__esModule: true,
default: (props: any) => <img {...props} />,
}));

vi.mock('next/link', () => ({
__esModule: true,
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));

vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));

vi.mock('@/lib/blog/date', () => ({
formatBlogDate: () => '01.01.2026',
}));

describe('BlogCard', () => {
it('renders title, excerpt, category badge and author', () => {
const author: Author = {
name: 'Анна',
image: 'https://example.com/anna.jpg',
};
const post: Post = {
_id: '1',
title: 'Пост про співбесіду',
slug: { current: 'interview' },
publishedAt: '2026-01-01',
categories: ['Growth'],
body: [
{
_type: 'block',
children: [{ _type: 'span', text: 'Опис поста' }],
},
],
author,
mainImage: 'https://example.com/image.jpg',
};

const onAuthorSelect = vi.fn();

render(
<BlogCard post={post} onAuthorSelect={onAuthorSelect} />
);

expect(screen.getByText('Пост про співбесіду')).toBeInTheDocument();
expect(screen.getByText('Опис поста')).toBeInTheDocument();
expect(screen.getByText('Career')).toBeInTheDocument();
expect(screen.getByText('Анна')).toBeInTheDocument();
expect(screen.getByText('01.01.2026')).toBeInTheDocument();
});

it('calls onAuthorSelect when author is clicked', () => {
const author: Author = { name: 'Анна' };
const post: Post = {
_id: '1',
title: 'Пост',
slug: { current: 'post' },
author,
};

const onAuthorSelect = vi.fn();

render(
<BlogCard post={post} onAuthorSelect={onAuthorSelect} />
);

fireEvent.click(screen.getByRole('button', { name: 'Анна' }));
expect(onAuthorSelect).toHaveBeenCalledWith(author);
});
});
38 changes: 38 additions & 0 deletions frontend/components/tests/blog/blog-category-grid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import type { Author, Post } from '@/components/blog/BlogFilters';
import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid';

const pushMock = vi.fn();

vi.mock('@/i18n/routing', () => ({
useRouter: () => ({
push: pushMock,
}),
}));

vi.mock('@/components/blog/BlogGrid', () => ({
__esModule: true,
default: ({ onAuthorSelect }: { onAuthorSelect: (author: Author) => void }) => (
<button
type="button"
onClick={() => onAuthorSelect({ name: 'Анна' })}
>
select-author
</button>
),
}));

describe('BlogCategoryGrid', () => {
it('pushes author filter when author selected', () => {
const posts: Post[] = [
{ _id: '1', title: 'Post', slug: { current: 'post' } },
];

render(<BlogCategoryGrid posts={posts} />);

fireEvent.click(screen.getByText('select-author'));
expect(pushMock).toHaveBeenCalledWith('/blog?author=%D0%90%D0%BD%D0%BD%D0%B0');
});
});
59 changes: 59 additions & 0 deletions frontend/components/tests/blog/blog-category-links.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BlogCategoryLinks } from '@/components/blog/BlogCategoryLinks';

vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
'categories.tech': 'Технології',
'categories.career': 'Кар\'єра',
'categories.insights': 'Інсайти',
'categories.news': 'Новини',
'categories.growth': 'Кар\'єра',
home: 'Головна',
};
return map[key] || key;
},
}));

vi.mock('@/i18n/routing', () => ({
usePathname: () => '/blog/category/tech',
}));

vi.mock('@/components/shared/AnimatedNavLink', () => ({
AnimatedNavLink: ({ href, children, isActive }: any) => (
<a href={href} data-active={isActive ? 'true' : 'false'}>
{children}
</a>
),
}));

vi.mock('@/components/shared/HeaderButton', () => ({
HeaderButton: ({ href, children }: any) => <a href={href}>{children}</a>,
}));

vi.mock('@/lib/utils', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' '),
}));

describe('BlogCategoryLinks', () => {
it('renders home and category links with slugs', () => {
render(
<BlogCategoryLinks
categories={[
{ _id: '1', title: 'Tech' },
{ _id: '2', title: 'Growth' },
]}
/>
);

expect(screen.getByText('Головна')).toBeInTheDocument();
expect(screen.getByText('Технології')).toBeInTheDocument();
expect(screen.getByText("Кар'єра")).toBeInTheDocument();
expect(screen.getByText('Технології').closest('a')).toHaveAttribute(
'href',
'/blog/category/tech'
);
});
});
137 changes: 137 additions & 0 deletions frontend/components/tests/blog/blog-filters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { Author, Post } from '@/components/blog/BlogFilters';
import BlogFilters from '@/components/blog/BlogFilters';

let searchParams = new URLSearchParams();
const replaceMock = vi.fn();

vi.mock('next/navigation', () => ({
useSearchParams: () => searchParams,
}));

vi.mock('@/i18n/routing', () => ({
Link: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
usePathname: () => '/blog',
useRouter: () => ({
replace: replaceMock,
}),
}));

vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
'categories.tech': 'Технології',
'categories.career': 'Кар\'єра',
'categories.insights': 'Інсайти',
'categories.news': 'Новини',
author: 'Автор',
blog: 'Блог',
noPosts: 'Статей не знайдено',
all: 'Усі',
articlesBy: 'Статті',
articlesPublished: 'Опубліковано',
};
return map[key] || key;
},
useLocale: () => 'uk',
}));

vi.mock('@/components/blog/BlogGrid', () => ({
default: ({ posts }: { posts: Post[] }) => (
<ul data-testid="blog-grid">
{posts.map(post => (
<li key={post._id}>{post.title}</li>
))}
</ul>
),
}));

afterEach(() => {
replaceMock.mockReset();
searchParams = new URLSearchParams();
vi.restoreAllMocks();
});

describe('BlogFilters', () => {
it('filters posts by search query in title or body', () => {
searchParams = new URLSearchParams({ search: 'співбесіди' });
const posts: Post[] = [
{
_id: '1',
title: 'Як підготуватися до співбесіди',
slug: { current: 'interview' },
body: [
{
_type: 'block',
children: [{ _type: 'span', text: 'Поради для співбесіди' }],
},
],
},
{
_id: '2',
title: 'CSS підказки',
slug: { current: 'css-tips' },
body: [
{
_type: 'block',
children: [{ _type: 'span', text: 'Про Flexbox' }],
},
],
},
];

render(<BlogFilters posts={posts} categories={[]} />);

const grid = screen.getByTestId('blog-grid');
expect(grid).toHaveTextContent('Як підготуватися до співбесіди');
expect(grid).not.toHaveTextContent('CSS підказки');
});

it('filters posts by author and renders author heading', async () => {
searchParams = new URLSearchParams({ author: 'Анна' });
const posts: Post[] = [
{
_id: '1',
title: 'Пост Анни',
slug: { current: 'anna-post' },
author: { name: 'Анна' },
},
{
_id: '2',
title: 'Пост Віктора',
slug: { current: 'viktor-post' },
author: { name: 'Віктор' },
},
];

const authorPayload: Author = {
name: 'Анна',
jobTitle: 'QA',
company: 'DevLovers',
};

const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => authorPayload,
});
vi.stubGlobal('fetch', fetchMock as typeof fetch);

render(<BlogFilters posts={posts} categories={[]} />);

await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});

expect(
screen.getByRole('heading', { name: 'Анна' })
).toBeInTheDocument();

const grid = screen.getByTestId('blog-grid');
expect(grid).toHaveTextContent('Пост Анни');
expect(grid).not.toHaveTextContent('Пост Віктора');
});
});
32 changes: 32 additions & 0 deletions frontend/components/tests/blog/blog-grid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { Post } from '@/components/blog/BlogFilters';
import BlogGrid from '@/components/blog/BlogGrid';

vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => (key === 'noPosts' ? 'Статей не знайдено' : key),
}));

vi.mock('@/components/blog/BlogCard', () => ({
default: ({ post }: { post: Post }) => <div>{post.title}</div>,
}));

describe('BlogGrid', () => {
it('renders empty state when no posts', () => {
render(<BlogGrid posts={[]} onAuthorSelect={() => {}} />);
expect(screen.getByText('Статей не знайдено')).toBeInTheDocument();
});

it('renders posts list', () => {
const posts: Post[] = [
{ _id: '1', title: 'Post 1', slug: { current: 'p1' } },
{ _id: '2', title: 'Post 2', slug: { current: 'p2' } },
];

render(<BlogGrid posts={posts} onAuthorSelect={() => {}} />);

expect(screen.getByText('Post 1')).toBeInTheDocument();
expect(screen.getByText('Post 2')).toBeInTheDocument();
});
});
Loading