API + kosmetyka
Deploy PROD / deploy (push) Successful in 25s

This commit is contained in:
2026-09-02 11:55:59 +02:00
parent 7b4d367a06
commit 9bd59d60bb
26 changed files with 1818 additions and 159 deletions
@@ -1,23 +1,37 @@
import { useParams } from 'react-router-dom'
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { publicClient } from '../../api/publicClient'
import type { CertificateVerification } from '../../api/types'
import { Icon } from '../../components/Icon'
import { PublicLayout } from './PublicLayout'
import { Button, Input } from '../../components/ui'
import { AcademyLayout } from '../../academy/AcademyLayout'
import { formatLocal } from '../../lib/dates'
/**
* Weryfikacja certyfikatu.
*
* DO CZEGO SLUZY
* ------------------------------------------------------------------------
* To jest PUBLICZNA weryfikacja po numerze, dla osoby z zewnatrz (pracodawca,
* instytucja), ktora dostala dokument i chce potwierdzic jego autentycznosc.
* Celowo bez logowania: sprawdzajacy nie ma u nas konta, a numer widniejacy
* na dokumencie sluzy wlasnie do takiego sprawdzenia. To NIE jest miejsce,
* gdzie kursant oglada wlasne certyfikaty - te sa na pulpicie po zalogowaniu.
*
* CO POKAZUJEMY, A CZEGO NIE
* ------------------------------------------------------------------------
* Strona jest otwarta dla kazdego, kto zna numer, wiec pokazuje **wylacznie**
* to, co potrzebne do potwierdzenia autentycznosci: numer, tytul, date, imie
* z pierwsza litera nazwiska i informacje o waznosci.
* z pierwsza litera nazwiska i informacje o waznosci. Nie ma tu pelnego
* nazwiska, adresu e-mail ani mozliwosci pobrania pliku.
*
* Nie ma tu pelnego nazwiska, adresu e-mail ani mozliwosci pobrania pliku.
* Pracodawca sprawdzajacy dokument ma potwierdzic, ze jest prawdziwy, a nie
* poznac dane osoby, ktorej nie zna, ani pobrac cudzy certyfikat.
* WEJSCIE BEZ NUMERU POKAZUJE FORMULARZ, NIE BLAD
* ------------------------------------------------------------------------
* Trasa `/certyfikat/*` lapie takze gole `/certyfikat` (ze stopki). Bez numeru
* nie ma czego weryfikowac, wiec zamiast odpytywac API o pusty numer i straszyc
* czerwonym "nie znaleziono", pokazujemy pole do wpisania numeru. Komunikat
* bledu pojawia sie dopiero po realnym wpisaniu numeru, ktory nie istnieje.
*/
export function CertificateVerifyPage() {
/*
@@ -26,8 +40,97 @@ export function CertificateVerifyPage() {
* by sie nie dopasowala. `*` lapie cala reszte sciezki wraz z ukosnikami.
*/
const params = useParams()
const number = decodeURIComponent(params['*'] ?? '')
const number = decodeURIComponent(params['*'] ?? '').trim()
return (
<AcademyLayout>
<div className="mx-auto max-w-2xl space-y-6">
<Link
to="/"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 transition-colors duration-fast hover:text-slate-900"
>
<Icon name="arrow-left" size={16} />
Strona główna
</Link>
{number ? <VerifyResult number={number} /> : <VerifyForm />}
</div>
</AcademyLayout>
)
}
/**
* Formularz wpisania numeru.
*
* Po wyslaniu przechodzimy na `/certyfikat/{numer}`: numer trafia do adresu,
* wiec wynik da sie odswiezyc, dodac do zakladek i podeslac linkiem - tak samo
* jak przy wejsciu z linku na dokumencie.
*/
function VerifyForm() {
const navigate = useNavigate()
const [value, setValue] = useState('')
const submit = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = value.trim()
if (!trimmed) return
// numer moze zawierac ukosnik ("2026/1") - trasa z wildcardem to obsluguje,
// ale poszczegolne segmenty koduje sie, zeby znaki specjalne nie rozbily URL
const encoded = trimmed.split('/').map(encodeURIComponent).join('/')
navigate(`/certyfikat/${encoded}`)
}
return (
<section className="rounded-xl border border-slate-200 bg-white p-6">
<div className="text-center">
<Icon name="award" size={36} className="mx-auto text-blue-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">Weryfikacja certyfikatu</h1>
<p className="mt-1 text-sm text-slate-600">
Wpisz numer widniejący na dokumencie, aby sprawdzić jego autentyczność.
</p>
</div>
<form onSubmit={submit} className="mx-auto mt-6 flex max-w-md flex-col gap-3 sm:flex-row">
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="np. 2026/1"
aria-label="Numer certyfikatu"
className="hx-data flex-1"
autoFocus
/>
<Button type="submit" icon="search" disabled={!value.trim()}>
Sprawdź
</Button>
</form>
{/*
Skad wziac numer. Sprawdzajacy z zewnatrz (pracodawca) trzyma dokument
w reku i moze nie wiedziec, ktora liczba na nim to numer do weryfikacji.
*/}
<div className="mx-auto mt-6 max-w-md rounded-lg bg-slate-50 p-4 text-sm text-slate-600">
<p className="flex items-center gap-2 font-medium text-slate-700">
<Icon name="help-circle" size={16} className="text-slate-400" />
Skąd wziąć numer certyfikatu?
</p>
<p className="mt-2 leading-relaxed">
Numer widnieje na samym dokumencie (PDF lub wydruku), zwykle w formacie
<span className="hx-data"> rok/numer</span>, na przykład
<span className="hx-data"> 2026/1</span>. Znajdziesz go w treści
certyfikatu, przy dacie wystawienia. Przepisz go dokładnie, razem
z ukośnikiem.
</p>
</div>
<p className="mt-5 border-t border-slate-100 pt-4 text-center text-xs text-slate-500">
Chcesz zobaczyć własne certyfikaty? Znajdziesz je po zalogowaniu na swoim koncie.
</p>
</section>
)
}
/** Wynik weryfikacji dla podanego numeru. */
function VerifyResult({ number }: { number: string }) {
const query = useQuery({
queryKey: ['certificate-verify', number],
queryFn: async () => {
@@ -42,71 +145,72 @@ export function CertificateVerifyPage() {
if (query.isPending) {
return (
<PublicLayout>
<div className="flex justify-center py-16">
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
</div>
</PublicLayout>
<div className="flex justify-center py-16">
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
</div>
)
}
if (query.isError) {
return (
<PublicLayout>
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
<Icon name="x-circle" size={36} className="mx-auto text-alarm-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Nie znaleźliśmy takiego certyfikatu
</h1>
<p className="mt-1 text-sm text-slate-600">
Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie.
</p>
</section>
</PublicLayout>
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
<Icon name="x-circle" size={36} className="mx-auto text-alarm-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Nie znaleźliśmy takiego certyfikatu
</h1>
<p className="mt-1 text-sm text-slate-600">
Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie.
</p>
<div className="mt-5">
<Link to="/certyfikat">
<Button variant="secondary" size="sm" icon="arrow-left">
Wpisz numer ponownie
</Button>
</Link>
</div>
</section>
)
}
const data = query.data
return (
<PublicLayout>
<section className="rounded-xl border border-slate-200 bg-white p-6">
<div className="text-center">
{data.valid ? (
<>
<Icon name="check-circle" size={40} className="mx-auto text-ok-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">Certyfikat jest ważny</h1>
</>
) : (
<>
<Icon name="x-circle" size={40} className="mx-auto text-alarm-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Certyfikat został unieważniony
</h1>
<p className="mt-1 text-sm text-slate-600">
Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu.
</p>
</>
)}
</div>
<section className="rounded-xl border border-slate-200 bg-white p-6">
<div className="text-center">
{data.valid ? (
<>
<Icon name="check-circle" size={40} className="mx-auto text-ok-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">Certyfikat jest ważny</h1>
</>
) : (
<>
<Icon name="x-circle" size={40} className="mx-auto text-alarm-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Certyfikat został unieważniony
</h1>
<p className="mt-1 text-sm text-slate-600">
Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu.
</p>
</>
)}
</div>
<dl className="mt-6 divide-y divide-slate-100 border-t border-slate-100">
<Row label="Numer certyfikatu" value={data.number} mono />
<Row label={data.forCourse ? 'Kurs' : 'Szkolenie'} value={data.subjectTitle} />
<Row label="Uczestnik" value={data.participantName} />
<Row label="Data wystawienia" value={formatLocal(data.issuedAt)} mono />
</dl>
<dl className="mt-6 divide-y divide-slate-100 border-t border-slate-100">
<Row label="Numer certyfikatu" value={data.number} mono />
<Row label={data.forCourse ? 'Kurs' : 'Szkolenie'} value={data.subjectTitle} />
<Row label="Uczestnik" value={data.participantName} />
<Row label="Data wystawienia" value={formatLocal(data.issuedAt)} mono />
</dl>
{/*
Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie
i moze uznac to za blad; to jest decyzja, nie usterka.
*/}
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska.
Pełne dane znajdują się na dokumencie, który posiada uczestnik.
</p>
</section>
</PublicLayout>
{/*
Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie
i moze uznac to za blad; to jest decyzja, nie usterka.
*/}
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska.
Pełne dane znajdują się na dokumencie, który posiada uczestnik.
</p>
</section>
)
}