feat: wire Footer to PocketBase site_settings

Fetches CV file, email and social links via expand=contacts,contacts.socials.
CV rendered as polymorphic Button with download attr; socials and email
rendered as Link components.
This commit is contained in:
Ilia Mashkov
2026-05-18 20:45:44 +03:00
parent f159c6e861
commit 2ae5ae3210
3 changed files with 220 additions and 28 deletions
@@ -5,6 +5,74 @@ export const dynamic = 'force-static';
const base = { created: '', updated: '' }; const base = { created: '', updated: '' };
const FIXTURES: Record<string, unknown[]> = { const FIXTURES: Record<string, unknown[]> = {
site_settings: [
{
id: 'ss1',
collectionId: 'site_settings',
collectionName: 'site_settings',
...base,
cv: '',
contacts: 'c1',
expand: {
contacts: {
id: 'c1',
collectionId: 'contacts',
collectionName: 'contacts',
...base,
email: 'hello@allmy.work',
socials: ['s1', 's2'],
expand: {
socials: [
{
id: 's1',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'GitHub',
url: 'https://github.com',
},
{
id: 's2',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'LinkedIn',
url: 'https://linkedin.com',
},
],
},
},
},
},
],
contacts: [
{
id: 'c1',
collectionId: 'contacts',
collectionName: 'contacts',
...base,
email: 'hello@allmy.work',
socials: ['s1', 's2'],
},
],
contact: [
{
id: 's1',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'GitHub',
url: 'https://github.com',
},
{
id: 's2',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'LinkedIn',
url: 'https://linkedin.com',
},
],
sections: [ sections: [
{ {
id: '1', id: '1',
+115 -15
View File
@@ -1,47 +1,147 @@
vi.mock('$shared/api', () => ({
getFirstRecord: vi.fn(),
}));
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { getFirstRecord } from '$shared/api';
import { Footer } from './Footer'; import { Footer } from './Footer';
const mockSettings = {
id: 'ss1',
collectionId: 'site_settings',
collectionName: 'site_settings',
created: '',
updated: '',
cv: 'cv_2024.pdf',
contacts: 'c1',
expand: {
contacts: {
id: 'c1',
collectionId: 'contacts',
collectionName: 'contacts',
created: '',
updated: '',
email: 'hello@allmy.work',
socials: ['s1'],
expand: {
socials: [
{
id: 's1',
collectionId: 'contact',
collectionName: 'contact',
created: '',
updated: '',
label: 'GitHub',
url: 'https://github.com',
},
],
},
},
},
};
describe('Footer', () => { describe('Footer', () => {
beforeEach(() => {
vi.mocked(getFirstRecord).mockResolvedValue(mockSettings);
});
describe('structure', () => { describe('structure', () => {
it('renders a footer element', () => { it('renders a footer element', async () => {
const { container } = render(<Footer />); const { container } = render(await Footer());
expect(container.querySelector('footer')).toBeInTheDocument(); expect(container.querySelector('footer')).toBeInTheDocument();
}); });
it('has brutal-border-top separator', () => { it('has brutal-border-top separator', async () => {
const { container } = render(<Footer />); const { container } = render(await Footer());
expect(container.querySelector('footer')).toHaveClass('brutal-border-top'); expect(container.querySelector('footer')).toHaveClass('brutal-border-top');
}); });
}); });
describe('email link', () => { describe('email link', () => {
it('renders the contact email as a mailto link', () => { it('renders the contact email as a mailto link', async () => {
render(<Footer />); render(await Footer());
const link = screen.getByRole('link', { name: /hello@allmy\.work/i }); const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work'); expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
}); });
it('does not render email link when contacts.email is missing', async () => {
vi.mocked(getFirstRecord).mockResolvedValue({
...mockSettings,
expand: {
contacts: {
...mockSettings.expand.contacts,
email: '',
},
},
});
render(await Footer());
expect(screen.queryByRole('link', { name: /hello@allmy\.work/i })).not.toBeInTheDocument();
});
});
describe('social links', () => {
it('renders GitHub social link with correct href', async () => {
render(await Footer());
const link = screen.getByRole('link', { name: 'GitHub' });
expect(link).toHaveAttribute('href', 'https://github.com');
});
it('social links have target="_blank"', async () => {
render(await Footer());
const link = screen.getByRole('link', { name: 'GitHub' });
expect(link).toHaveAttribute('target', '_blank');
});
it('does not render social links when expand.socials is empty', async () => {
vi.mocked(getFirstRecord).mockResolvedValue({
...mockSettings,
expand: {
contacts: {
...mockSettings.expand.contacts,
expand: { socials: [] },
},
},
});
render(await Footer());
expect(screen.queryByRole('link', { name: 'GitHub' })).not.toBeInTheDocument();
});
}); });
describe('CV download', () => { describe('CV download', () => {
it('renders a CV download link', () => { it('renders a CV download link when cv is available', async () => {
render(<Footer />); render(await Footer());
expect(screen.getByRole('link', { name: /download cv/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /download cv/i })).toBeInTheDocument();
}); });
it('CV link points to the cv file', () => { it('CV link points to the PocketBase file URL', async () => {
render(<Footer />); render(await Footer());
expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute('href', '/cv.pdf'); expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute(
'href',
'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
);
}); });
it('CV link has download attribute', () => { it('CV link has download attribute', async () => {
render(<Footer />); render(await Footer());
expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute('download'); expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute('download');
}); });
it('CV link has button styling', () => { it('CV link has button styling', async () => {
render(<Footer />); render(await Footer());
const link = screen.getByRole('link', { name: /download cv/i }); const link = screen.getByRole('link', { name: /download cv/i });
expect(link).toHaveClass('brutal-border', 'uppercase'); expect(link).toHaveClass('brutal-border', 'uppercase');
}); });
it('does not render CV link when no cv field', async () => {
vi.mocked(getFirstRecord).mockResolvedValue({ ...mockSettings, cv: '' });
render(await Footer());
expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
});
it('does not render CV link when settings record is missing', async () => {
vi.mocked(getFirstRecord).mockResolvedValue(null);
render(await Footer());
expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
});
}); });
}); });
+35 -11
View File
@@ -1,22 +1,46 @@
import { CONTACT_LINKS } from '$shared/lib'; import type { SiteSettingsRecord } from '$shared/api';
import { getFirstRecord } from '$shared/api';
import { buildFileUrl } from '$shared/lib';
import { Button, Link } from '$shared/ui';
/** /**
* Site-wide footer with contact email and CV download link. * Site-wide footer with contact email, social links, and CV download.
* All contact data is fetched from the site_settings CMS collection with nested expand.
*/ */
export function Footer() { export async function Footer() {
const settings = await getFirstRecord<SiteSettingsRecord>('site_settings', {
expand: 'contacts,contacts.socials',
});
const cvUrl = settings?.cv ? buildFileUrl(settings.collectionId, settings.id, settings.cv) : null;
const contacts = settings?.expand?.contacts;
const socials = contacts?.expand?.socials ?? [];
return ( return (
<footer className="brutal-border-top px-8 py-6 lg:px-16 lg:py-8"> <footer className="brutal-border-top px-8 py-6 lg:px-16 lg:py-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-sm opacity-60 hover:opacity-100 transition-opacity"> <div className="flex flex-wrap items-center gap-4">
{CONTACT_LINKS.email} {contacts?.email && (
</a> <Link href={`mailto:${contacts.email}`} className="text-sm opacity-60 hover:opacity-100 no-underline">
<a {contacts.email}
href="/cv.pdf" </Link>
download )}
className="brutal-border px-4 py-2 bg-blue text-cream text-sm uppercase tracking-wider btn-transition self-start sm:self-auto" {socials.map((social) => (
<Link
key={social.id}
href={social.url}
external
className="text-sm opacity-60 hover:opacity-100 no-underline"
> >
{social.label}
</Link>
))}
</div>
{cvUrl && (
<Button href={cvUrl} download size="sm" className="self-start sm:self-auto">
Download CV Download CV
</a> </Button>
)}
</div> </div>
</footer> </footer>
); );