Skip to content

Commit a33a314

Browse files
feat(Blog) (#216)
1 parent b7a5e14 commit a33a314

16 files changed

Lines changed: 32919 additions & 13 deletions

frontend/components/blog/BlogFilters.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ export default function BlogFilters({
382382
}, [selectedAuthorData]);
383383

384384
return (
385-
<div className="mt-8">
385+
<div className="mt-4">
386386
{!resolvedAuthor && featuredPost && (
387387
<section className="mb-12">
388388
<div className="grid gap-8 md:grid-cols-[1.3fr_1fr] md:items-stretch lg:grid-cols-[1.4fr_1fr]">
@@ -400,21 +400,23 @@ export default function BlogFilters({
400400
</div>
401401
</Link>
402402
)}
403-
<div className="pt-2 flex flex-col h-full">
403+
<div className="relative flex flex-col h-full pt-8">
404404
{featuredPost.categories?.[0] && (
405-
<div className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--accent-primary)] -mt-2">
405+
<div className="absolute top-0 left-0 text-xs font-bold uppercase tracking-[0.2em] text-[var(--accent-primary)]">
406406
{featuredPost.categories[0]}
407407
</div>
408408
)}
409-
<Link
410-
href={`/blog/${featuredPost.slug.current}`}
411-
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"
412-
>
413-
{featuredPost.title}
414-
</Link>
415-
<p className="mt-4 text-base leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-3 whitespace-pre-line">
416-
{plainTextExcerpt(featuredPost.body)}
417-
</p>
409+
<div className="my-auto">
410+
<Link
411+
href={`/blog/${featuredPost.slug.current}`}
412+
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"
413+
>
414+
{featuredPost.title}
415+
</Link>
416+
<p className="mt-4 text-base leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-3 whitespace-pre-line">
417+
{plainTextExcerpt(featuredPost.body)}
418+
</p>
419+
</div>
418420
{featuredPost.publishedAt && (
419421
<div className="mt-auto pt-8 flex items-center justify-between text-xs tracking-[0.25em] text-gray-500 dark:text-gray-400">
420422
<time
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { render, screen, fireEvent } from '@testing-library/react';
4+
import type { Author, Post } from '@/components/blog/BlogFilters';
5+
import BlogCard from '@/components/blog/BlogCard';
6+
7+
vi.mock('next/image', () => ({
8+
__esModule: true,
9+
default: (props: any) => <img {...props} />,
10+
}));
11+
12+
vi.mock('next/link', () => ({
13+
__esModule: true,
14+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
15+
<a href={href}>{children}</a>
16+
),
17+
}));
18+
19+
vi.mock('next-intl', () => ({
20+
useTranslations: () => (key: string) => key,
21+
}));
22+
23+
vi.mock('@/lib/blog/date', () => ({
24+
formatBlogDate: () => '01.01.2026',
25+
}));
26+
27+
describe('BlogCard', () => {
28+
it('renders title, excerpt, category badge and author', () => {
29+
const author: Author = {
30+
name: 'Анна',
31+
image: 'https://example.com/anna.jpg',
32+
};
33+
const post: Post = {
34+
_id: '1',
35+
title: 'Пост про співбесіду',
36+
slug: { current: 'interview' },
37+
publishedAt: '2026-01-01',
38+
categories: ['Growth'],
39+
body: [
40+
{
41+
_type: 'block',
42+
children: [{ _type: 'span', text: 'Опис поста' }],
43+
},
44+
],
45+
author,
46+
mainImage: 'https://example.com/image.jpg',
47+
};
48+
49+
const onAuthorSelect = vi.fn();
50+
51+
render(
52+
<BlogCard post={post} onAuthorSelect={onAuthorSelect} />
53+
);
54+
55+
expect(screen.getByText('Пост про співбесіду')).toBeInTheDocument();
56+
expect(screen.getByText('Опис поста')).toBeInTheDocument();
57+
expect(screen.getByText('Career')).toBeInTheDocument();
58+
expect(screen.getByText('Анна')).toBeInTheDocument();
59+
expect(screen.getByText('01.01.2026')).toBeInTheDocument();
60+
});
61+
62+
it('calls onAuthorSelect when author is clicked', () => {
63+
const author: Author = { name: 'Анна' };
64+
const post: Post = {
65+
_id: '1',
66+
title: 'Пост',
67+
slug: { current: 'post' },
68+
author,
69+
};
70+
71+
const onAuthorSelect = vi.fn();
72+
73+
render(
74+
<BlogCard post={post} onAuthorSelect={onAuthorSelect} />
75+
);
76+
77+
fireEvent.click(screen.getByRole('button', { name: 'Анна' }));
78+
expect(onAuthorSelect).toHaveBeenCalledWith(author);
79+
});
80+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { render, screen, fireEvent } from '@testing-library/react';
4+
import type { Author, Post } from '@/components/blog/BlogFilters';
5+
import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid';
6+
7+
const pushMock = vi.fn();
8+
9+
vi.mock('@/i18n/routing', () => ({
10+
useRouter: () => ({
11+
push: pushMock,
12+
}),
13+
}));
14+
15+
vi.mock('@/components/blog/BlogGrid', () => ({
16+
__esModule: true,
17+
default: ({ onAuthorSelect }: { onAuthorSelect: (author: Author) => void }) => (
18+
<button
19+
type="button"
20+
onClick={() => onAuthorSelect({ name: 'Анна' })}
21+
>
22+
select-author
23+
</button>
24+
),
25+
}));
26+
27+
describe('BlogCategoryGrid', () => {
28+
it('pushes author filter when author selected', () => {
29+
const posts: Post[] = [
30+
{ _id: '1', title: 'Post', slug: { current: 'post' } },
31+
];
32+
33+
render(<BlogCategoryGrid posts={posts} />);
34+
35+
fireEvent.click(screen.getByText('select-author'));
36+
expect(pushMock).toHaveBeenCalledWith('/blog?author=%D0%90%D0%BD%D0%BD%D0%B0');
37+
});
38+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { render, screen } from '@testing-library/react';
4+
import { BlogCategoryLinks } from '@/components/blog/BlogCategoryLinks';
5+
6+
vi.mock('next-intl', () => ({
7+
useTranslations: () => (key: string) => {
8+
const map: Record<string, string> = {
9+
'categories.tech': 'Технології',
10+
'categories.career': 'Кар\'єра',
11+
'categories.insights': 'Інсайти',
12+
'categories.news': 'Новини',
13+
'categories.growth': 'Кар\'єра',
14+
home: 'Головна',
15+
};
16+
return map[key] || key;
17+
},
18+
}));
19+
20+
vi.mock('@/i18n/routing', () => ({
21+
usePathname: () => '/blog/category/tech',
22+
}));
23+
24+
vi.mock('@/components/shared/AnimatedNavLink', () => ({
25+
AnimatedNavLink: ({ href, children, isActive }: any) => (
26+
<a href={href} data-active={isActive ? 'true' : 'false'}>
27+
{children}
28+
</a>
29+
),
30+
}));
31+
32+
vi.mock('@/components/shared/HeaderButton', () => ({
33+
HeaderButton: ({ href, children }: any) => <a href={href}>{children}</a>,
34+
}));
35+
36+
vi.mock('@/lib/utils', () => ({
37+
cn: (...args: string[]) => args.filter(Boolean).join(' '),
38+
}));
39+
40+
describe('BlogCategoryLinks', () => {
41+
it('renders home and category links with slugs', () => {
42+
render(
43+
<BlogCategoryLinks
44+
categories={[
45+
{ _id: '1', title: 'Tech' },
46+
{ _id: '2', title: 'Growth' },
47+
]}
48+
/>
49+
);
50+
51+
expect(screen.getByText('Головна')).toBeInTheDocument();
52+
expect(screen.getByText('Технології')).toBeInTheDocument();
53+
expect(screen.getByText("Кар'єра")).toBeInTheDocument();
54+
expect(screen.getByText('Технології').closest('a')).toHaveAttribute(
55+
'href',
56+
'/blog/category/tech'
57+
);
58+
});
59+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3+
import { render, screen, waitFor } from '@testing-library/react';
4+
import type { Author, Post } from '@/components/blog/BlogFilters';
5+
import BlogFilters from '@/components/blog/BlogFilters';
6+
7+
let searchParams = new URLSearchParams();
8+
const replaceMock = vi.fn();
9+
10+
vi.mock('next/navigation', () => ({
11+
useSearchParams: () => searchParams,
12+
}));
13+
14+
vi.mock('@/i18n/routing', () => ({
15+
Link: ({ href, children }: { href: string; children: React.ReactNode }) => (
16+
<a href={href}>{children}</a>
17+
),
18+
usePathname: () => '/blog',
19+
useRouter: () => ({
20+
replace: replaceMock,
21+
}),
22+
}));
23+
24+
vi.mock('next-intl', () => ({
25+
useTranslations: () => (key: string) => {
26+
const map: Record<string, string> = {
27+
'categories.tech': 'Технології',
28+
'categories.career': 'Кар\'єра',
29+
'categories.insights': 'Інсайти',
30+
'categories.news': 'Новини',
31+
author: 'Автор',
32+
blog: 'Блог',
33+
noPosts: 'Статей не знайдено',
34+
all: 'Усі',
35+
articlesBy: 'Статті',
36+
articlesPublished: 'Опубліковано',
37+
};
38+
return map[key] || key;
39+
},
40+
useLocale: () => 'uk',
41+
}));
42+
43+
vi.mock('@/components/blog/BlogGrid', () => ({
44+
default: ({ posts }: { posts: Post[] }) => (
45+
<ul data-testid="blog-grid">
46+
{posts.map(post => (
47+
<li key={post._id}>{post.title}</li>
48+
))}
49+
</ul>
50+
),
51+
}));
52+
53+
afterEach(() => {
54+
replaceMock.mockReset();
55+
searchParams = new URLSearchParams();
56+
vi.restoreAllMocks();
57+
});
58+
59+
describe('BlogFilters', () => {
60+
it('filters posts by search query in title or body', () => {
61+
searchParams = new URLSearchParams({ search: 'співбесіди' });
62+
const posts: Post[] = [
63+
{
64+
_id: '1',
65+
title: 'Як підготуватися до співбесіди',
66+
slug: { current: 'interview' },
67+
body: [
68+
{
69+
_type: 'block',
70+
children: [{ _type: 'span', text: 'Поради для співбесіди' }],
71+
},
72+
],
73+
},
74+
{
75+
_id: '2',
76+
title: 'CSS підказки',
77+
slug: { current: 'css-tips' },
78+
body: [
79+
{
80+
_type: 'block',
81+
children: [{ _type: 'span', text: 'Про Flexbox' }],
82+
},
83+
],
84+
},
85+
];
86+
87+
render(<BlogFilters posts={posts} categories={[]} />);
88+
89+
const grid = screen.getByTestId('blog-grid');
90+
expect(grid).toHaveTextContent('Як підготуватися до співбесіди');
91+
expect(grid).not.toHaveTextContent('CSS підказки');
92+
});
93+
94+
it('filters posts by author and renders author heading', async () => {
95+
searchParams = new URLSearchParams({ author: 'Анна' });
96+
const posts: Post[] = [
97+
{
98+
_id: '1',
99+
title: 'Пост Анни',
100+
slug: { current: 'anna-post' },
101+
author: { name: 'Анна' },
102+
},
103+
{
104+
_id: '2',
105+
title: 'Пост Віктора',
106+
slug: { current: 'viktor-post' },
107+
author: { name: 'Віктор' },
108+
},
109+
];
110+
111+
const authorPayload: Author = {
112+
name: 'Анна',
113+
jobTitle: 'QA',
114+
company: 'DevLovers',
115+
};
116+
117+
const fetchMock = vi.fn().mockResolvedValue({
118+
ok: true,
119+
json: async () => authorPayload,
120+
});
121+
vi.stubGlobal('fetch', fetchMock as typeof fetch);
122+
123+
render(<BlogFilters posts={posts} categories={[]} />);
124+
125+
await waitFor(() => {
126+
expect(fetchMock).toHaveBeenCalled();
127+
});
128+
129+
expect(
130+
screen.getByRole('heading', { name: 'Анна' })
131+
).toBeInTheDocument();
132+
133+
const grid = screen.getByTestId('blog-grid');
134+
expect(grid).toHaveTextContent('Пост Анни');
135+
expect(grid).not.toHaveTextContent('Пост Віктора');
136+
});
137+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { render, screen } from '@testing-library/react';
4+
import type { Post } from '@/components/blog/BlogFilters';
5+
import BlogGrid from '@/components/blog/BlogGrid';
6+
7+
vi.mock('next-intl', () => ({
8+
useTranslations: () => (key: string) => (key === 'noPosts' ? 'Статей не знайдено' : key),
9+
}));
10+
11+
vi.mock('@/components/blog/BlogCard', () => ({
12+
default: ({ post }: { post: Post }) => <div>{post.title}</div>,
13+
}));
14+
15+
describe('BlogGrid', () => {
16+
it('renders empty state when no posts', () => {
17+
render(<BlogGrid posts={[]} onAuthorSelect={() => {}} />);
18+
expect(screen.getByText('Статей не знайдено')).toBeInTheDocument();
19+
});
20+
21+
it('renders posts list', () => {
22+
const posts: Post[] = [
23+
{ _id: '1', title: 'Post 1', slug: { current: 'p1' } },
24+
{ _id: '2', title: 'Post 2', slug: { current: 'p2' } },
25+
];
26+
27+
render(<BlogGrid posts={posts} onAuthorSelect={() => {}} />);
28+
29+
expect(screen.getByText('Post 1')).toBeInTheDocument();
30+
expect(screen.getByText('Post 2')).toBeInTheDocument();
31+
});
32+
});

0 commit comments

Comments
 (0)