-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): productivity tools - copy citation button and print styles #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
8cd8fef
6a95e3f
e09d339
f14eac6
d926835
aa7585b
458c4d0
17c8a25
b10aa22
05ff318
bc61c8d
f4b6351
b057aaf
9ba5636
b053051
828fab8
dd5a559
652476b
a560c88
eaff395
2ea523c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| 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 }) { | ||
| const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); | ||
| const timeoutRef = useRef<number | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| return () => { | ||
| 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) { | ||
|
StanislavBG marked this conversation as resolved.
|
||
| navigator.clipboard | ||
| .writeText(textToCopy) | ||
| .then(() => { | ||
| setStatus('copied'); | ||
|
StanislavBG marked this conversation as resolved.
|
||
| resetAfterDelay(); | ||
| }) | ||
| .catch((err) => { | ||
| console.error('Failed to copy text:', err); | ||
| setStatus(copyWithExecCommand(textToCopy) ? 'copied' : 'failed'); | ||
|
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' : ''}`} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a11y забележка: бутонът има динамичен |
||
| aria-label={failed ? 'Копирането не бе успешно' : 'Копирай данните като цитат'} | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| 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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| 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', | ||
| hasEik: true, | ||
| }; | ||
|
|
||
| const citation = buildCompanyCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Компания: Техно ООД', | ||
| 'ЕИК: 123456789', | ||
| 'Общо спечелено: 5 млн. €', | ||
| 'Брой договори: 42', | ||
| 'Връзка: https://sigma.test/companies/techno-ood', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
| it('builds a company citation without EIK', () => { | ||
| const c = { | ||
| displayName: 'Чуждестранна фирма', | ||
| eik: null, | ||
| wonEur: 0, | ||
| contracts: 1, | ||
| slug: 'foreign-corp', | ||
| hasEik: false, | ||
| }; | ||
|
|
||
| const citation = buildCompanyCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Компания: Чуждестранна фирма', | ||
| 'ЕИК: Няма', | ||
| 'Общо спечелено: 0 €', | ||
| 'Брой договори: 1', | ||
| 'Връзка: https://sigma.test/companies/foreign-corp', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
|
StanislavBG marked this conversation as resolved.
|
||
| 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'), | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { money, count } from '@sigma/shared'; | ||
|
|
||
| export function buildContractCitation( | ||
| c: { | ||
| subject: string; | ||
| authority: { name: string }; | ||
| bidder: { displayName: string }; | ||
| value: { currentEur: number | null }; | ||
| id: string; | ||
| }, | ||
| origin: string, | ||
| ): string { | ||
| return [ | ||
| `Договор: ${c.subject}`, | ||
| `Възложител: ${c.authority.name}`, | ||
| `Изпълнител: ${c.bidder.displayName}`, | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| `Стойност: ${money(c.value.currentEur)}`, | ||
| `Връзка: ${origin}/contracts/${c.id}`, | ||
|
StanislavBG marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Точка за проверка: линкът се строи с |
||
| ].join('\n'); | ||
| } | ||
|
|
||
| export function buildCompanyCitation( | ||
| c: { | ||
| displayName: string; | ||
| eik: string | null; | ||
| wonEur: number; | ||
| contracts: number; | ||
| slug: string; | ||
| hasEik?: boolean; | ||
| }, | ||
| origin: string, | ||
| ): string { | ||
| return [ | ||
| `Компания: ${c.displayName}`, | ||
|
StanislavBG marked this conversation as resolved.
|
||
| `ЕИК: ${c.hasEik && c.eik ? c.eik : 'Няма'}`, | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| `Общо спечелено: ${money(c.wonEur)}`, | ||
| `Брой договори: ${count(c.contracts)}`, | ||
| `Връзка: ${origin}/companies/${c.slug}`, | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| export function buildAuthorityCitation( | ||
| a: { | ||
| name: string; | ||
| spentEur: number; | ||
| contracts: number; | ||
| slug: string; | ||
| }, | ||
| origin: string, | ||
| ): string { | ||
| return [ | ||
| `Институция: ${a.name}`, | ||
| `Общо похарчено: ${money(a.spentEur)}`, | ||
| `Брой договори: ${count(a.contracts)}`, | ||
| `Връзка: ${origin}/authorities/${a.slug}`, | ||
| ].join('\n'); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.