Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8cd8fef
feat(web): productivity tools - copy citation button and print styles
Hard-system Jun 27, 2026
6a95e3f
fix(web): address PR #157 feedback for productivity tools
Hard-system Jun 29, 2026
e09d339
test(web): fix citation test expectations for money formatting
Hard-system Jun 29, 2026
f14eac6
fix(web): address PR #157 review comments for citations
Hard-system Jun 29, 2026
d926835
fix(web): fallback origin for citation builders
Hard-system Jun 29, 2026
aa7585b
style(web): move productivity-tools CSS into the styles/* split
StanislavBG Jul 3, 2026
458c4d0
fix(web): grow source-cta/save-btn touch targets to 44px on mobile
StanislavBG Jul 5, 2026
17c8a25
test(web): assert concrete citation literals instead of re-deriving v…
StanislavBG Jul 10, 2026
b10aa22
fix(web): add clipboard fallback and rename copy-button classes
StanislavBG Jul 10, 2026
05ff318
fix(web): address PR #206 review feedback
StanislavBG Jul 11, 2026
bc61c8d
fix(web): drop invalid vitest environmentMatchGlobs option, fix prett…
StanislavBG Jul 11, 2026
f4b6351
Merge remote-tracking branch 'origin/main' into feat/productivity-tools
StanislavBG Jul 11, 2026
b057aaf
fix(web): address ydimitrof review round on PR #206 (copy citation bu…
StanislavBG Jul 11, 2026
9ba5636
fix(web): address remaining PR #206 review threads on citation.ts
StanislavBG Jul 12, 2026
b053051
fix(web): address ydimitrof review round 4 on PR #206
StanislavBG Jul 18, 2026
828fab8
Merge remote-tracking branch 'origin/main' into feat/productivity-tools
StanislavBG Jul 18, 2026
dd5a559
fix(web): document canonical contract slug in citation + de-duplicate…
StanislavBG Jul 20, 2026
652476b
fix(web): show Няма for empty-string EIK, cover protocol-relative pri…
StanislavBG Jul 22, 2026
a560c88
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG Jul 22, 2026
eaff395
build(deps): patch postcss/valibot CVEs, bump react-router within 7.x…
StanislavBG Jul 27, 2026
2ea523c
build: merge origin/main into feat/productivity-tools, resolve conflicts
StanislavBG Jul 28, 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
108 changes: 108 additions & 0 deletions apps/web/app/components/CopyCitationButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { CopyCitationButton } from './CopyCitationButton';

const originalClipboard = navigator.clipboard;
const originalExecCommand = document.execCommand;

afterEach(() => {
cleanup();
vi.restoreAllMocks();
Comment thread
StanislavBG marked this conversation as resolved.
vi.useRealTimers();
Object.assign(navigator, { clipboard: originalClipboard });
Object.assign(document, { execCommand: originalExecCommand });
});

describe('CopyCitationButton', () => {
it('shows the copied state after a successful Clipboard API write', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

render(<CopyCitationButton textToCopy="hello" />);
fireEvent.click(screen.getByRole('button'));

await waitFor(() => expect(writeText).toHaveBeenCalledWith('hello'));
await screen.findByText('Копирано!');
expect(screen.getByRole('button').className).toContain('is-copied');
});

it('falls back to execCommand when navigator.clipboard is unavailable', async () => {
Object.assign(navigator, { clipboard: undefined });
const execCommand = vi.fn().mockReturnValue(true);
Object.assign(document, { execCommand });

render(<CopyCitationButton textToCopy="hello" />);
fireEvent.click(screen.getByRole('button'));

await screen.findByText('Копирано!');
expect(execCommand).toHaveBeenCalledWith('copy');
expect(screen.getByRole('button').className).toContain('is-copied');
});

it('shows the failed state when both the Clipboard API and execCommand fail', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'));
Object.assign(navigator, { clipboard: { writeText } });
const execCommand = vi.fn().mockReturnValue(false);
Object.assign(document, { execCommand });
vi.spyOn(console, 'error').mockImplementation(() => {});

render(<CopyCitationButton textToCopy="hello" />);
fireEvent.click(screen.getByRole('button'));

await screen.findByText('Неуспешно копиране');
expect(screen.getByRole('button').className).not.toContain('is-copied');
expect(screen.getByRole('button').getAttribute('aria-label')).toBe('Копирането не бе успешно');
});

it('reports failed when execCommand throws while navigator.clipboard is unavailable', async () => {
Object.assign(navigator, { clipboard: undefined });
Object.assign(document, {
execCommand: vi.fn(() => {
throw new Error('unsupported');
}),
});

render(<CopyCitationButton textToCopy="hello" />);
fireEvent.click(screen.getByRole('button'));

await screen.findByText('Неуспешно копиране');
});

it('clears the pending reset timeout on unmount', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');

const { unmount } = render(<CopyCitationButton textToCopy="hello" />);
fireEvent.click(screen.getByRole('button'));
await screen.findByText('Копирано!');

clearTimeoutSpy.mockClear();
unmount();

expect(clearTimeoutSpy).toHaveBeenCalled();
});

it('does not update state after unmount when the clipboard write resolves late', async () => {
let resolveWrite: () => void = () => {};
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveWrite = resolve;
}),
);
Object.assign(navigator, { clipboard: { writeText } });
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const { unmount } = render(<CopyCitationButton textToCopy="hello" />);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(writeText).toHaveBeenCalled());

unmount();
resolveWrite();
await Promise.resolve();

expect(errorSpy).not.toHaveBeenCalled();
});
});
121 changes: 121 additions & 0 deletions apps/web/app/components/CopyCitationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useState, useCallback, useEffect, useRef } from 'react';

function copyWithExecCommand(text: string): boolean {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.setAttribute('aria-hidden', 'true');
textarea.tabIndex = -1;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
document.body.removeChild(textarea);
return ok;
}

export function CopyCitationButton({ textToCopy }: { textToCopy: string }) {
Comment thread
StanislavBG marked this conversation as resolved.
const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
const timeoutRef = useRef<number | null>(null);
const mountedRef = useRef(true);

useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);

const resetAfterDelay = useCallback(() => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => setStatus('idle'), 2000);
}, []);

const handleCopy = useCallback(() => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
Comment thread
StanislavBG marked this conversation as resolved.
navigator.clipboard
.writeText(textToCopy)
.then(() => {
if (!mountedRef.current) return;
setStatus('copied');
Comment thread
StanislavBG marked this conversation as resolved.
resetAfterDelay();
})
.catch((err) => {
console.error('Failed to copy text:', err);
if (!mountedRef.current) return;
// execCommand('copy') here runs after an awaited promise rejection, so in some
// browsers the original click's user-gesture context may already be gone by this
// point, which can make execCommand return false even though a synchronous-first
// attempt would have succeeded. We still degrade correctly to the 'failed' UI state
// in that case, and writeText (tried first, above) is the modern/preferred path that
// succeeds in the overwhelming majority of real browsers — so this ordering is kept
// rather than reversing the priority to chase a rarer edge case.
setStatus(copyWithExecCommand(textToCopy) ? 'copied' : 'failed');
Comment thread
StanislavBG marked this conversation as resolved.
resetAfterDelay();
});
return;
}
setStatus(copyWithExecCommand(textToCopy) ? 'copied' : 'failed');
resetAfterDelay();
}, [textToCopy, resetAfterDelay]);

const copied = status === 'copied';
const failed = status === 'failed';

return (
<button
type="button"
onClick={handleCopy}
className={`copy-btn ${copied ? 'is-copied' : ''}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a11y забележка: бутонът има динамичен aria-label, а вътрешният <span> също носи aria-live="polite" с променящ се текст. Тъй като aria-label замества съдържанието за достъпното име, това може да доведе до двойно или непоследователно обявяване от екранни четци при смяна на статуса. Обмислете едно място за live-съобщения (или само aria-label, или само live региона), за да е предвидимо озвучаването.

aria-label={
failed ? 'Копирането не бе успешно' : copied ? 'Копирано!' : 'Копирай данните като цитат'
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Дребно UX несъответствие: title остава „Копирай основните факти“ и в състояние copied (променя се само за failed). За консистентност с aria-label и текста на бутона обмислете title да отразява и успешното копиране (напр. „Копирано!“).

title={failed ? 'Копирането не бе успешно — опитайте отново' : 'Копирай основните факти'}
>
{copied ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
<span className="copy-btn-text" aria-live="polite">
{copied ? 'Копирано!' : failed ? 'Неуспешно копиране' : 'Копирай'}
</span>
</button>
);
}
149 changes: 149 additions & 0 deletions apps/web/app/lib/citation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest';
import { buildContractCitation, buildCompanyCitation, buildAuthorityCitation } from './citation';

describe('citation builders', () => {
it('builds a contract citation', () => {
const c = {
subject: 'Доставка на компютри',
authority: { name: 'Община Пловдив' },
bidder: { displayName: 'Техно ООД' },
value: { currentEur: 125000.5 },
id: 'abc-123',
};

const citation = buildContractCitation(c, 'https://sigma.test');
expect(citation).toBe(
[
'Договор: Доставка на компютри',
'Възложител: Община Пловдив',
'Изпълнител: Техно ООД',
'Стойност: 125 хил. €',
'Връзка: https://sigma.test/contracts/abc-123',
].join('\n'),
);
});

it('handles contract with null value', () => {
const c = {
subject: 'Одит',
authority: { name: 'Община Пловдив' },
bidder: { displayName: 'Техно ООД' },
value: { currentEur: null },
id: 'abc-123',
};

const citation = buildContractCitation(c, 'https://sigma.test');
expect(citation).toBe(
[
'Договор: Одит',
'Възложител: Община Пловдив',
'Изпълнител: Техно ООД',
'Стойност: —',
'Връзка: https://sigma.test/contracts/abc-123',
].join('\n'),
);
});

it('builds a company citation with EIK', () => {
const c = {
displayName: 'Техно ООД',
eik: '123456789',
wonEur: 5000000,
contracts: 42,
slug: 'techno-ood',
};

const citation = buildCompanyCitation(c, 'https://sigma.test');
expect(citation).toBe(
[
'Компания: Техно ООД',
'ЕИК: 123456789',
'Общо спечелено: 5 млн. €',
'Брой договори: 42',
'Връзка: https://sigma.test/companies/techno-ood',
].join('\n'),
);
});

it('handles contract with no awarded bidder', () => {
const c = {
subject: 'Прекратена процедура',
authority: { name: 'Община Пловдив' },
bidder: null,
value: { currentEur: null },
id: 'abc-123',
};

const citation = buildContractCitation(c, 'https://sigma.test');
expect(citation).toBe(
[
'Договор: Прекратена процедура',
'Възложител: Община Пловдив',
'Изпълнител: —',
'Стойност: —',
'Връзка: https://sigma.test/contracts/abc-123',
].join('\n'),
);
});

it('builds a company citation without EIK', () => {
const c = {
displayName: 'Чуждестранна фирма',
eik: null,
wonEur: 0,
contracts: 1,
slug: 'foreign-corp',
};

const citation = buildCompanyCitation(c, 'https://sigma.test');
expect(citation).toBe(
[
'Компания: Чуждестранна фирма',
'ЕИК: Няма',
'Общо спечелено: 0 €',
'Брой договори: 1',
'Връзка: https://sigma.test/companies/foreign-corp',
].join('\n'),
);
});

Comment thread
StanislavBG marked this conversation as resolved.
it('shows the EIK when hasEik is absent from loaderData but eik is present', () => {
const c = {
displayName: 'Техно ООД',
eik: '123456789',
wonEur: 5000000,
contracts: 42,
slug: 'techno-ood',
};

const citation = buildCompanyCitation(c, 'https://sigma.test');
expect(citation).toBe(
[
'Компания: Техно ООД',
'ЕИК: 123456789',
'Общо спечелено: 5 млн. €',
'Брой договори: 42',
'Връзка: https://sigma.test/companies/techno-ood',
].join('\n'),
);
});

it('builds an authority citation', () => {
const a = {
name: 'Община Варна',
spentEur: 1000000,
contracts: 5,
slug: 'obshtina-varna',
};

const citation = buildAuthorityCitation(a, 'https://sigma.test');
expect(citation).toBe(
[
'Институция: Община Варна',
'Общо похарчено: 1 млн. €',
'Брой договори: 5',
'Връзка: https://sigma.test/authorities/obshtina-varna',
].join('\n'),
);
});
});
Loading