Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
107 changes: 107 additions & 0 deletions apps/web/app/components/CopyCitationButton.tsx
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 }) {
Comment thread
StanislavBG marked this conversation as resolved.
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) {
Comment thread
StanislavBG marked this conversation as resolved.
navigator.clipboard
.writeText(textToCopy)
.then(() => {
setStatus('copied');
Comment thread
StanislavBG marked this conversation as resolved.
resetAfterDelay();
})
.catch((err) => {
console.error('Failed to copy text:', err);
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 ? 'Копирането не бе успешно' : 'Копирай данните като цитат'}
Comment thread
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>
);
}
109 changes: 109 additions & 0 deletions apps/web/app/lib/citation.test.ts
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'),
);
});

Comment thread
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'),
);
});
});
57 changes: 57 additions & 0 deletions apps/web/app/lib/citation.ts
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}`,
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
`Стойност: ${money(c.value.currentEur)}`,
`Връзка: ${origin}/contracts/${c.id}`,
Comment thread
StanislavBG marked this conversation as resolved.

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.

Точка за проверка: линкът се строи с ${origin}/contracts/${c.id}, а не с contractSlug(c), който contract.tsx използва навсякъде другаде. Коментарът твърди, че c.id вече е percent-encoded slug изход от contractSlug. Ако c.id в действителност е сурово UUID/числово id (а не slug), копираният цитат ще сочи към неканоничен или несъществуващ URL. Моля потвърдете, че ContractDetail.id е точно същата стойност като contractSlug(c), използвана в маршрута — иначе е добре да се извика contractSlug(c) тук за единен източник на истина.

].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}`,
Comment thread
StanislavBG marked this conversation as resolved.
`ЕИК: ${c.hasEik && c.eik ? c.eik : 'Няма'}`,
Comment thread
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');
}
14 changes: 11 additions & 3 deletions apps/web/app/routes/authority.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link } from 'react-router';
import { Link, useMatches } from 'react-router';
import { count, money, moneyBare, pct, periodRange, plural } from '@sigma/shared';
import {
authorityIdFromSlug,
Expand All @@ -10,6 +10,7 @@ import {
import type { Route } from './+types/authority';
import { Breadcrumbs } from '../components/Breadcrumbs';
import { PageHeader } from '../components/PageHeader';
import { CopyCitationButton } from '../components/CopyCitationButton';
import { FactsList } from '../components/FactsList';
import { StackedBar } from '../components/StackedBar';
import { DataTable } from '../components/DataTable';
Expand All @@ -22,7 +23,8 @@ import { publicCache } from '../lib/cache';
import { coverageRange, getCoverageMeta } from '../lib/coverage';
import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tables';
import { withDbRetry } from '../lib/retry';
import { seoMeta } from '../lib/meta';
import { buildAuthorityCitation } from '../lib/citation';
import { seoMeta, getRootOrigin } from '../lib/meta';

export function meta({ data, params, matches }: Route.MetaArgs) {
const name = data?.authority.name ?? 'Институция';
Expand Down Expand Up @@ -58,6 +60,8 @@ export async function loader({ params, context }: Route.LoaderArgs) {
}

export default function Authority({ loaderData }: Route.ComponentProps) {
const matches = useMatches();
const origin = getRootOrigin(matches) ?? 'https://sigma.midt.bg';
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
const a = loaderData.authority;
const { trend, network, competition } = loaderData;
const ct = competition;
Expand Down Expand Up @@ -90,7 +94,11 @@ export default function Authority({ loaderData }: Route.ComponentProps) {
}
title={a.name}
lede={`Колко публични средства е похарчила институцията за обществени поръчки през ${range} г. Зад всяко число по-долу стоят конкретните договори, които го формират.`}
/>
>
<div className="header-actions">
<CopyCitationButton textToCopy={buildAuthorityCitation(a, origin)} />
</div>
</PageHeader>

<FactsList
label="Ключови показатели"
Expand Down
14 changes: 11 additions & 3 deletions apps/web/app/routes/company.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link } from 'react-router';
import { Link, useMatches } from 'react-router';
import {
count,
isNaturalPersonProfileName,
Expand All @@ -12,6 +12,7 @@ import { bidderIdFromSlug, getCompany, getEntityNetwork, getSpendingTrend } from
import type { Route } from './+types/company';
import { Breadcrumbs } from '../components/Breadcrumbs';
import { PageHeader } from '../components/PageHeader';
import { CopyCitationButton } from '../components/CopyCitationButton';
import { FactsList } from '../components/FactsList';
import { StackedBar } from '../components/StackedBar';
import { DataTable } from '../components/DataTable';
Expand All @@ -23,7 +24,8 @@ import { publicCache } from '../lib/cache';
import { coverageRange, getCoverageMeta } from '../lib/coverage';
import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tables';
import { withDbRetry } from '../lib/retry';
import { seoMeta } from '../lib/meta';
import { buildCompanyCitation } from '../lib/citation';
import { seoMeta, getRootOrigin } from '../lib/meta';

function isSingleNaturalPersonProfile(kind: string, legalForm: string | null): boolean {
if (kind === 'consortium' || !legalForm) return false;
Expand Down Expand Up @@ -79,6 +81,8 @@ export async function loader({ params, context }: Route.LoaderArgs) {
}

export default function Company({ loaderData }: Route.ComponentProps) {
const matches = useMatches();
const origin = getRootOrigin(matches) ?? 'https://sigma.midt.bg';
const c = loaderData.company;
const { trend, network } = loaderData;
const range = coverageRange(loaderData.coverage.coverageEndYear);
Expand Down Expand Up @@ -127,7 +131,11 @@ export default function Company({ loaderData }: Route.ComponentProps) {
}
title={c.displayName}
lede={`Колко публични средства е ${wonVerb} ${subjectPhrase} по обществени поръчки за периода ${range} г.`}
/>
>
<div className="header-actions">
<CopyCitationButton textToCopy={buildCompanyCitation(c, origin)} />
</div>
</PageHeader>

<FactsList
label="Ключови показатели"
Expand Down
Loading