@@ -50,6 +50,7 @@ import { ConsentSettingsPage } from './pages/settings/ConsentSettingsPage'
|
||||
import { CompanySettingsPage } from './pages/settings/CompanySettingsPage'
|
||||
import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
|
||||
import { UserListPage } from './pages/users/UserListPage'
|
||||
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
|
||||
import {
|
||||
AcademyChoicePage,
|
||||
AcademyPasswordResetPage,
|
||||
@@ -330,6 +331,14 @@ export function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="klucze-api"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ApiKeyListPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
|
||||
<Route path="*" element={<Navigate to={panelPath()} replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
|
||||
import { HomeWebinarCard } from './HomeWebinarCard'
|
||||
import { useStudentAuth } from './StudentAuthContext'
|
||||
import { STUDENT_ROUTES, panelPath } from '../lib/routes'
|
||||
import { AdminEmptyHint, AdminSectionHint, SampleBadge } from './AdminHint'
|
||||
import { AdminEmptyHint, AdminSectionHint, SampleBadge, useIsStaff } from './AdminHint'
|
||||
|
||||
type PriceFilter = 'all' | 'free' | 'paid'
|
||||
|
||||
@@ -33,6 +33,9 @@ type PriceFilter = 'all' | 'free' | 'paid'
|
||||
*/
|
||||
export function AcademyHomePage() {
|
||||
const { student } = useStudentAuth()
|
||||
// widocznosc placeholderow sekcji: admin i moderator widza podpowiedzi
|
||||
// w miejscu pustych sekcji, gosc nie
|
||||
const isStaff = useIsStaff()
|
||||
const [filter, setFilter] = useState<PriceFilter>('all')
|
||||
const [category, setCategory] = useState<string>('')
|
||||
|
||||
@@ -41,9 +44,12 @@ export function AcademyHomePage() {
|
||||
queryFn: async () => (await publicClient.get<AcademyHome>('/public/academy/home')).data,
|
||||
})
|
||||
|
||||
// tytul karty to sama nazwa marki (APP_BRAND_NAME); haslo hero zostaje
|
||||
// opisem zajawki, ale nie tytulem strony
|
||||
useDocumentMeta({
|
||||
title: data?.heroTitle ?? 'Akademia',
|
||||
description: data?.heroText,
|
||||
title: 'Akademia',
|
||||
description: data?.heroText ?? data?.heroTitle,
|
||||
brandOnly: true,
|
||||
})
|
||||
|
||||
const courses = useMemo(() => {
|
||||
@@ -301,88 +307,117 @@ export function AcademyHomePage() {
|
||||
</Band>
|
||||
)}
|
||||
|
||||
{/* ============ OPINIE I PYTANIA: dwie kolumny, tlo biale ============ */}
|
||||
<Band tone="white">
|
||||
<div className="grid items-start gap-10 py-12 sm:py-14 lg:grid-cols-2 lg:gap-14">
|
||||
{/* --- opinie --- */}
|
||||
<div>
|
||||
{data.reviews.length === 0 ? (
|
||||
<AdminSectionHint
|
||||
flat
|
||||
title={data.reviewsTitle}
|
||||
what="Opinie osób, które ukończyły szkolenie. Publikuj wyłącznie te, na które masz zgodę autora: wymyślona opinia jest nieprawdą o nieistniejącej osobie, dlatego nie zakładamy tu przykładów."
|
||||
to={panelPath('/ustawienia/strona-glowna')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
||||
{data.reviewsTitle}
|
||||
</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
{data.reviews.map((r) => (
|
||||
<figure
|
||||
key={r.id}
|
||||
className="rounded-lg border border-slate-100 bg-slate-25 p-5"
|
||||
>
|
||||
<Icon name="message-square" size={18} className="text-aqua-500" />
|
||||
<blockquote className="mt-2.5 text-sm leading-relaxed text-slate-700">
|
||||
{r.body}
|
||||
</blockquote>
|
||||
<figcaption className="mt-3.5 border-t border-slate-200 pt-3">
|
||||
<p className="text-sm font-semibold text-slate-900">{r.title}</p>
|
||||
{r.subtitle && <p className="text-xs text-slate-500">{r.subtitle}</p>}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* ============ OPINIE I PYTANIA: tlo biale ============ */}
|
||||
{/*
|
||||
Sekcje opinii i FAQ stoja obok siebie tylko wtedy, gdy OBIE maja co
|
||||
pokazac. Dla goscia placeholder sie nie renderuje, wiec pusta sekcja
|
||||
znika calkiem, a druga zajmuje PELNA SZEROKOSC i jest wyrownana do
|
||||
lewej, jak reszta sekcji strony. Bez tego jedyna widoczna sekcja
|
||||
(np. samo FAQ) laduje w waskiej prawej kolumnie siatki, obok pustego
|
||||
miejsca po niewidocznych opiniach.
|
||||
*/}
|
||||
{(() => {
|
||||
const showReviews = data.reviews.length > 0 || isStaff
|
||||
const showFaq = data.faq.length > 0 || isStaff
|
||||
if (!showReviews && !showFaq) return null
|
||||
const twoColumns = showReviews && showFaq
|
||||
|
||||
{/* --- FAQ --- */}
|
||||
<div>
|
||||
{data.faq.length === 0 ? (
|
||||
<AdminSectionHint
|
||||
flat
|
||||
title={data.faqTitle}
|
||||
what="Pytania i odpowiedzi w rozwijanej liście. Przy pierwszym uruchomieniu zakładamy trzy przykładowe pytania o działanie platformy."
|
||||
to={panelPath('/ustawienia/strona-glowna')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
||||
{data.faqTitle}
|
||||
</h2>
|
||||
<div className="mt-6 divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
{data.faq.map((f) => (
|
||||
<details key={f.id} className="group">
|
||||
{/* Element details, a nie wlasny stan: dziala bez JavaScriptu,
|
||||
obsluguje klawiature i jest czytany przez czytniki ekranu
|
||||
bez dopisywania atrybutow aria. */}
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-sm font-medium text-slate-900 hover:bg-slate-25 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-blue-500/45">
|
||||
<span className="min-w-0">
|
||||
{f.title}
|
||||
{f.sample && <SampleBadge to={panelPath('/ustawienia/strona-glowna')} />}
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size={18}
|
||||
className="shrink-0 text-slate-400 transition-transform duration-fast group-open:rotate-180"
|
||||
/>
|
||||
</summary>
|
||||
{f.body && (
|
||||
<div className="px-5 pb-4 text-sm leading-relaxed text-slate-600">
|
||||
{f.body}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
return (
|
||||
<Band tone="white">
|
||||
<div
|
||||
className={`grid items-start gap-10 py-12 sm:py-14 lg:gap-14 ${
|
||||
twoColumns ? 'lg:grid-cols-2' : ''
|
||||
}`}
|
||||
>
|
||||
{/* --- opinie --- */}
|
||||
{showReviews && (
|
||||
<div>
|
||||
{data.reviews.length === 0 ? (
|
||||
<AdminSectionHint
|
||||
flat
|
||||
title={data.reviewsTitle}
|
||||
what="Opinie osób, które ukończyły szkolenie. Publikuj wyłącznie te, na które masz zgodę autora: wymyślona opinia jest nieprawdą o nieistniejącej osobie, dlatego nie zakładamy tu przykładów."
|
||||
to={panelPath('/ustawienia/strona-glowna')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
||||
{data.reviewsTitle}
|
||||
</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
{data.reviews.map((r) => (
|
||||
<figure
|
||||
key={r.id}
|
||||
className="rounded-lg border border-slate-100 bg-slate-25 p-5"
|
||||
>
|
||||
<Icon name="message-square" size={18} className="text-aqua-500" />
|
||||
<blockquote className="mt-2.5 text-sm leading-relaxed text-slate-700">
|
||||
{r.body}
|
||||
</blockquote>
|
||||
<figcaption className="mt-3.5 border-t border-slate-200 pt-3">
|
||||
<p className="text-sm font-semibold text-slate-900">{r.title}</p>
|
||||
{r.subtitle && (
|
||||
<p className="text-xs text-slate-500">{r.subtitle}</p>
|
||||
)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Band>
|
||||
)}
|
||||
|
||||
{/* --- FAQ --- */}
|
||||
{showFaq && (
|
||||
<div>
|
||||
{data.faq.length === 0 ? (
|
||||
<AdminSectionHint
|
||||
flat
|
||||
title={data.faqTitle}
|
||||
what="Pytania i odpowiedzi w rozwijanej liście. Przy pierwszym uruchomieniu zakładamy trzy przykładowe pytania o działanie platformy."
|
||||
to={panelPath('/ustawienia/strona-glowna')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
|
||||
{data.faqTitle}
|
||||
</h2>
|
||||
<div className="mt-6 divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
{data.faq.map((f) => (
|
||||
<details key={f.id} className="group">
|
||||
{/* Element details, a nie wlasny stan: dziala bez JavaScriptu,
|
||||
obsluguje klawiature i jest czytany przez czytniki ekranu
|
||||
bez dopisywania atrybutow aria. */}
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-sm font-medium text-slate-900 hover:bg-slate-25 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-blue-500/45">
|
||||
<span className="min-w-0">
|
||||
{f.title}
|
||||
{f.sample && (
|
||||
<SampleBadge to={panelPath('/ustawienia/strona-glowna')} />
|
||||
)}
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size={18}
|
||||
className="shrink-0 text-slate-400 transition-transform duration-fast group-open:rotate-180"
|
||||
/>
|
||||
</summary>
|
||||
{f.body && (
|
||||
<div className="px-5 pb-4 text-sm leading-relaxed text-slate-600">
|
||||
{f.body}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ================= CENNIK: tlo szare ================= */}
|
||||
{paidCourses.length > 0 && (
|
||||
|
||||
@@ -290,11 +290,10 @@ export function AcademyFooter({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-9 flex flex-col gap-2 border-t border-white/10 pt-5 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="mt-9 border-t border-white/10 pt-5 text-xs text-slate-500">
|
||||
<span>
|
||||
© {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone.
|
||||
</span>
|
||||
<span className="hx-data">Obsługiwane przez platformę webinarową</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { UpcomingWebinar } from '../api/types'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { formatDateTimeParts } from '../lib/dates'
|
||||
@@ -27,9 +26,15 @@ import { formatDateTimeParts } from '../lib/dates'
|
||||
* zapisuje. Przycisk mowi wprost "Przejdz do zapisu", a nie "Zapisz sie":
|
||||
* przycisk, ktory nie robi tego, co obiecuje, jest gorszy niz jedno klikniecie
|
||||
* wiecej. Adres nie przepada, wiec uzytkownik nie pisze go drugi raz.
|
||||
*
|
||||
* STRONA ZAPISU OTWIERA SIE W NOWEJ KARCIE.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Uzytkownik przegladajacy strone glowna nie traci jej z oczu: po zapisaniu
|
||||
* moze wrocic do listy kolejnych webinarow. Dlatego zamiast `navigate` (ta
|
||||
* sama karta) otwieramy `window.open` z `noopener,noreferrer` - nowa karta
|
||||
* nie dostaje dostepu do `window.opener`.
|
||||
*/
|
||||
export function HomeWebinarCard({ webinar }: { webinar: UpcomingWebinar }) {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const when = formatDateTimeParts(webinar.startsAt, webinar.timezone)
|
||||
|
||||
@@ -41,11 +46,10 @@ export function HomeWebinarCard({ webinar }: { webinar: UpcomingWebinar }) {
|
||||
* daje sie odswiezyc i podeslac. Nie buduje sie z niego zadnej sciezki,
|
||||
* wiec nie ma tu ryzyka przekierowania na obcy adres.
|
||||
*/
|
||||
navigate(
|
||||
trimmed
|
||||
? `/w/${webinar.slug}?email=${encodeURIComponent(trimmed)}`
|
||||
: `/w/${webinar.slug}`,
|
||||
)
|
||||
const url = trimmed
|
||||
? `/w/${webinar.slug}?email=${encodeURIComponent(trimmed)}`
|
||||
: `/w/${webinar.slug}`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,6 @@ import { BrandMark } from '../components/BrandMark'
|
||||
import { formatWithZone } from '../lib/dates'
|
||||
import { CourseCover, ProgressBar } from './AcademyBits'
|
||||
import { useStudentAuth } from './StudentAuthContext'
|
||||
import { STUDENT_ROUTES } from '../lib/routes'
|
||||
|
||||
type Tab = 'kursy' | 'webinary' | 'certyfikaty' | 'dane'
|
||||
|
||||
@@ -41,7 +40,8 @@ export function StudentDashboardPage() {
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate(STUDENT_ROUTES.login, { replace: true })
|
||||
// po wylogowaniu na strone glowna, nie na formularz logowania
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Plus,
|
||||
Play,
|
||||
Presentation,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Send,
|
||||
@@ -109,6 +110,7 @@ const ICONS = {
|
||||
plus: Plus,
|
||||
play: Play,
|
||||
presentation: Presentation,
|
||||
refresh: RefreshCw,
|
||||
save: Save,
|
||||
search: Search,
|
||||
send: Send,
|
||||
|
||||
@@ -46,6 +46,19 @@ export function Layout() {
|
||||
|
||||
useInstanceFavicon()
|
||||
|
||||
/*
|
||||
* Wylogowanie przenosi na strone glowna, nie na formularz logowania.
|
||||
*
|
||||
* Sam `logout` tylko czysci stan; bez jawnej nawigacji guard RequireAuth
|
||||
* przerzucilby na /logowanie, gdy `user` znika. Nawigujemy na `/` PRZED
|
||||
* wyczyszczeniem stanu, zeby uzytkownik trafil na strone publiczna, a nie
|
||||
* na ekran logowania.
|
||||
*/
|
||||
const handleLogout = useCallback(() => {
|
||||
navigate('/', { replace: true })
|
||||
logout()
|
||||
}, [navigate, logout])
|
||||
|
||||
// zmiana ekranu zamyka menu, zeby nie zostawalo otwarte nad trescia
|
||||
useEffect(() => {
|
||||
setMenuOpen(false)
|
||||
@@ -176,7 +189,7 @@ export function Layout() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
onClick={handleLogout}
|
||||
className="flex min-h-[44px] items-center gap-2 rounded-md px-3 text-sm font-medium text-slate-600 transition duration-fast ease-standard hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="log-out" size={18} />
|
||||
|
||||
@@ -106,6 +106,7 @@ export const NAV_COMMON: NavItem[] = [
|
||||
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
|
||||
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
|
||||
]
|
||||
|
||||
/** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usePublicConfig } from './usePublicConfig'
|
||||
|
||||
type Meta = {
|
||||
title: string
|
||||
description?: string
|
||||
/**
|
||||
* Strona glowna: tytul jest sama nazwa marki, bez doklejania jej po
|
||||
* myslniku (inaczej wyszloby "Akademia Wodociagowca - Akademia Wodociagowca").
|
||||
*/
|
||||
brandOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia tytul strony i podstawowe meta tagi.
|
||||
*
|
||||
* NAZWA MARKI W TYTULE KARTY
|
||||
* ---------------------------------------------------------------------------
|
||||
* Tytul karty przegladarki ZAWSZE zawiera nazwe marki z ustawien
|
||||
* (`APP_BRAND_NAME`, pole "Nazwa wyswietlana w mailach i w naglowku"). Strona
|
||||
* przekazuje wlasny tytul opisujacy JEJ tresc ("Kursy online", "Cennik"),
|
||||
* a hook doklada do niego marke: "Kursy online - Akademia Wodociagowca".
|
||||
* Dzieki temu w karcie widac i marke, i to, ktora strona jest otwarta, a nowy
|
||||
* ekran nie musi o tym pamietac.
|
||||
*
|
||||
* Strona glowna podaje `brandOnly`, bo jej tytulem jest sama marka.
|
||||
*
|
||||
* Aplikacja jest SPA, wiec te tagi widzi dopiero klient po wykonaniu skryptu.
|
||||
* Komunikatory pobierajace zajawke bez uruchamiania JavaScriptu zobacza wartosci
|
||||
* z index.html. Pelna zajawka per webinar wymagalaby renderowania po stronie
|
||||
* serwera albo prerenderingu, co jest poza zakresem tego kroku.
|
||||
*/
|
||||
export function useDocumentMeta({ title, description }: Meta) {
|
||||
export function useDocumentMeta({ title, description, brandOnly = false }: Meta) {
|
||||
const { brandName } = usePublicConfig()
|
||||
|
||||
// dopoki config sie nie zaladuje, `brandName` jest pusty: uzywamy wtedy
|
||||
// samego tytulu strony, zeby w karcie nie mignal wiszacy myslnik
|
||||
const brand = brandName.trim()
|
||||
const fullTitle = brandOnly
|
||||
? brand || title
|
||||
: brand
|
||||
? `${title} - ${brand}`
|
||||
: title
|
||||
|
||||
useEffect(() => {
|
||||
const previousTitle = document.title
|
||||
document.title = title
|
||||
document.title = fullTitle
|
||||
|
||||
const tags: HTMLMetaElement[] = []
|
||||
|
||||
@@ -31,11 +59,11 @@ export function useDocumentMeta({ title, description }: Meta) {
|
||||
tag.setAttribute('content', content)
|
||||
}
|
||||
|
||||
setMeta('property', 'og:title', title)
|
||||
setMeta('property', 'og:title', fullTitle)
|
||||
setMeta('property', 'og:type', 'website')
|
||||
setMeta('property', 'og:url', window.location.href)
|
||||
setMeta('name', 'twitter:card', 'summary')
|
||||
setMeta('name', 'twitter:title', title)
|
||||
setMeta('name', 'twitter:title', fullTitle)
|
||||
|
||||
if (description) {
|
||||
setMeta('name', 'description', description)
|
||||
@@ -48,5 +76,5 @@ export function useDocumentMeta({ title, description }: Meta) {
|
||||
// usuwamy tylko tagi dodane przez ten hook, istniejace zostaja
|
||||
tags.forEach((tag) => tag.remove())
|
||||
}
|
||||
}, [title, description])
|
||||
}, [fullTitle, description])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
IconButton,
|
||||
Input,
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
/** Klucz API zwracany na liscie: bez samego klucza, tylko prefiks. */
|
||||
type ApiKey = {
|
||||
id: number
|
||||
name: string
|
||||
prefix: string
|
||||
active: boolean
|
||||
lastUsedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** Odpowiedz na utworzenie: widok plus surowy klucz pokazany raz. */
|
||||
type ApiKeyCreated = {
|
||||
apiKey: ApiKey
|
||||
key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Zarzadzanie kluczami API do publicznego API.
|
||||
*
|
||||
* SUROWY KLUCZ POKAZANY RAZ. Backend trzyma wylacznie skrot, wiec po zamknieciu
|
||||
* panelu klucza nie da sie odczytac ponownie: pokazujemy go od razu po
|
||||
* utworzeniu, z przyciskiem kopiowania i wyraznym ostrzezeniem.
|
||||
*/
|
||||
export function ApiKeyListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
/** Surowy klucz po utworzeniu, pokazywany jeden raz. */
|
||||
const [created, setCreated] = useState<ApiKeyCreated | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: async () => (await client.get<ApiKey[]>('/admin/api-keys')).data,
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (payload: { name: string }) => {
|
||||
const response = await client.post<ApiKeyCreated>('/admin/api-keys', payload)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setCreated(data)
|
||||
setCreating(false)
|
||||
setName('')
|
||||
setErrors({})
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors(fieldErrors(error))
|
||||
toasts.error(errorMessage(error, 'Nie udało się utworzyć klucza'))
|
||||
},
|
||||
})
|
||||
|
||||
const regenerate = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await client.post<ApiKeyCreated>(`/admin/api-keys/${id}/regenerate`)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setCreated(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wygenerować klucza ponownie')),
|
||||
})
|
||||
|
||||
const setActive = useMutation({
|
||||
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
|
||||
client.post(`/admin/api-keys/${id}/${active ? 'activate' : 'deactivate'}`),
|
||||
onSuccess: (_, variables) => {
|
||||
toasts.success(variables.active ? 'Klucz włączony' : 'Klucz wyłączony')
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić stanu klucza')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/api-keys/${id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Klucz usunięty')
|
||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć klucza')),
|
||||
})
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setErrors({ name: 'Nazwa klucza jest wymagana' })
|
||||
return
|
||||
}
|
||||
create.mutate({ name: name.trim() })
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
if (!created) return
|
||||
navigator.clipboard
|
||||
.writeText(created.key)
|
||||
.then(() => toasts.success('Klucz skopiowany do schowka'))
|
||||
.catch(() => toasts.error('Nie udało się skopiować. Zaznacz i skopiuj ręcznie.'))
|
||||
}
|
||||
|
||||
if (query.isPending) return <Spinner />
|
||||
if (query.isError) {
|
||||
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać kluczy API')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Klucze API</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Dostęp do publicznego API z listą aktywnych webinarów i kursów
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
setCreating(true)
|
||||
setName('')
|
||||
setErrors({})
|
||||
}}
|
||||
>
|
||||
Nowy klucz
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Alert tone="info" icon="help-circle">
|
||||
Klucze chronią publiczne API tylko do odczytu:
|
||||
{' '}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 text-xs">GET /api/public/v1/webinars</code>
|
||||
{' '}i{' '}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 text-xs">GET /api/public/v1/courses</code>.
|
||||
{' '}Odbiorca przekazuje klucz w nagłówku{' '}
|
||||
<code className="rounded bg-white/70 px-1.5 py-0.5 text-xs">X-API-Key</code>.
|
||||
{' '}Możesz wydać wiele kluczy dla różnych odbiorców i wyłączać je niezależnie.
|
||||
{' '}Klucz jest pokazywany tylko raz przy utworzeniu i nie da się go później
|
||||
odczytać. Gdy zostanie zgubiony, użyj{' '}
|
||||
<span className="whitespace-nowrap">„wygeneruj ponownie”</span>
|
||||
{' '}(ikona odświeżania) — powstaje nowy klucz, a stary przestaje działać.
|
||||
</Alert>
|
||||
|
||||
{/* Surowy klucz po utworzeniu: pokazany jeden raz. */}
|
||||
{created && (
|
||||
<div className="rounded-lg border border-warn-500/25 bg-warn-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon name="key" className="mt-0.5 shrink-0 text-warn-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-warn-600">
|
||||
Nowy klucz dla „{created.apiKey.name}”
|
||||
</p>
|
||||
<p className="mt-1.5 break-all rounded bg-white px-2 py-1.5 font-mono text-sm text-slate-900">
|
||||
{created.key}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-warn-600">
|
||||
Klucz jest pokazany tylko raz. Skopiuj go teraz i przekaż odbiorcy bezpiecznym
|
||||
kanałem. Po zamknięciu nie da się go odczytać ponownie.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button size="sm" variant="secondary" icon="copy" onClick={copyKey}>
|
||||
Kopiuj klucz
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreated(null)}
|
||||
className="-m-1 rounded p-1 text-warn-600 transition duration-fast ease-standard hover:text-warn-500"
|
||||
aria-label="Zamknij"
|
||||
>
|
||||
<Icon name="x" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<Card title="Nowy klucz API">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Field
|
||||
label="Nazwa odbiorcy"
|
||||
error={errors.name}
|
||||
hint="Do czego lub dla kogo klucz. Np. „Strona firmowa”, „Integracja CRM”."
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="np. Strona firmowa"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreating(false)
|
||||
setErrors({})
|
||||
}}
|
||||
>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="key" disabled={create.isPending}>
|
||||
{create.isPending ? 'Generowanie...' : 'Wygeneruj klucz'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{query.data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="key"
|
||||
title="Brak kluczy API"
|
||||
description="Utwórz pierwszy klucz, aby udostępnić publiczne API zewnętrznemu odbiorcy."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
||||
{/* karty na telefonie, tabela od duzego ekranu */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{query.data.map((key) => (
|
||||
<li key={key.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{key.name}</p>
|
||||
<p className="hx-data truncate text-sm text-slate-500">{key.prefix}…</p>
|
||||
</div>
|
||||
{key.active ? (
|
||||
<Badge tone="ok" dot>Aktywny</Badge>
|
||||
) : (
|
||||
<Badge tone="alarm">Wyłączony</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Ostatnio użyty: {key.lastUsedAt ? formatLocal(key.lastUsedAt) : 'nigdy'}
|
||||
</p>
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
label="Wygeneruj ponownie"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||
)
|
||||
) {
|
||||
regenerate.mutate(key.id)
|
||||
}
|
||||
}}
|
||||
disabled={regenerate.isPending}
|
||||
/>
|
||||
<IconButton
|
||||
icon={key.active ? 'eye-off' : 'eye'}
|
||||
label={key.active ? 'Wyłącz' : 'Włącz'}
|
||||
onClick={() => setActive.mutate({ id: key.id, active: !key.active })}
|
||||
disabled={setActive.isPending}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label="Usuń"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||
remove.mutate(key.id)
|
||||
}
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">Nazwa</th>
|
||||
<th className="px-4 py-3 font-semibold">Klucz</th>
|
||||
<th className="px-4 py-3 font-semibold">Stan</th>
|
||||
<th className="px-4 py-3 font-semibold">Ostatnio użyty</th>
|
||||
<th className="px-4 py-3 font-semibold">Utworzony</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{query.data.map((key) => (
|
||||
<tr key={key.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 font-medium text-slate-900">{key.name}</td>
|
||||
<td className="hx-data px-4 py-3 text-slate-500">{key.prefix}…</td>
|
||||
<td className="px-4 py-3">
|
||||
{key.active ? (
|
||||
<Badge tone="ok" dot>Aktywny</Badge>
|
||||
) : (
|
||||
<Badge tone="alarm">Wyłączony</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
|
||||
{key.lastUsedAt ? formatLocal(key.lastUsedAt) : 'nigdy'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
|
||||
{formatLocal(key.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="refresh"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||
)
|
||||
) {
|
||||
regenerate.mutate(key.id)
|
||||
}
|
||||
}}
|
||||
disabled={regenerate.isPending}
|
||||
title="Wygeneruj ponownie"
|
||||
>
|
||||
<span className="sr-only">Wygeneruj ponownie</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={key.active ? 'eye-off' : 'eye'}
|
||||
onClick={() => setActive.mutate({ id: key.id, active: !key.active })}
|
||||
disabled={setActive.isPending}
|
||||
title={key.active ? 'Wyłącz' : 'Włącz'}
|
||||
>
|
||||
<span className="sr-only">{key.active ? 'Wyłącz' : 'Włącz'}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||
remove.mutate(key.id)
|
||||
}
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
title="Usuń"
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
<span className="sr-only">Usuń</span>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user