fix: storybook font rendering and shared fonts module #1
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
Download CV
|
key={social.id}
|
||||||
</a>
|
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
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user