Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from '@mui/x-charts/LineChart'
import { client, errorMessage } from '../api/client'
import type { Dashboard } from '../api/types'
import { Icon } from '../components/Icon'
import { Card, ErrorState, Spinner } from '../components/ui'
import { formatWithZone } from '../lib/dates'
import { WEBINAR_STATUS_LABEL } from '../lib/labels'
import { panelPath } from '../lib/routes'
/**
* Ekran glowny panelu.
*
* Pierwsze, co widzi zalogowany uzytkownik, wiec pokazuje wylacznie rzeczy,
* na ktore mozna zareagowac: co sie zbliza i co czeka na obsluge. Nie ma tu
* liczb dla samych liczb.
*/
const HYDRO_AZURE = '#2C8ABD'
export function DashboardPage() {
const query = useQuery({
queryKey: ['dashboard'],
queryFn: async () => {
const response = await client.get<Dashboard>('/admin/dashboard')
return response.data
},
})
if (query.isPending) return <Spinner />
if (query.isError) {
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać podsumowania')} />
}
const data = query.data
const statuses = data.webinarsByStatus
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Podsumowanie</h1>
<p className="text-sm text-slate-500">Co się zbliża i co czeka na obsługę</p>
</div>
{/*
Nieobsluzone zapytania na samej gorze i wyroznione: to jedyna pozycja
na tym ekranie, ktora oznacza zalegla prace.
*/}
{data.pendingOffers > 0 && (
<Link
to={panelPath('/zapytania')}
className="flex items-center gap-4 rounded-lg border border-warn-500/30 bg-warn-50 p-4 transition-colors duration-fast ease-standard hover:bg-warn-50/70"
>
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-warn-500/15">
<Icon name="send" size={22} className="text-warn-600" />
</span>
<span className="min-w-0 flex-1">
<span className="hx-data block text-2xl font-semibold text-warn-600">
{data.pendingOffers}
</span>
<span className="block text-sm text-slate-700">
{data.pendingOffers === 1
? 'zapytanie ofertowe czeka na obsługę'
: 'zapytań ofertowych czeka na obsługę'}
</span>
</span>
<Icon name="chevron-right" size={20} className="shrink-0 text-warn-600" />
</Link>
)}
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Najbliższy webinar">
{data.upcoming ? (
<UpcomingCard upcoming={data.upcoming} />
) : (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
Nie zaplanowano żadnego webinaru.{' '}
<Link to={panelPath('/webinary/nowy')} className="font-medium text-blue-700 hover:underline">
Dodaj pierwszy
</Link>
.
</p>
)}
</Card>
<Card title="Webinary">
<dl className="divide-y divide-slate-100">
{(['PUBLISHED', 'DRAFT', 'FINISHED', 'CANCELLED'] as const).map((status) => (
<div key={status} className="flex items-center justify-between py-2">
<dt className="text-sm text-slate-600">{WEBINAR_STATUS_LABEL[status]}</dt>
<dd className="hx-data font-medium text-slate-900">{statuses[status] ?? 0}</dd>
</div>
))}
</dl>
<Link
to={panelPath('/webinary')}
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
>
<Icon name="presentation" size={16} />
Pełna lista
</Link>
</Card>
</div>
<Card title="Zapisy z ostatnich 30 dni" description="Liczba nowych zapisów dziennie">
{data.dailyRegistrations.every((d) => d.count === 0) ? (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
W ostatnich 30 dniach nikt się nie zapisał.
</p>
) : (
<div className="overflow-x-auto">
<LineChart
height={260}
series={[
{
data: data.dailyRegistrations.map((d) => d.count),
label: 'Zapisy',
color: HYDRO_AZURE,
area: true,
showMark: false,
},
]}
xAxis={[
{
scaleType: 'point',
data: data.dailyRegistrations.map((d) => formatDay(d.day)),
},
]}
yAxis={[{ min: 0 }]}
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
/>
</div>
)}
</Card>
</div>
)
}
/** Najblizszy webinar wraz z odliczaniem. */
function UpcomingCard({ upcoming }: { upcoming: NonNullable<Dashboard['upcoming']> }) {
const [countdown, setCountdown] = useState(() => describeCountdown(upcoming.startsAt))
useEffect(() => {
// odliczanie odswiezane co minute: sekundy przy odleglosci dni nic nie wnosza
const timer = window.setInterval(() => {
setCountdown(describeCountdown(upcoming.startsAt))
}, 60_000)
return () => window.clearInterval(timer)
}, [upcoming.startsAt])
return (
<div className="space-y-3">
<div>
<Link
to={panelPath(`/webinary/${upcoming.id}`)}
className="font-medium text-slate-900 hover:text-blue-700 hover:underline"
>
{upcoming.title}
</Link>
<p className="hx-data mt-0.5 text-xs text-slate-500">
{formatWithZone(upcoming.startsAt, upcoming.timezone)}
</p>
</div>
<div className="rounded-md bg-blue-50 p-3">
<p className="hx-eyebrow text-blue-700">Rozpocznie się za</p>
<p className="hx-data mt-0.5 text-xl font-semibold text-blue-700">{countdown}</p>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Icon name="users" size={16} className="text-slate-400" />
<span className="hx-data font-medium text-slate-900">{upcoming.registered}</span>
<span>
{upcoming.capacity !== null ? `z ${upcoming.capacity} miejsc` : 'zapisanych, bez limitu'}
</span>
</div>
</div>
)
}
/**
* Ile zostalo do startu, zwyklym zdaniem.
*
* Bez sekund: przy odleglosci liczonej w dniach sekundy sa szumem, a przy
* odleglosci minut i tak wystarcza dokladnosc do minuty.
*/
function describeCountdown(startsAt: string): string {
const diffMs = new Date(startsAt).getTime() - Date.now()
if (diffMs <= 0) return 'już trwa'
const minutes = Math.floor(diffMs / 60_000)
if (minutes < 60) {
return `${minutes} ${plural(minutes, 'minutę', 'minuty', 'minut')}`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
const rest = minutes % 60
const hourPart = `${hours} ${plural(hours, 'godzinę', 'godziny', 'godzin')}`
return rest > 0 ? `${hourPart} i ${rest} min` : hourPart
}
const days = Math.floor(hours / 24)
return `${days} ${plural(days, 'dzień', 'dni', 'dni')}`
}
/** Odmiana przez liczbę, wedlug reguly polskiej. */
function plural(count: number, one: string, few: string, many: string): string {
if (count === 1) return one
const lastTwo = count % 100
const last = count % 10
if (lastTwo >= 12 && lastTwo <= 14) return many
return last >= 2 && last <= 4 ? few : many
}
/** Dzien i miesiac, bez roku: wykres obejmuje 30 dni. */
function formatDay(iso: string): string {
try {
return new Intl.DateTimeFormat('pl-PL', { day: '2-digit', month: '2-digit' })
.format(new Date(iso))
} catch {
return iso
}
}
+57
View File
@@ -0,0 +1,57 @@
import { useQuery } from '@tanstack/react-query'
import { client } from '../api/client'
type Health = {
status: string
version: string
}
export function HealthPage() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['health'],
queryFn: async () => {
const response = await client.get<Health>('/health')
return response.data
},
})
return (
<main className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
<section className="w-full max-w-md rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-xl font-semibold text-slate-900">Platforma webinarowa</h1>
<p className="mt-1 text-sm text-slate-500">Stan połączenia z backendem</p>
<div className="mt-5">
{isPending && (
<p className="flex items-center gap-2 text-slate-600">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
Sprawdzanie...
</p>
)}
{isError && (
<p className="flex items-center gap-2 text-red-600">
<span className="material-symbols-outlined">error</span>
Backend niedostępny: {error instanceof Error ? error.message : 'nieznany błąd'}
</p>
)}
{data && (
<dl className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-green-600">check_circle</span>
<dt className="text-slate-500">Status:</dt>
<dd className="font-medium text-slate-900">{data.status}</dd>
</div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-slate-400">deployed_code</span>
<dt className="text-slate-500">Wersja:</dt>
<dd className="font-medium text-slate-900">{data.version}</dd>
</div>
</dl>
)}
</div>
</section>
</main>
)
}
+176
View File
@@ -0,0 +1,176 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { client, errorMessage } from '../api/client'
import type { UnifiedLoginResponse } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { useStudentAuth } from '../academy/StudentAuthContext'
import { Alert, Button, Field, Input, PasswordInput } from '../components/ui'
import { Icon } from '../components/Icon'
import { BrandMark } from '../components/BrandMark'
import { PANEL_HOME, STUDENT_ROUTES } from '../lib/routes'
import { safeInternalPath } from '../lib/safePath'
/**
* Jedno logowanie dla calej instancji.
*
* FRONT NIE ZGADUJE, DOKAD KIEROWAC.
* ---------------------------------------------------------------------------
* Formularz ma jedno pole adresu i jedno pole hasla. Po wyslaniu backend
* (`POST /auth/login/unified`) sprawdza haslo **osobno** w kontach panelu
* i w kontach kursantow, po czym odpowiada tym, co znalazl. Front czyta
* obecnosc pol `staff` i `student`, a nie domysla sie po adresie ani po tym,
* z jakiej strony uzytkownik przyszedl.
*
* Trafienie w oba swiaty naraz jest normalnym przypadkiem: administrator moze
* byc zapisany na webinar i miec to samo haslo. Pokazujemy wtedy wybor,
* zamiast decydowac za uzytkownika.
*
* POWROT DZIALA DLA OBU SWIATOW.
* ---------------------------------------------------------------------------
* `location.state.from` ustawia zarowno RequireAuth, jak i RequireStudent,
* a takze przycisk zapisu na kurs. Wczesniej obsluga powrotu byla wylacznie
* w formularzu panelu, wiec kursant, ktory kliknal zapis przed zalogowaniem,
* ladowal na pulpicie zamiast wrocic na strone kursu.
*/
export function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const { adoptSession: adoptStaff } = useAuth()
const { adoptSession: adoptStudent } = useStudentAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
/** Cel powrotu. Wartosc ze stanu trasy jest danymi od uzytkownika. */
const requested = safeInternalPath((location.state as { from?: string } | null)?.from, '')
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setPending(true)
try {
const { data } = await client.post<UnifiedLoginResponse>('/auth/login/unified', {
email,
password,
})
// kazdy token trafia do wlasnego kontekstu, niezaleznie od drugiego
if (data.staff) {
adoptStaff(data.staff.token, data.staff.user)
}
if (data.student) {
adoptStudent(data.student.token, data.student.student)
}
/*
* Konto w obu swiatach: pokazujemy wybor. Cel powrotu przekazujemy dalej,
* zeby wybor "Mój pulpit" po kliknieciu zapisu na kurs nadal wracal na
* strone kursu, a nie na pulpit.
*/
if (data.staff && data.student) {
navigate(STUDENT_ROUTES.choice, { replace: true, state: { from: requested } })
return
}
/*
* Jeden swiat: wracamy tam, skad uzytkownik przyszedl, a gdy przyszedl
* wprost na logowanie, na ekran startowy wlasciwy dla tego konta.
*/
const fallback = data.staff ? PANEL_HOME : STUDENT_ROUTES.dashboard
navigate(requested || fallback, { replace: true })
} catch (e) {
setError(errorMessage(e, 'Nie udało się zalogować'))
} finally {
setPending(false)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
<div className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center text-center">
<Link to="/" className="rounded-md" aria-label="Strona główna">
<BrandMark />
</Link>
<h1 className="mt-3 text-xl font-semibold text-slate-900">Zaloguj się</h1>
<p className="text-sm text-slate-500">
Jeden adres i hasło do szkoleń oraz do panelu
</p>
</div>
<form
onSubmit={handleSubmit}
noValidate
className="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"
>
{error && <Alert tone="alarm">{error}</Alert>}
<Field label="Adres e-mail">
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
required
autoFocus
/>
</Field>
<Field label="Hasło">
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</Field>
<Button
type="submit"
disabled={pending}
className="w-full"
icon={pending ? undefined : 'log-in'}
>
{pending ? 'Logowanie...' : 'Zaloguj się'}
</Button>
</form>
{/*
Rejestracja i przypomnienie hasla dotycza WYLACZNIE kont kursantow.
Mowimy to wprost, zeby nikt nie probowal zalozyc sobie tedy konta
administracyjnego: konta panelu zaklada administrator instancji.
*/}
<div className="mt-5 space-y-3 rounded-lg border border-slate-200 bg-white p-5 text-sm">
<p className="text-slate-600">
Nie masz konta?{' '}
<Link
to={STUDENT_ROUTES.register}
state={requested ? { from: requested } : undefined}
className="font-medium text-blue-600 hover:text-blue-700"
>
Załóż konto
</Link>
</p>
<p className="text-slate-600">
Nie pamiętasz hasła?{' '}
<Link
to={STUDENT_ROUTES.passwordReset}
className="font-medium text-blue-600 hover:text-blue-700"
>
Przypomnij hasło
</Link>
</p>
<p className="flex items-start gap-2 border-t border-slate-100 pt-3 text-xs leading-relaxed text-slate-500">
<Icon name="circle-alert" size={14} className="mt-0.5 shrink-0" />
<span>
Rejestracja i przypomnienie hasła dotyczą kont uczestników szkoleń i webinarów.
Konta do panelu administracyjnego zakłada administrator instancji.
</span>
</p>
</div>
</div>
</main>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { Link } from 'react-router-dom'
import { Button } from '../components/ui'
import { BrandMark } from '../components/BrandMark'
import { Icon } from '../components/Icon'
/**
* Nieznany adres.
*
* Wczesniej catch-all przekierowywal wszystko na "/", czyli na ekran logowania
* do panelu: gosc, ktory pomylil litere w adresie kursu, dostawal formularz
* administratora bez zadnego wyjasnienia. Strona 404 mowi wprost, co sie stalo,
* i daje droge powrotna.
*/
export function NotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-slate-50 p-6 text-center">
<Link to="/" className="rounded-md" aria-label="Strona główna">
<BrandMark />
</Link>
<p className="hx-data mt-8 text-sm font-semibold uppercase tracking-caps text-slate-400">
Błąd 404
</p>
<h1 className="mt-2 text-2xl font-semibold text-slate-900">Nie ma takiej strony</h1>
<p className="mt-3 max-w-md text-sm leading-relaxed text-slate-600">
Adres jest nieprawidłowy albo strona została usunięta. Sprawdź, czy link jest pełny,
albo wróć na stronę główną.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link to="/">
<Button icon="arrow-left">Strona główna</Button>
</Link>
</div>
<p className="mt-8 flex items-center gap-2 text-xs text-slate-500">
<Icon name="circle-alert" size={14} />
Jeśli trafiłeś tu z linku w wiadomości, sprawdź, czy nie został skrócony.
</p>
</main>
)
}
@@ -0,0 +1,397 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Banner, BannerPlacement } from '../../api/types'
import { ContentEditor } from '../../components/ContentEditor'
import { sanitizeHtmlPreview } from '../../lib/sanitizeHtmlPreview'
import { Icon } from '../../components/Icon'
import { MediaField } from '../../components/MediaField'
import { Modal } from '../../components/Modal'
import { useToasts } from '../../components/Toasts'
import { Button, Field, Input, Select } from '../../components/ui'
/**
* Formularz banera.
*
* DWA WARIANTY TRESCI, JEDEN NA RAZ
* ------------------------------------------------------------------------
* Baner ma grafike **albo** tresc tekstowa. Pozwolenie na oba naraz wymagaloby
* decyzji, co wygrywa przy wyswietlaniu, a ta decyzja i tak bylaby arbitralna.
* Przelacznik jest wiec jawny, zamiast zostawiac uzytkownika z dwoma polami
* i domyslem.
*
* DWA WARIANTY OKNA CZASOWEGO, OBA OPCJONALNE
* ------------------------------------------------------------------------
* "Od kiedy do kiedy kampania trwa" i "w ktorej minucie prezentacji" to dwa
* rozne pytania. Pierwsze ma sens przy banerze globalnym, drugie przy banerze
* webinaru. Oba puste oznaczaja baner zawsze widoczny.
*/
type ContentMode = 'media' | 'html'
const PLACEMENTS: { value: BannerPlacement; label: string; hint: string }[] = [
{ value: 'SIDE', label: 'Panel boczny', hint: 'Nad zakładkami, na wąskim ekranie pod wideo' },
{ value: 'BELOW_VIDEO', label: 'Pas pod wideo', hint: 'Na całej szerokości obrazu' },
{ value: 'OVERLAY', label: 'Warstwa na obrazie', hint: 'W rogu wideo, uczestnik może ją zamknąć' },
]
export function BannerFormDialog({
banner,
webinarId,
offerTopics,
onClose,
}: {
/** Null oznacza nowy baner. */
banner: Banner | null
/** Null oznacza baner globalny. */
webinarId: number | null
/** Tematy zapytan tego webinaru. Puste przy banerze globalnym. */
offerTopics: { id: number; name: string }[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const isEdit = Boolean(banner)
const [title, setTitle] = useState(banner?.title ?? '')
const [mode, setMode] = useState<ContentMode>(banner?.contentHtml ? 'html' : 'media')
const [mediaId, setMediaId] = useState(banner?.mediaId ? String(banner.mediaId) : '')
const [contentHtml, setContentHtml] = useState(banner?.contentHtml ?? '')
const [targetUrl, setTargetUrl] = useState(banner?.targetUrl ?? '')
const [offerTopicId, setOfferTopicId] = useState(
banner?.offerTopicId ? String(banner.offerTopicId) : '',
)
const [placement, setPlacement] = useState<BannerPlacement>(banner?.placement ?? 'SIDE')
const [startsAt, setStartsAt] = useState(toInput(banner?.startsAt))
const [endsAt, setEndsAt] = useState(toInput(banner?.endsAt))
const [showFrom, setShowFrom] = useState(banner?.showFromMinute?.toString() ?? '')
const [showTo, setShowTo] = useState(banner?.showToMinute?.toString() ?? '')
const [sortOrder, setSortOrder] = useState(banner?.sortOrder?.toString() ?? '0')
const [active, setActive] = useState(banner?.active ?? true)
const [error, setError] = useState<string | null>(null)
const save = useMutation({
mutationFn: async () => {
const payload = {
title,
// wysylamy tylko ten wariant tresci, ktory jest wybrany: inaczej
// przelaczenie na grafike zostawialoby w bazie stara tresc tekstowa
mediaId: mode === 'media' && mediaId ? Number(mediaId) : null,
contentHtml: mode === 'html' ? contentHtml : null,
targetUrl: targetUrl.trim() || null,
offerTopicId: offerTopicId ? Number(offerTopicId) : null,
placement,
startsAt: startsAt ? new Date(startsAt).toISOString() : null,
endsAt: endsAt ? new Date(endsAt).toISOString() : null,
showFromMinute: showFrom === '' ? null : Number(showFrom),
showToMinute: showTo === '' ? null : Number(showTo),
sortOrder: Number(sortOrder) || 0,
active,
}
if (isEdit) {
return client.put(`/admin/banners/${banner!.id}`, payload)
}
return webinarId
? client.post(`/admin/webinars/${webinarId}/banners`, payload)
: client.post('/admin/banners', payload)
},
onSuccess: () => {
toasts.success(isEdit ? 'Baner zapisany' : 'Baner został dodany')
queryClient.invalidateQueries({ queryKey: ['banners'] })
onClose()
},
onError: (e) => setError(errorMessage(e, 'Nie udało się zapisać banera')),
})
const remove = useMutation({
mutationFn: () => client.delete(`/admin/banners/${banner!.id}`),
onSuccess: () => {
toasts.success('Baner został usunięty')
queryClient.invalidateQueries({ queryKey: ['banners'] })
onClose()
},
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się usunąć banera')),
})
return (
<Modal
title={isEdit ? 'Edycja banera' : 'Nowy baner'}
description={
webinarId
? 'Baner pokazywany na tym webinarze.'
: 'Baner globalny, pokazywany na każdym webinarze.'
}
onClose={onClose}
wide
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
setError(null)
save.mutate()
}}
>
{error && (
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600">
{error}
</p>
)}
<Field label="Tytuł" hint="Widoczny w panelu i jako opis grafiki dla czytników ekranu">
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</Field>
{/* wybor wariantu tresci jest jawny, zamiast zostawiac dwa pola i domysl */}
<div>
<span className="mb-1.5 block text-sm font-semibold text-slate-800">Treść banera</span>
<div className="mb-3 flex gap-2">
{(
[
['media', 'Grafika', 'image'],
['html', 'Tekst', 'file-text'],
] as const
).map(([value, label, icon]) => (
<button
key={value}
type="button"
onClick={() => setMode(value)}
className={`inline-flex min-h-[44px] items-center gap-2 rounded-md border px-3 text-sm transition-colors duration-fast ease-standard ${
mode === value
? 'border-blue-500 bg-blue-50 font-medium text-blue-700'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
<Icon name={icon} size={16} />
{label}
</button>
))}
</div>
{mode === 'media' ? (
<MediaField value={mediaId} onChange={setMediaId} />
) : (
<ContentEditor value={contentHtml} onChange={setContentHtml} />
)}
</div>
<BannerPreview
placement={placement}
mediaId={mediaId}
contentHtml={mode === 'html' ? contentHtml : null}
mode={mode}
/>
<Field label="Miejsce wyświetlenia">
<Select
value={placement}
onChange={(e) => setPlacement(e.target.value as BannerPlacement)}
>
{PLACEMENTS.map((item) => (
<option key={item.value} value={item.value}>
{item.label} - {item.hint}
</option>
))}
</Select>
</Field>
{/*
Adres albo temat oferty, nie oba: baner nie moze jednoczesnie
wychodzic na obcy serwis i otwierac formularza u nas.
*/}
<Field
label="Adres docelowy"
hint="Dozwolone wyłącznie http:// i https://. Zostaw puste, jeśli baner ma otwierać zapytanie ofertowe."
>
<Input
type="url"
value={targetUrl}
onChange={(e) => setTargetUrl(e.target.value)}
placeholder="https://przyklad.pl/oferta"
disabled={Boolean(offerTopicId)}
/>
</Field>
{webinarId && offerTopics.length > 0 && (
<Field
label="Albo: otwórz zapytanie ofertowe"
hint="Kliknięcie otworzy formularz z tym tematem, bez opuszczania sali"
>
<Select
value={offerTopicId}
onChange={(e) => {
setOfferTopicId(e.target.value)
if (e.target.value) setTargetUrl('')
}}
>
<option value="">Nie, baner prowadzi pod adres</option>
{offerTopics.map((topic) => (
<option key={topic.id} value={topic.id}>
{topic.name}
</option>
))}
</Select>
</Field>
)}
<div className="rounded-md border border-slate-200 bg-slate-25 p-4">
<p className="mb-3 text-sm font-semibold text-slate-800">Kiedy pokazywać</p>
<p className="mb-3 text-xs leading-relaxed text-slate-500">
Puste pola oznaczają baner widoczny zawsze. Oba warunki obowiązują jednocześnie,
gdy ustawisz oba.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Od (data i godzina)">
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
/>
</Field>
<Field label="Do (data i godzina)">
<Input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
/>
</Field>
</div>
<div className="mt-2 grid gap-4 sm:grid-cols-2">
<Field label="Od minuty webinaru" hint="Np. 40 oznacza 40 minut po starcie">
<Input
type="number"
min={0}
value={showFrom}
onChange={(e) => setShowFrom(e.target.value)}
placeholder="od początku"
/>
</Field>
<Field label="Do minuty webinaru">
<Input
type="number"
min={0}
value={showTo}
onChange={(e) => setShowTo(e.target.value)}
placeholder="do końca"
/>
</Field>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Kolejność" hint="Mniejsza liczba wyświetla się wcześniej w rotacji">
<Input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
/>
</Field>
<label className="flex cursor-pointer items-center gap-2 self-end pb-2.5">
<input
type="checkbox"
checked={active}
onChange={(e) => setActive(e.target.checked)}
className="h-4 w-4 accent-blue-500"
/>
<span className="text-sm text-slate-700">Baner aktywny</span>
</label>
</div>
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
{isEdit && (
<Button
type="button"
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
if (
window.confirm(
`Usunąć baner "${banner!.title}"?\n\n` +
`Przepadnie także jego statystyka: ${banner!.impressions} wyświetleń ` +
`i ${banner!.clicks} kliknięć. Tej operacji nie da się cofnąć.`,
)
) {
remove.mutate()
}
}}
>
Usuń
</Button>
)}
<Button type="button" variant="secondary" onClick={onClose}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
</form>
</Modal>
)
}
/**
* Podglad w proporcji miejsca docelowego.
*
* Bez tego administrator dowiaduje sie, ze baner jest za wysoki albo urwany,
* dopiero na zywym webinarze, gdy widzi go juz cala sala.
*/
function BannerPreview({
placement,
mediaId,
contentHtml,
mode,
}: {
placement: BannerPlacement
mediaId: string
contentHtml: string | null
mode: ContentMode
}) {
const hasContent = mode === 'media' ? Boolean(mediaId) : Boolean(contentHtml?.trim())
if (!hasContent) return null
// proporcje odpowiadaja faktycznym miejscom w sali
const frame =
placement === 'BELOW_VIDEO'
? 'w-full max-w-2xl'
: placement === 'OVERLAY'
? 'w-56'
: 'w-80'
return (
<div>
<p className="mb-1.5 text-sm font-semibold text-slate-800">
Podgląd w proporcji, w jakiej pojawi się w sali
</p>
<div className={`overflow-hidden rounded-lg border border-slate-200 bg-white ${frame}`}>
{mode === 'media' ? (
<img
src={`/api/public/media/${mediaId}`}
alt="Podgląd banera"
className="block w-full object-contain"
/>
) : (
<div
className="prose prose-sm max-w-none p-3"
// podglad renderuje stan edytora sprzed zapisu, wiec czyszczenie
// robimy tu; zapisana tresc i tak przechodzi przez HtmlSanitizer
dangerouslySetInnerHTML={{ __html: sanitizeHtmlPreview(contentHtml) }}
/>
)}
</div>
</div>
)
}
/** ISO na wartosc pola datetime-local, ktore nie zna stref. */
function toInput(iso: string | null | undefined): string {
if (!iso) return ''
const date = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
@@ -0,0 +1,260 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Banner } from '../../api/types'
import { Icon } from '../../components/Icon'
import { Badge, Button, EmptyState, ErrorState, Spinner } from '../../components/ui'
import { BannerFormDialog } from './BannerFormDialog'
/**
* Lista banerow wraz ze skutecznoscia.
*
* TEN SAM KOMPONENT DLA BANEROW GLOBALNYCH I BANEROW WEBINARU
* ------------------------------------------------------------------------
* Parametryzowany webinarem, dokladnie tak jak `OfferSection`: obsluga banera
* wyglada tak samo niezaleznie od drogi dojscia, a poprawka nie wymaga zmiany
* w dwoch plikach. Przy banerze globalnym znika wybor tematu oferty, bo tematy
* naleza do konkretnego webinaru.
*
* KLIKALNOSC LICZY BACKEND
* ------------------------------------------------------------------------
* Ten sam wzor ma obowiazywac tutaj i w statystykach webinaru, a dwa miejsca
* liczenia to dwie okazje do rozjechania sie wynikow.
*/
const PLACEMENT_LABEL: Record<Banner['placement'], string> = {
SIDE: 'Panel boczny',
BELOW_VIDEO: 'Pod wideo',
OVERLAY: 'Na obrazie',
}
export function BannerSection({ webinarId }: { webinarId?: number | null }) {
const [editing, setEditing] = useState<Banner | null>(null)
const [creating, setCreating] = useState(false)
const banners = useQuery({
queryKey: ['banners', webinarId ?? 'global'],
queryFn: async () => {
const response = await client.get<Banner[]>(
webinarId ? `/admin/webinars/${webinarId}/banners` : '/admin/banners',
)
return response.data
},
})
// tematy potrzebne wylacznie przy banerze webinaru
const topics = useQuery({
queryKey: ['offer-topics', webinarId],
enabled: Boolean(webinarId),
queryFn: async () => {
const response = await client.get<{ id: number; name: string }[]>(
`/admin/webinars/${webinarId}/offer-topics`,
)
return response.data
},
})
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-slate-900">
{webinarId ? 'Banery tego webinaru' : 'Banery globalne'}
</h2>
<p className="text-sm text-slate-500">
{webinarId
? 'Pokazywane w sali obok banerów globalnych.'
: 'Pokazywane w sali każdego webinaru tej instancji.'}
</p>
</div>
<Button icon="plus" onClick={() => setCreating(true)}>
Nowy baner
</Button>
</div>
{banners.isPending && <Spinner />}
{banners.isError && (
<ErrorState message={errorMessage(banners.error, 'Nie udało się wczytać banerów')} />
)}
{banners.data && banners.data.length === 0 && (
<EmptyState
icon="image"
title="Brak banerów"
description={
webinarId
? 'Ten webinar nie ma własnych banerów. Uczestnicy widzą tylko banery globalne.'
: 'Nie ma jeszcze banerów globalnych.'
}
/>
)}
{banners.data && banners.data.length > 0 && (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
{/* karty do lg, tabela od lg: zadnych tabel z min-w */}
<ul className="divide-y divide-slate-100 lg:hidden">
{banners.data.map((banner) => (
<li key={banner.id} className="space-y-2 p-4">
<BannerRow banner={banner} onEdit={() => setEditing(banner)} />
<BannerNumbers banner={banner} />
</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">Baner</th>
<th className="px-4 py-3 font-semibold">Miejsce</th>
<th className="px-4 py-3 font-semibold">Kiedy</th>
<th className="px-4 py-3 text-right font-semibold">Wyświetlenia</th>
<th className="px-4 py-3 text-right font-semibold">Kliknięcia</th>
<th className="px-4 py-3 text-right font-semibold">Klikalność</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{banners.data.map((banner) => (
<tr key={banner.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<BannerRow banner={banner} onEdit={() => setEditing(banner)} compact />
</td>
<td className="px-4 py-3 text-slate-600">
{PLACEMENT_LABEL[banner.placement]}
</td>
<td className="px-4 py-3 text-xs text-slate-500">{describeWindow(banner)}</td>
<td className="hx-data px-4 py-3 text-right text-slate-700">
{banner.impressions}
</td>
<td className="hx-data px-4 py-3 text-right text-slate-700">{banner.clicks}</td>
<td className="hx-data px-4 py-3 text-right font-medium text-slate-900">
{banner.clickRate.toFixed(1)}%
</td>
<td className="px-4 py-3 text-right">
<Button
type="button"
size="sm"
variant="ghost"
icon="pencil"
onClick={() => setEditing(banner)}
>
Edytuj
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{(creating || editing) && (
<BannerFormDialog
banner={editing}
webinarId={webinarId ?? null}
offerTopics={topics.data ?? []}
onClose={() => {
setCreating(false)
setEditing(null)
}}
/>
)}
</div>
)
}
function BannerRow({
banner,
onEdit,
compact = false,
}: {
banner: Banner
onEdit: () => void
compact?: boolean
}) {
return (
<div className="flex items-center gap-3">
{banner.mediaUrl ? (
<img
src={banner.mediaUrl}
alt=""
className="h-10 w-16 shrink-0 rounded border border-slate-200 object-contain"
/>
) : (
<div className="flex h-10 w-16 shrink-0 items-center justify-center rounded border border-slate-200 bg-slate-25 text-slate-400">
<Icon name="file-text" size={16} />
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-slate-900">{banner.title}</p>
<div className="flex flex-wrap items-center gap-1.5">
{!banner.active && <Badge tone="neutral">wyłączony</Badge>}
{banner.offerTopicName && (
<span className="truncate text-xs text-blue-700">
zapytanie: {banner.offerTopicName}
</span>
)}
{!banner.offerTopicName && banner.targetUrl && (
<span className="hx-data truncate text-xs text-slate-400">{banner.targetUrl}</span>
)}
</div>
</div>
{!compact && (
<Button type="button" size="sm" variant="ghost" icon="pencil" onClick={onEdit}>
Edytuj
</Button>
)}
</div>
)
}
/** Liczby na karcie mobilnej: tabela od lg pokazuje je w kolumnach. */
function BannerNumbers({ banner }: { banner: Banner }) {
return (
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
<span>
Wyświetlenia: <strong className="hx-data text-slate-700">{banner.impressions}</strong>
</span>
<span>
Kliknięcia: <strong className="hx-data text-slate-700">{banner.clicks}</strong>
</span>
<span>
Klikalność:{' '}
<strong className="hx-data text-slate-900">{banner.clickRate.toFixed(1)}%</strong>
</span>
<span>{describeWindow(banner)}</span>
</div>
)
}
/**
* Okno wyswietlania zwyklym zdaniem.
*
* Administrator nie ma czytac dwoch par dat i sam skladac z nich reguly:
* to jest dokladnie ten format techniczny, ktorego w panelu nie ma byc.
*/
function describeWindow(banner: Banner): string {
const parts: string[] = []
if (banner.startsAt || banner.endsAt) {
const from = banner.startsAt ? new Date(banner.startsAt).toLocaleDateString('pl-PL') : null
const to = banner.endsAt ? new Date(banner.endsAt).toLocaleDateString('pl-PL') : null
if (from && to) parts.push(`${from} - ${to}`)
else if (from) parts.push(`od ${from}`)
else if (to) parts.push(`do ${to}`)
}
if (banner.showFromMinute !== null || banner.showToMinute !== null) {
const from = banner.showFromMinute
const to = banner.showToMinute
if (from !== null && to !== null) parts.push(`min. ${from}-${to} webinaru`)
else if (from !== null) parts.push(`od ${from}. minuty`)
else if (to !== null) parts.push(`do ${to}. minuty`)
}
return parts.length === 0 ? 'zawsze' : parts.join(', ')
}
@@ -0,0 +1,28 @@
import { BannerSection } from './BannerSection'
/**
* Dzial banerow globalnych.
*
* Ekran zbiorczy istnieje **obok** zakladki na karcie webinaru, a nie zamiast
* niej: banery instancji ustawia sie raz i dotycza wszystkich wydarzen, wiec
* szukanie ich w konkretnym webinarze nie mialoby sensu. Ta sama zasada co przy
* zapytaniach ofertowych, gdzie `/zapytania` zostaje dla handlowca.
*
* Oba miejsca pokazuja **ten sam komponent**, parametryzowany webinarem:
* obsluga banera wyglada tak samo niezaleznie od drogi dojscia, a poprawka nie
* wymaga zmiany w dwoch plikach.
*/
export function BannersPage() {
return (
<div className="space-y-4">
<div>
<h1 className="text-xl">Banery</h1>
<p className="text-sm text-slate-500">
Reklamy i komunikaty pokazywane uczestnikom w sali webinarowej
</p>
</div>
<BannerSection webinarId={null} />
</div>
)
}
@@ -0,0 +1,235 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Contact, ContactGroup, ContactRequest } from '../../api/types'
import { useToasts } from '../../components/Toasts'
import { Button, Field, Input, Textarea } from '../../components/ui'
import { Modal } from '../../components/Modal'
/** Dodanie albo edycja kontaktu. */
export function ContactFormDialog({
contact,
groups,
onClose,
}: {
contact: Contact | null
groups: ContactGroup[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const isEdit = contact !== null
const [form, setForm] = useState({
email: contact?.email ?? '',
firstName: contact?.firstName ?? '',
lastName: contact?.lastName ?? '',
phone: contact?.phone ?? '',
company: contact?.company ?? '',
position: contact?.position ?? '',
notes: contact?.notes ?? '',
active: contact?.active ?? true,
marketingConsent: contact?.marketingConsent ?? false,
groupIds: contact?.groupIds ?? [],
})
const [errors, setErrors] = useState<Record<string, string>>({})
const save = useMutation({
mutationFn: async (payload: ContactRequest) => {
const response = isEdit
? await client.put<Contact>(`/admin/contacts/${contact.id}`, payload)
: await client.post<Contact>('/admin/contacts', payload)
return response.data
},
onSuccess: () => {
toasts.success(isEdit ? 'Kontakt został zapisany' : 'Kontakt został dodany')
queryClient.invalidateQueries({ queryKey: ['contacts'] })
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
onClose()
},
onError: (error) => {
setErrors(fieldErrors(error))
toasts.error(errorMessage(error, 'Nie udało się zapisać kontaktu'))
},
})
const remove = useMutation({
mutationFn: () => client.delete(`/admin/contacts/${contact!.id}`),
onSuccess: () => {
toasts.success('Kontakt został usunięty')
queryClient.invalidateQueries({ queryKey: ['contacts'] })
onClose()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć kontaktu')),
})
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
setForm((current) => ({ ...current, [key]: value }))
}
function toggleGroup(groupId: number) {
update(
'groupIds',
form.groupIds.includes(groupId)
? form.groupIds.filter((x) => x !== groupId)
: [...form.groupIds, groupId],
)
}
return (
<Modal
title={isEdit ? 'Edycja kontaktu' : 'Nowy kontakt'}
onClose={onClose}
description={
contact?.unsubscribed ? 'Ten kontakt wypisał się z wiadomości marketingowych.' : undefined
}
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
save.mutate({
email: form.email.trim(),
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
phone: form.phone.trim() || null,
company: form.company.trim() || null,
position: form.position.trim() || null,
notes: form.notes.trim() || null,
active: form.active,
marketingConsent: form.marketingConsent,
groupIds: form.groupIds,
})
}}
>
<Field label="Adres e-mail" error={errors.email}>
<Input
type="email"
required
value={form.email}
onChange={(e) => update('email', e.target.value)}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Imię" error={errors.firstName}>
<Input
required
value={form.firstName}
onChange={(e) => update('firstName', e.target.value)}
/>
</Field>
<Field label="Nazwisko" error={errors.lastName}>
<Input
required
value={form.lastName}
onChange={(e) => update('lastName', e.target.value)}
/>
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Telefon" error={errors.phone}>
<Input value={form.phone} onChange={(e) => update('phone', e.target.value)} />
</Field>
<Field label="Firma" error={errors.company}>
<Input value={form.company} onChange={(e) => update('company', e.target.value)} />
</Field>
</div>
<Field label="Stanowisko" error={errors.position}>
<Input value={form.position} onChange={(e) => update('position', e.target.value)} />
</Field>
<Field label="Notatki" hint="Widoczne tylko w panelu">
<Textarea rows={2} value={form.notes} onChange={(e) => update('notes', e.target.value)} />
</Field>
{groups.length > 0 && (
<Field label="Grupy">
<div className="flex flex-wrap gap-2">
{groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => toggleGroup(group.id)}
className={`min-h-[36px] rounded-full px-3 text-sm transition-colors duration-fast ease-standard ${
form.groupIds.includes(group.id)
? 'bg-blue-50 font-medium text-blue-700 ring-1 ring-inset ring-blue-200'
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
}`}
>
{group.name}
</button>
))}
</div>
</Field>
)}
<div className="space-y-2 rounded-md border border-slate-200 p-3">
<label className="flex items-start gap-2.5">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-slate-300"
checked={form.marketingConsent}
onChange={(e) => update('marketingConsent', e.target.checked)}
/>
<span className="text-sm text-slate-700">
Zgoda na informacje handlowe drogą elektroniczną
<span className="mt-0.5 block text-xs text-slate-500">
Bez niej kontakt nie dostanie zaproszenia. Zmiana zapisuje nowy wpis w historii
zgód, poprzedni zostaje jako ślad.
</span>
</span>
</label>
<label className="flex items-start gap-2.5">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-slate-300"
checked={form.active}
onChange={(e) => update('active', e.target.checked)}
/>
<span className="text-sm text-slate-700">
Aktywny
<span className="mt-0.5 block text-xs text-slate-500">
Nieaktywny kontakt zostaje w bazie, ale nie trafia do nowych zaproszeń.
</span>
</span>
</label>
</div>
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
{isEdit && (
<Button
type="button"
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
if (
confirm(
`Usunąć kontakt ${contact!.email} wraz z historią zgód?
` +
'Tej operacji nie da się cofnąć.',
)
) {
remove.mutate()
}
}}
>
Usuń
</Button>
)}
<Button type="button" variant="secondary" onClick={onClose}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
</form>
</Modal>
)
}
@@ -0,0 +1,321 @@
import { useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { ContactGroup, ImportMapping, ImportPreview, ImportResult } from '../../api/types'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Alert, Button, Select, Spinner } from '../../components/ui'
import { Modal } from '../../components/Modal'
/**
* Import kontaktow z pliku CSV.
*
* TRZY KROKI, NIE JEDEN
* ------------------------------------------------------------------------
* Wybor pliku, potem mapowanie kolumn z podgladem, dopiero na koncu import.
*
* Podglad jest tu najwazniejszy: pomylka w kolejnosci kolumn oznaczalaby setki
* kontaktow z nazwiskiem w polu firmy, a odkrecanie tego recznie kosztuje
* wielokrotnie wiecej niz jedno spojrzenie na piec przykladowych wierszy.
*/
/** Pola, ktore da sie zmapowac. Kolejnosc jak w formularzu kontaktu. */
const FIELDS: { key: keyof ImportMapping; label: string; required: boolean }[] = [
{ key: 'email', label: 'Adres e-mail', required: true },
{ key: 'firstName', label: 'Imię', required: false },
{ key: 'lastName', label: 'Nazwisko', required: false },
{ key: 'phone', label: 'Telefon', required: false },
{ key: 'company', label: 'Firma', required: false },
{ key: 'position', label: 'Stanowisko', required: false },
]
export function ContactImportDialog({
groups,
onClose,
}: {
groups: ContactGroup[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const fileInput = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [mapping, setMapping] = useState<ImportMapping | null>(null)
const [groupId, setGroupId] = useState('')
const [grantConsent, setGrantConsent] = useState(false)
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<ImportResult | null>(null)
async function handleFile(selected: File) {
setFile(selected)
setBusy(true)
try {
const form = new FormData()
form.append('file', selected)
const response = await client.post<ImportPreview>('/admin/contacts/import/preview', form)
setPreview(response.data)
setMapping(response.data.suggested)
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się odczytać pliku'))
setFile(null)
} finally {
setBusy(false)
}
}
async function handleImport() {
if (!file || !mapping) return
setBusy(true)
try {
const form = new FormData()
form.append('file', file)
const response = await client.post<ImportResult>('/admin/contacts/import', form, {
params: {
emailColumn: mapping.email,
firstNameColumn: mapping.firstName,
lastNameColumn: mapping.lastName,
phoneColumn: mapping.phone,
companyColumn: mapping.company,
positionColumn: mapping.position,
groupId: groupId || undefined,
grantConsent,
},
timeout: 0,
})
setResult(response.data)
queryClient.invalidateQueries({ queryKey: ['contacts'] })
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zaimportować pliku'))
} finally {
setBusy(false)
}
}
// --- raport po imporcie ---
if (result) {
return (
<Modal title="Import zakończony" onClose={onClose} wide>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<Summary label="Dodane" value={result.added} tone="ok" />
<Summary label="Zaktualizowane" value={result.updated} tone="info" />
<Summary label="Pominięte" value={result.skipped.length} tone="warn" />
</div>
{result.skipped.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-800">
Wiersze pominięte wraz z powodem
</h3>
<div className="max-h-64 overflow-y-auto rounded-md border border-slate-200">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
<th className="px-3 py-2">Wiersz</th>
<th className="px-3 py-2">Adres</th>
<th className="px-3 py-2">Powód</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{result.skipped.map((row) => (
<tr key={`${row.line}-${row.email}`}>
<td className="hx-data px-3 py-2 text-slate-500">{row.line}</td>
<td className="hx-data break-all px-3 py-2">{row.email || '-'}</td>
<td className="px-3 py-2 text-slate-600">{row.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end border-t border-slate-100 pt-4">
<Button onClick={onClose}>Zamknij</Button>
</div>
</div>
</Modal>
)
}
// --- wybor pliku i mapowanie ---
return (
<Modal
title="Import kontaktów z pliku CSV"
description="Duplikat po adresie aktualizuje istniejący kontakt, nie tworzy drugiego"
onClose={onClose}
wide
>
<div className="space-y-4">
<input
ref={fileInput}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const selected = e.target.files?.[0]
if (selected) handleFile(selected)
}}
/>
{!preview && (
<div className="rounded-md border border-dashed border-slate-300 p-8 text-center">
<Icon name="upload" size={28} className="mx-auto text-slate-400" />
<p className="mt-2 text-sm text-slate-600">
Wybierz plik CSV z listą kontaktów.
</p>
<p className="mt-1 text-xs text-slate-500">
Separatorem może być średnik albo przecinek. Pierwszy wiersz to nazwy kolumn.
</p>
<div className="mt-4">
<Button icon="upload" disabled={busy} onClick={() => fileInput.current?.click()}>
{busy ? 'Wczytywanie...' : 'Wybierz plik'}
</Button>
</div>
</div>
)}
{busy && preview && <Spinner />}
{preview && mapping && (
<>
<p className="text-sm text-slate-600">
Plik zawiera <span className="hx-data font-semibold">{preview.totalRows}</span>{' '}
wierszy z danymi. Sprawdź, czy kolumny zostały rozpoznane poprawnie.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key}>
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
{field.label}
{field.required && <span className="text-alarm-600"> *</span>}
</label>
<Select
value={String(mapping[field.key])}
onChange={(e) =>
setMapping({ ...mapping, [field.key]: Number(e.target.value) })
}
>
<option value="-1">Nie importuj</option>
{preview.header.map((name, index) => (
<option key={index} value={index}>
{name || `Kolumna ${index + 1}`}
</option>
))}
</Select>
</div>
))}
</div>
{mapping.email < 0 && (
<Alert tone="warn">
Wskaż kolumnę z adresem e-mail. Bez niej nie da się rozpoznać kontaktów.
</Alert>
)}
{/* podglad: tak zostana zrozumiane pierwsze wiersze */}
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-800">
Podgląd pierwszych wierszy
</h3>
<div className="overflow-x-auto rounded-md border border-slate-200">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
{FIELDS.filter((f) => mapping[f.key] >= 0).map((f) => (
<th key={f.key} className="whitespace-nowrap px-3 py-2">
{f.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{preview.sample.map((row, index) => (
<tr key={index}>
{FIELDS.filter((f) => mapping[f.key] >= 0).map((f) => (
<td key={f.key} className="whitespace-nowrap px-3 py-2 text-slate-700">
{row[mapping[f.key]] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="space-y-3 rounded-md border border-slate-200 p-3">
<div className="w-full sm:w-64">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
Dodaj wszystkich do grupy
</label>
<Select value={groupId} onChange={(e) => setGroupId(e.target.value)}>
<option value="">Bez grupy</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
</div>
<label className="flex items-start gap-2.5">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-slate-300"
checked={grantConsent}
onChange={(e) => setGrantConsent(e.target.checked)}
/>
<span className="text-sm text-slate-700">
Zapisz zgodę marketingową dla importowanych kontaktów
<span className="mt-0.5 block text-xs text-slate-500">
Zaznacz tylko wtedy, gdy masz podstawę prawną: zgodę zebraną wcześniej albo
inną przesłankę. Osoby, które wcześniej się wypisały, i tak jej nie dostaną.
</span>
</span>
</label>
</div>
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
<Button type="button" variant="secondary" onClick={onClose}>
Anuluj
</Button>
<Button
icon="upload"
disabled={busy || mapping.email < 0}
onClick={handleImport}
>
{busy ? 'Importowanie...' : `Importuj ${preview.totalRows} wierszy`}
</Button>
</div>
</>
)}
</div>
</Modal>
)
}
function Summary({
label,
value,
tone,
}: {
label: string
value: number
tone: 'ok' | 'info' | 'warn'
}) {
const toneClass =
tone === 'ok' ? 'text-ok-600' : tone === 'warn' ? 'text-warn-600' : 'text-blue-700'
return (
<div className="rounded-md border border-slate-200 p-3 text-center">
<p className={`hx-data text-2xl font-semibold ${toneClass}`}>{value}</p>
<p className="hx-eyebrow mt-0.5 text-slate-500">{label}</p>
</div>
)
}
@@ -0,0 +1,384 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Contact, ContactGroup, Page } from '../../api/types'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Input,
Select,
Spinner,
} from '../../components/ui'
import { ContactFormDialog } from './ContactFormDialog'
import { ContactImportDialog } from './ContactImportDialog'
import { GroupsDialog } from './GroupsDialog'
/**
* Baza kontaktow.
*
* Kontakt jest niezalezny od pojedynczego webinaru: dostaje zaproszenia na
* kolejne wydarzenia, ma wlasne zgody i wlasne wypisanie. Dlatego to osobny
* dzial panelu, a nie zakladka webinaru.
*/
const SOURCE_LABEL: Record<string, string> = {
MANUAL: 'Dodany ręcznie',
IMPORT: 'Z importu',
REGISTRATION: 'Z zapisu na webinar',
}
export function ContactListPage() {
const queryClient = useQueryClient()
const toasts = useToasts()
const [search, setSearch] = useState('')
const [groupId, setGroupId] = useState('')
const [consent, setConsent] = useState('')
const [page, setPage] = useState(0)
const [editing, setEditing] = useState<Contact | null>(null)
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const [managingGroups, setManagingGroups] = useState(false)
/** Zaznaczone kontakty, do masowego przypisania do grupy. */
const [selected, setSelected] = useState<number[]>([])
const [exporting, setExporting] = useState(false)
const groups = useQuery({
queryKey: ['contact-groups'],
queryFn: async () => {
const response = await client.get<ContactGroup[]>('/admin/contacts/groups')
return response.data
},
})
const contacts = useQuery({
queryKey: ['contacts', search, groupId, consent, page],
queryFn: async () => {
const response = await client.get<Page<Contact>>('/admin/contacts', {
params: {
search: search || undefined,
groupId: groupId || undefined,
withConsent: consent === '' ? undefined : consent === 'tak',
page,
size: PAGE_SIZE,
},
})
return response.data
},
})
const assignGroup = useMutation({
mutationFn: (targetGroupId: number) =>
client.post(`/admin/contacts/groups/${targetGroupId}/members`, { contactIds: selected }),
onSuccess: (response) => {
const added = (response.data as { added: number }).added
toasts.success(`Dodano ${added} kontaktów do grupy`)
setSelected([])
queryClient.invalidateQueries({ queryKey: ['contacts'] })
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się przypisać do grupy')),
})
async function handleExport() {
setExporting(true)
try {
const response = await client.get('/admin/contacts/export', {
params: { groupId: groupId || undefined },
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = 'kontakty.csv'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
toasts.success('Plik CSV został pobrany')
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się pobrać pliku CSV'))
} finally {
setExporting(false)
}
}
function toggle(id: number) {
setSelected((current) =>
current.includes(id) ? current.filter((x) => x !== id) : [...current, id])
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-slate-900">Kontakty</h1>
<p className="text-sm text-slate-500">
Baza odbiorców zaproszeń, niezależna od pojedynczego webinaru
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" icon="users" onClick={() => setManagingGroups(true)}>
Grupy
</Button>
<Button variant="secondary" icon="upload" onClick={() => setImporting(true)}>
Import CSV
</Button>
<Button variant="secondary" icon="download" onClick={handleExport} disabled={exporting}>
{exporting ? 'Pobieranie...' : 'Eksport CSV'}
</Button>
<Button icon="user-plus" onClick={() => setCreating(true)}>
Dodaj kontakt
</Button>
</div>
</div>
<Card>
<div className="flex flex-wrap items-end gap-3">
<div className="w-full sm:w-72">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Szukaj</label>
<Input
value={search}
placeholder="Nazwisko, adres e-mail albo firma"
onChange={(e) => {
setSearch(e.target.value)
setPage(0)
}}
/>
</div>
<div className="w-full sm:w-56">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Grupa</label>
<Select
value={groupId}
onChange={(e) => {
setGroupId(e.target.value)
setPage(0)
}}
>
<option value="">Wszystkie</option>
{groups.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.contactCount})
</option>
))}
</Select>
</div>
<div className="w-full sm:w-56">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
Zgoda marketingowa
</label>
<Select
value={consent}
onChange={(e) => {
setConsent(e.target.value)
setPage(0)
}}
>
<option value="">Wszystkie</option>
<option value="tak">Może dostać zaproszenie</option>
<option value="nie">Bez zgody albo wypisany</option>
</Select>
</div>
</div>
</Card>
{/* pasek akcji masowych pojawia sie dopiero po zaznaczeniu */}
{selected.length > 0 && (
<Card>
<div className="flex flex-wrap items-end gap-3">
<p className="text-sm text-slate-700">
Zaznaczono <span className="hx-data font-semibold">{selected.length}</span>
</p>
<div className="w-full sm:w-56">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
Dodaj do grupy
</label>
<Select
value=""
disabled={assignGroup.isPending}
onChange={(e) => {
if (e.target.value) assignGroup.mutate(Number(e.target.value))
}}
>
<option value="">Wybierz grupę</option>
{groups.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
</div>
<Button variant="ghost" onClick={() => setSelected([])}>
Odznacz wszystkie
</Button>
</div>
</Card>
)}
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
{contacts.isPending && <Spinner />}
{contacts.isError && (
<div className="p-5">
<ErrorState message={errorMessage(contacts.error, 'Nie udało się wczytać kontaktów')} />
</div>
)}
{contacts.data && contacts.data.content.length === 0 && (
<EmptyState
icon="users"
title="Brak kontaktów"
description="Dodaj pierwszy kontakt albo zaimportuj listę z pliku CSV."
/>
)}
{contacts.data && contacts.data.content.length > 0 && (
<>
{/* karty do lg, tabela od lg w gore */}
<ul className="divide-y divide-slate-100 lg:hidden">
{contacts.data.content.map((contact) => (
<li key={contact.id} className="space-y-2 p-4">
<div className="flex items-start justify-between gap-2">
<button
type="button"
className="min-w-0 text-left"
onClick={() => setEditing(contact)}
>
<p className="font-medium text-slate-900">
{contact.firstName} {contact.lastName}
</p>
<p className="hx-data break-all text-xs text-slate-500">{contact.email}</p>
</button>
<ConsentBadge contact={contact} />
</div>
{contact.company && (
<p className="text-sm text-slate-500">{contact.company}</p>
)}
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
<th className="w-10 px-4 py-3">
<span className="sr-only">Zaznacz</span>
</th>
<th className="px-4 py-3">Osoba</th>
<th className="px-4 py-3">Firma</th>
<th className="px-4 py-3">Źródło</th>
<th className="px-4 py-3">Zgoda</th>
<th className="px-4 py-3 text-right">Akcje</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{contacts.data.content.map((contact) => (
<tr key={contact.id} className="transition duration-fast ease-standard hover:bg-slate-25">
<td className="px-4 py-3">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300"
checked={selected.includes(contact.id)}
onChange={() => toggle(contact.id)}
aria-label={`Zaznacz ${contact.firstName} ${contact.lastName}`}
/>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">
{contact.firstName} {contact.lastName}
</p>
<p className="hx-data text-xs text-slate-500">{contact.email}</p>
</td>
<td className="px-4 py-3 text-slate-600">{contact.company || '-'}</td>
<td className="px-4 py-3 text-xs text-slate-500">
{SOURCE_LABEL[contact.source] ?? contact.source}
</td>
<td className="px-4 py-3">
<ConsentBadge contact={contact} />
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Button
size="sm"
variant="ghost"
icon="pencil"
onClick={() => setEditing(contact)}
>
Edytuj
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{contacts.data && (
<Pagination
page={contacts.data.number}
totalPages={contacts.data.totalPages}
totalElements={contacts.data.totalElements}
onChange={setPage}
itemLabel="kontaktów"
/>
)}
</div>
{(creating || editing) && (
<ContactFormDialog
contact={editing}
groups={groups.data ?? []}
onClose={() => {
setCreating(false)
setEditing(null)
}}
/>
)}
{importing && (
<ContactImportDialog groups={groups.data ?? []} onClose={() => setImporting(false)} />
)}
{managingGroups && (
<GroupsDialog groups={groups.data ?? []} onClose={() => setManagingGroups(false)} />
)}
</div>
)
}
/**
* Stan zgody jednym spojrzeniem.
*
* Wypisanie pokazujemy osobno od braku zgody: to dwie rozne sytuacje.
* Ktos bez zgody moze ja dostac, ktos wypisany sam zdecydowal inaczej.
*/
function ConsentBadge({ contact }: { contact: Contact }) {
if (contact.unsubscribed) {
return (
<Badge tone="alarm">
<Icon name="user-off" size={12} className="mr-1" />
Wypisany
</Badge>
)
}
if (!contact.active) {
return <Badge tone="neutral">Nieaktywny</Badge>
}
return contact.marketingConsent ? (
<Badge tone="ok">Ze zgodą</Badge>
) : (
<Badge tone="neutral">Bez zgody</Badge>
)
}
@@ -0,0 +1,101 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { ContactGroup } from '../../api/types'
import { useToasts } from '../../components/Toasts'
import { Button, Input } from '../../components/ui'
import { Modal } from '../../components/Modal'
/** Zarzadzanie grupami kontaktow. */
export function GroupsDialog({
groups,
onClose,
}: {
groups: ContactGroup[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [name, setName] = useState('')
const create = useMutation({
mutationFn: () => client.post('/admin/contacts/groups', { name }),
onSuccess: () => {
toasts.success('Grupa została utworzona')
setName('')
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się utworzyć grupy')),
})
const remove = useMutation({
mutationFn: (groupId: number) => client.delete(`/admin/contacts/groups/${groupId}`),
onSuccess: () => {
toasts.success('Grupa została usunięta')
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
queryClient.invalidateQueries({ queryKey: ['contacts'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć grupy')),
})
return (
<Modal
title="Grupy kontaktów"
description="Grupy służą do wyboru odbiorców zaproszenia"
onClose={onClose}
>
<div className="space-y-4">
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
if (name.trim()) create.mutate()
}}
>
<div className="min-w-0 flex-1">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Nazwa grupy</label>
<Input
value={name}
placeholder="np. Klienci, Newsletter"
onChange={(e) => setName(e.target.value)}
/>
</div>
<Button type="submit" icon="plus" disabled={create.isPending || !name.trim()}>
Dodaj
</Button>
</form>
{groups.length === 0 ? (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
Nie ma jeszcze żadnej grupy.
</p>
) : (
<ul className="divide-y divide-slate-100">
{groups.map((group) => (
<li key={group.id} className="flex items-center justify-between gap-3 py-2.5">
<div className="min-w-0">
<p className="font-medium text-slate-900">{group.name}</p>
<p className="hx-data text-xs text-slate-500">{group.contactCount} kontaktów</p>
</div>
<Button
size="sm"
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
// grupa znika, kontakty zostaja: to tylko etykieta
if (confirm(`Usunąć grupę "${group.name}"? Kontakty zostaną w bazie.`)) {
remove.mutate(group.id)
}
}}
>
Usuń
</Button>
</li>
))}
</ul>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,593 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type {
CertificateCounters,
CertificateResendResult,
CertificateRowAdmin,
CertificateSubject,
} from '../../api/types'
import { Icon } from '../../components/Icon'
import type { IconName } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { Pagination, PAGE_SIZE } from '../../components/Pagination'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Input,
Select,
Spinner,
Textarea,
} from '../../components/ui'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { usePublicUrl } from '../../lib/usePublicConfig'
import { panelPath } from '../../lib/routes'
/**
* Przekrojowa lista certyfikatow calej instancji.
*
* Sluzy PRZEGLADANIU I OBSLUDZE juz wydanych dokumentow. Wydawanie zostaje na
* karcie kursu i karcie webinaru, gdzie widac kontekst: komu certyfikat sie
* nalezy i dlaczego. Tutaj tego kontekstu nie ma, wiec uniewaznienie wymaga
* uzasadnienia.
*/
export function CertificateOverviewPage() {
const queryClient = useQueryClient()
const toasts = useToasts()
const publicUrl = usePublicUrl()
const [source, setSource] = useState('')
const [status, setStatus] = useState('')
const [subject, setSubject] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const [revoking, setRevoking] = useState<CertificateRowAdmin | null>(null)
const [exporting, setExporting] = useState(false)
/*
* Filtr zrodla niesie dwie wartosci: identyfikator i to, czy chodzi o kurs.
* Bez drugiej kursy i webinary o tym samym numerze mieszalyby sie ze soba,
* bo obie tabele maja wlasne sekwencje.
*/
const [subjectId, subjectIsCourse] = subject
? [subject.split(':')[1], subject.split(':')[0] === 'kurs']
: [undefined, undefined]
const params = {
forCourse: source === '' ? undefined : source === 'kurs',
revoked: status === '' ? undefined : status === 'uniewazniony',
subjectId,
subjectIsCourse,
from: from || undefined,
to: to || undefined,
search: search || undefined,
}
const counters = useQuery({
queryKey: ['certificate-counters'],
queryFn: async () =>
(await client.get<CertificateCounters>('/admin/certificates/counters')).data,
})
const subjects = useQuery({
queryKey: ['certificate-subjects'],
queryFn: async () =>
(await client.get<CertificateSubject[]>('/admin/certificates/subjects')).data,
})
const { data, isPending, isError, error } = useQuery({
queryKey: ['certificates-all', source, status, subject, from, to, search, page],
queryFn: async () =>
(
await client.get<{
content: CertificateRowAdmin[]
number: number
totalPages: number
totalElements: number
}>('/admin/certificates', { params: { ...params, page, size: PAGE_SIZE } })
).data,
})
const resend = useMutation({
mutationFn: async (id: number) =>
(await client.post<CertificateResendResult>(`/admin/certificates/${id}/resend`)).data,
onSuccess: (result) => {
// mowimy wprost, czy poszedl zalacznik: bez tego wsparcie nie wie,
// czy odbiorca dostal dokument, czy sam odnosnik
if (result.withAttachment) {
toasts.success(`Certyfikat wysłany na ${result.recipient}`)
} else {
toasts.error(result.message)
}
},
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać certyfikatu')),
})
const revoke = useMutation({
mutationFn: async ({ id, note }: { id: number; note: string }) => {
await client.post(`/admin/certificates/${id}/revoke-with-note`, { note })
},
onSuccess: () => {
toasts.success('Certyfikat unieważniony')
queryClient.invalidateQueries({ queryKey: ['certificates-all'] })
queryClient.invalidateQueries({ queryKey: ['certificate-counters'] })
setRevoking(null)
},
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się unieważnić certyfikatu')),
})
const exportCsv = async () => {
setExporting(true)
try {
const response = await client.get('/admin/certificates/export', {
params,
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = 'certyfikaty.csv'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (e) {
toasts.error(errorMessage(e, 'Nie udało się pobrać pliku'))
} finally {
setExporting(false)
}
}
/** Zmiana filtru cofa na pierwsza strone: inaczej widac pusta tabele. */
const changeFilter = (setter: (value: string) => void) => (value: string) => {
setter(value)
setPage(0)
}
const rows = data?.content ?? []
const filtersActive = Boolean(source || status || subject || from || to || search)
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">Certyfikaty</h1>
<p className="text-sm text-slate-500">
Wszystkie wydane dokumenty, z kursów i z webinarów.
</p>
</div>
<Button variant="secondary" icon="download" onClick={exportCsv} disabled={exporting}>
{exporting ? 'Przygotowuję...' : 'Eksport CSV'}
</Button>
</div>
{/* --- liczniki --- */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Metric
icon="award"
label="Wydane w tym roku"
value={counters.data?.issuedThisYear ?? 0}
/>
<Metric
icon="clock"
label="Wydane w ostatnim miesiącu"
value={counters.data?.issuedLastMonth ?? 0}
/>
<Metric
icon="x-circle"
label="Unieważnione"
value={counters.data?.revoked ?? 0}
tone={counters.data && counters.data.revoked > 0 ? 'alarm' : 'neutral'}
/>
</div>
{/* --- filtry --- */}
<Card>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-source">
Źródło
</label>
<Select
id="cert-source"
className="w-full sm:w-40"
value={source}
onChange={(e) => changeFilter(setSource)(e.target.value)}
>
<option value="">Wszystkie</option>
<option value="kurs">Kursy</option>
<option value="webinar">Webinary</option>
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-status">
Status
</label>
<Select
id="cert-status"
className="w-full sm:w-44"
value={status}
onChange={(e) => changeFilter(setStatus)(e.target.value)}
>
<option value="">Wszystkie</option>
<option value="wazny">Ważne</option>
<option value="uniewazniony">Unieważnione</option>
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-subject">
Kurs lub webinar
</label>
<Select
id="cert-subject"
className="w-full sm:w-64"
value={subject}
onChange={(e) => changeFilter(setSubject)(e.target.value)}
>
<option value="">Wszystkie</option>
{(subjects.data ?? []).map((s) => (
<option key={`${s.forCourse ? 'kurs' : 'webinar'}:${s.id}`} value={`${s.forCourse ? 'kurs' : 'webinar'}:${s.id}`}>
{s.forCourse ? 'Kurs: ' : 'Webinar: '}
{s.title}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-from">
Wydane od
</label>
<Input
id="cert-from"
type="date"
className="w-full sm:w-40"
value={from}
onChange={(e) => changeFilter(setFrom)(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-to">
do
</label>
<Input
id="cert-to"
type="date"
className="w-full sm:w-40"
value={to}
onChange={(e) => changeFilter(setTo)(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-search">
Szukaj
</label>
<Input
id="cert-search"
className="w-full sm:w-56"
placeholder="Numer albo nazwisko"
value={search}
onChange={(e) => changeFilter(setSearch)(e.target.value)}
/>
</div>
<p className="ml-auto text-sm text-slate-500">
Znaleziono: <strong className="hx-data">{data?.totalElements ?? 0}</strong>
</p>
</div>
</Card>
{/* --- lista --- */}
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{isPending && (
<div className="p-5">
<Spinner />
</div>
)}
{isError && (
<div className="p-5">
<ErrorState message={errorMessage(error, 'Nie udało się wczytać certyfikatów')} />
</div>
)}
{data && rows.length === 0 && (
<div className="p-5">
<EmptyState
icon="award"
title="Brak certyfikatów"
description={
filtersActive
? 'Żaden certyfikat nie spełnia wybranych warunków.'
: 'Certyfikaty pojawią się tutaj po wydaniu ich na karcie kursu albo webinaru.'
}
/>
</div>
)}
{rows.length > 0 && (
<>
<ul className="divide-y divide-slate-100 lg:hidden">
{rows.map((row) => (
<li key={row.id} className="p-4">
<div className="flex flex-wrap items-center gap-2">
<p className="hx-data text-sm font-medium text-slate-900">{row.number}</p>
<Badge tone={row.forCourse ? 'brand' : 'info'}>
{row.forCourse ? 'kurs' : 'webinar'}
</Badge>
{row.revoked && <Badge tone="alarm">unieważniony</Badge>}
</div>
<p className="mt-1 text-sm text-slate-800">{row.recipientName}</p>
<p className="text-xs text-slate-500">{row.subjectTitle}</p>
<p className="hx-data mt-1 text-xs text-slate-500">
{formatLocal(row.issuedAt)}
</p>
<div className="mt-2 flex flex-wrap gap-2">
<RowActions
row={row}
publicUrl={publicUrl}
onResend={() => resend.mutate(row.id)}
onRevoke={() => setRevoking(row)}
busy={resend.isPending}
/>
</div>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
<th className="px-4 py-2.5">Numer</th>
<th className="px-4 py-2.5">Odbiorca</th>
<th className="px-4 py-2.5">Źródło</th>
<th className="px-4 py-2.5">Wydano</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5 text-right">Akcje</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className="border-b border-slate-50 transition duration-fast last:border-b-0 hover:bg-slate-25"
>
<td className="hx-data px-4 py-3 font-medium text-slate-900">
{row.number}
</td>
<td className="px-4 py-3">
<p className="text-slate-800">{row.recipientName}</p>
<p className="hx-data text-xs text-slate-500">{row.email}</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Badge tone={row.forCourse ? 'brand' : 'info'}>
{row.forCourse ? 'kurs' : 'webinar'}
</Badge>
<Link
to={
row.forCourse
? panelPath(`/kursy/${row.subjectId}`)
: panelPath(`/webinary/${row.subjectId}`)
}
className="text-slate-700 hover:text-blue-600"
>
{row.subjectTitle}
</Link>
</div>
</td>
<td className="hx-data px-4 py-3 text-slate-600">
{formatLocal(row.issuedAt)}
</td>
<td className="px-4 py-3">
{row.revoked ? (
<Badge tone="alarm">unieważniony</Badge>
) : (
<Badge tone="ok">ważny</Badge>
)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<RowActions
row={row}
publicUrl={publicUrl}
onResend={() => resend.mutate(row.id)}
onRevoke={() => setRevoking(row)}
busy={resend.isPending}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={data!.number}
totalPages={data!.totalPages}
totalElements={data!.totalElements}
onChange={setPage}
itemLabel="certyfikatów"
/>
</>
)}
</div>
<Alert tone="info" icon="circle-alert">
Certyfikaty wydaje się na karcie kursu albo webinaru, gdzie widać, komu się należą.
Ten ekran służy do przeglądania i obsługi już wydanych.
</Alert>
{revoking && (
<RevokeModal
row={revoking}
onClose={() => setRevoking(null)}
onConfirm={(note) => revoke.mutate({ id: revoking.id, note })}
busy={revoke.isPending}
/>
)}
</div>
)
}
function RowActions({
row,
publicUrl,
onResend,
onRevoke,
busy,
}: {
row: CertificateRowAdmin
publicUrl: string
onResend: () => void
onRevoke: () => void
busy: boolean
}) {
return (
<>
<a
href={publicUrl + row.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-700"
>
Pobierz
</a>
{!row.revoked && (
<>
<button
type="button"
onClick={() => {
if (
window.confirm(
`Wysłać certyfikat ${row.number} ponownie na adres ${row.email}? ` +
'Wyślemy dokładnie ten plik, który został wydany.',
)
) {
onResend()
}
}}
disabled={busy}
className="text-sm text-slate-600 hover:text-slate-900 disabled:opacity-50"
>
Wyślij ponownie
</button>
<button
type="button"
onClick={onRevoke}
className="text-sm text-alarm-600 hover:text-alarm-700"
>
Unieważnij
</button>
</>
)}
</>
)
}
/**
* Uniewaznienie z uzasadnieniem.
*
* Notatka jest WYMAGANA: na liscie przekrojowej nie widac kontekstu, ktorego
* dokument dotyczyl, a uniewazniony certyfikat mogl juz trafic do pracodawcy.
* Po miesiacu musi dac sie ustalic, dlaczego przestal byc wazny.
*/
function RevokeModal({
row,
onClose,
onConfirm,
busy,
}: {
row: CertificateRowAdmin
onClose: () => void
onConfirm: (note: string) => void
busy: boolean
}) {
const [note, setNote] = useState('')
return (
<Modal
title={`Unieważnienie certyfikatu ${row.number}`}
onClose={onClose}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Anuluj
</Button>
<Button
variant="danger"
icon="x-circle"
onClick={() => onConfirm(note)}
disabled={!note.trim() || busy}
>
{busy ? 'Unieważnianie...' : 'Unieważnij'}
</Button>
</>
}
>
<div className="space-y-4">
<Alert tone="warn" icon="alert-triangle">
Certyfikat <strong>{row.number}</strong> wydany dla{' '}
<strong>{row.recipientName}</strong> przestanie być ważny. Dokument nie zniknie:
weryfikacja publiczna pokaże, że został wycofany, bo mógł już trafić do pracodawcy.
</Alert>
<Field
label="Uzasadnienie"
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego certyfikat wycofano."
>
<Textarea
rows={3}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="np. wydany przez pomyłkę, kursant nie ukończył wymaganych lekcji"
autoFocus
/>
</Field>
</div>
</Modal>
)
}
function Metric({
icon,
label,
value,
tone = 'neutral',
}: {
icon: IconName
label: string
value: number
tone?: 'neutral' | 'alarm'
}) {
return (
<div className="rounded-lg border border-slate-100 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<Icon
name={icon}
size={16}
className={tone === 'alarm' ? 'text-alarm-500' : 'text-slate-400'}
/>
<p className="hx-eyebrow">{label}</p>
</div>
<p className="hx-data mt-1.5 text-2xl font-bold text-slate-900">{value}</p>
</div>
)
}
@@ -0,0 +1,434 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Certificate, CertificateBulkResult, Course, CourseEnrollment } from '../../api/types'
import { Modal } from '../../components/Modal'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Input,
Spinner,
Textarea,
} from '../../components/ui'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { usePublicUrl } from '../../lib/usePublicConfig'
/**
* Certyfikaty kursu.
*
* Ten sam zestaw akcji co przy webinarze (`CertificateSection`): ustawienia,
* wydawanie pojedyncze i masowe, lista wydanych, uniewaznienie. Rozni sie
* warunek wydania: przy webinarze decyduje obecnosc, tutaj ukonczenie kursu.
*
* Wydanie osobie, ktora kursu nie skonczyla, WYMAGA UZASADNIENIA. Bez notatki
* po miesiacu nie da sie ustalic, dlaczego dokument powstal i kto o tym
* zdecydowal; przy certyfikacie, ktory trafia do pracodawcy, to nie jest
* szczegol.
*/
export function CourseCertificateSection({ course }: { course: Course }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const publicUrl = usePublicUrl()
const [enabled, setEnabled] = useState(course.certificateEnabled)
const [hours, setHours] = useState(
course.certificateHours === null || course.certificateHours === undefined
? ''
: String(course.certificateHours),
)
const [template, setTemplate] = useState(course.certificateTemplateHtml ?? '')
const [forcing, setForcing] = useState<{ enrollmentId: number | null; name: string } | null>(null)
const certificates = useQuery({
queryKey: ['course-certificates', course.id],
queryFn: async () =>
(await client.get<Certificate[]>(`/admin/courses/${course.id}/certificates`)).data,
})
const enrollments = useQuery({
queryKey: ['course-enrollments', course.id],
queryFn: async () =>
(await client.get<CourseEnrollment[]>(`/admin/courses/${course.id}/enrollments`)).data,
})
const saveSettings = useMutation({
mutationFn: async () => {
/*
* PUT wysyla caly obiekt kursu, tak samo jak przy webinarze: endpoint
* edycji nie jest patchem, wiec pominiete pole zostaloby wyczyszczone.
*/
await client.put(`/admin/courses/${course.id}`, {
...course,
certificateEnabled: enabled,
certificateHours: hours.trim() === '' ? null : Number(hours),
certificateTemplateHtml: template.trim() === '' ? null : template,
})
},
onSuccess: () => {
toasts.success('Ustawienia certyfikatów zapisane')
queryClient.invalidateQueries({ queryKey: ['course', course.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
})
const issueOne = useMutation({
mutationFn: async (payload: { enrollmentId: number; force: boolean; note?: string }) => {
await client.post(`/admin/courses/${course.id}/certificates`, payload)
},
onSuccess: () => {
toasts.success('Certyfikat wydany')
queryClient.invalidateQueries({ queryKey: ['course-certificates', course.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatu')),
})
const issueAll = useMutation({
mutationFn: async (payload: { force: boolean; note?: string }) => {
const response = await client.post<CertificateBulkResult>(
`/admin/courses/${course.id}/certificates/all`,
null,
{ params: payload },
)
return response.data
},
onSuccess: (result) => {
toasts.success(`Wydano ${result.issued}, pominięto ${result.skipped}`)
if (result.problems.length > 0) {
toasts.error(`Problemy: ${result.problems.slice(0, 3).join('; ')}`)
}
queryClient.invalidateQueries({ queryKey: ['course-certificates', course.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatów')),
})
const revoke = useMutation({
mutationFn: async (certificateId: number) => {
await client.post(`/admin/certificates/${certificateId}/revoke`)
},
onSuccess: () => {
toasts.success('Certyfikat unieważniony')
queryClient.invalidateQueries({ queryKey: ['course-certificates', course.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się unieważnić certyfikatu')),
})
const withCertificate = new Set(
(certificates.data ?? []).map((c) => c.enrollmentId).filter(Boolean) as number[],
)
const pending = (enrollments.data ?? []).filter((e) => !withCertificate.has(e.id))
const completedPending = pending.filter((e) => e.status === 'COMPLETED')
return (
<div className="space-y-4">
{/* --- ustawienia --- */}
<Card
title="Ustawienia certyfikatów"
description="Certyfikat wydaje się automatycznie w chwili ukończenia kursu."
>
<div className="space-y-4">
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm text-slate-700">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-3 focus:ring-blue-500/45"
/>
Wydawaj certyfikaty po ukończeniu tego kursu
</label>
<Field
label="Liczba godzin szkoleniowych"
hint="Puste oznacza, że certyfikat nie podaje godzin."
>
<Input
type="number"
step="0.5"
min="0"
value={hours}
onChange={(e) => setHours(e.target.value)}
/>
</Field>
<Field
label="Własna treść certyfikatu"
hint="Puste oznacza szablon domyślny. Dostępne znaczniki: ${participantName}, ${courseTitle}, ${completedDate}, ${hours}, ${scorePercent}, ${number}, ${brandName}."
wide
>
<Textarea
rows={6}
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder="<h2>Zaświadczenie o ukończeniu</h2>"
/>
</Field>
<Button icon="save" onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
{saveSettings.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</Button>
</div>
</Card>
{/* --- wydawanie --- */}
<Card
title="Wydawanie"
description="Kursanci bez certyfikatu. Ukończenie kursu wydaje go automatycznie."
>
{enrollments.isPending && <Spinner />}
{enrollments.isError && <ErrorState message="Nie udało się wczytać listy zapisanych." />}
{enrollments.data && (
<>
<div className="mb-4 flex flex-wrap gap-2">
<Button
icon="award"
onClick={() => {
if (
window.confirm(
`Wydać certyfikaty wszystkim, którzy ukończyli kurs "${course.title}"? ` +
`Dotyczy ${completedPending.length} osób.`,
)
) {
issueAll.mutate({ force: false })
}
}}
disabled={issueAll.isPending || completedPending.length === 0}
>
Wydaj tym, którzy ukończyli ({completedPending.length})
</Button>
<Button
variant="secondary"
onClick={() => setForcing({ enrollmentId: null, name: '' })}
disabled={issueAll.isPending || pending.length === 0}
>
Wydaj wszystkim, także nieukończonym
</Button>
</div>
{pending.length === 0 ? (
<p className="text-sm text-slate-500">
Wszyscy zapisani mają już certyfikat.
</p>
) : (
<ul className="space-y-2">
{pending.map((enrollment) => (
<li
key={enrollment.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">
{enrollment.firstName} {enrollment.lastName}
</p>
<p className="text-xs text-slate-500">
{enrollment.email} · postęp {enrollment.progressPercent}%
</p>
</div>
<div className="flex items-center gap-2">
{enrollment.status !== 'COMPLETED' && (
<Badge tone="warn">Kurs nieukończony</Badge>
)}
<Button
size="sm"
onClick={() => {
if (enrollment.status === 'COMPLETED') {
issueOne.mutate({
enrollmentId: enrollment.id,
force: false,
})
} else {
// wydanie bez ukonczenia wymaga uzasadnienia
setForcing({
enrollmentId: enrollment.id,
name: `${enrollment.firstName} ${enrollment.lastName}`,
})
}
}}
disabled={issueOne.isPending}
>
Wydaj
</Button>
</div>
</li>
))}
</ul>
)}
</>
)}
</Card>
{/* --- wydane --- */}
<Card title="Wydane certyfikaty">
{certificates.isPending && <Spinner />}
{certificates.isError && <ErrorState message="Nie udało się wczytać certyfikatów." />}
{certificates.data && certificates.data.length === 0 && (
<EmptyState
icon="award"
title="Brak wydanych certyfikatów"
description="Pojawią się tutaj, gdy kursanci zaczną kończyć kurs."
/>
)}
{certificates.data && certificates.data.length > 0 && (
<ul className="space-y-2">
{certificates.data.map((certificate) => (
<li
key={certificate.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">
{certificate.participantName}
</p>
<p className="hx-data text-xs text-slate-500">
{certificate.number} · {formatLocal(certificate.issuedAt)}
</p>
</div>
<div className="flex items-center gap-2">
{certificate.valid ? (
<Badge tone="ok">Ważny</Badge>
) : (
<Badge tone="alarm">Unieważniony</Badge>
)}
<a
href={publicUrl + certificate.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-700"
>
Pobierz
</a>
{certificate.valid && (
<Button
size="sm"
variant="danger"
onClick={() => {
if (
window.confirm(
`Unieważnić certyfikat ${certificate.number} ` +
`wydany dla ${certificate.participantName}? ` +
'Dokument zostanie oznaczony jako nieważny przy weryfikacji publicznej, ' +
'ale nie zniknie.',
)
) {
revoke.mutate(certificate.id)
}
}}
disabled={revoke.isPending}
>
Unieważnij
</Button>
)}
</div>
</li>
))}
</ul>
)}
</Card>
<Alert tone="info" icon="circle-alert">
Autentyczność każdego certyfikatu można sprawdzić pod adresem{' '}
<span className="hx-data">{publicUrl}/certyfikat/NUMER</span>. Weryfikacja publiczna nie
pokazuje danych osobowych.
</Alert>
{forcing && (
<ForceIssueModal
name={forcing.name}
bulk={forcing.enrollmentId === null}
pendingCount={pending.length}
onClose={() => setForcing(null)}
onConfirm={(note) => {
if (forcing.enrollmentId === null) {
issueAll.mutate({ force: true, note })
} else {
issueOne.mutate({ enrollmentId: forcing.enrollmentId, force: true, note })
}
setForcing(null)
}}
/>
)}
</div>
)
}
/**
* Okno wydania bez ukonczenia kursu.
*
* Notatka jest WYMAGANA, a nie opcjonalna: to jedyny slad, dlaczego dokument
* powstal mimo niespelnionego warunku. Ostrzezenie mowi wprost, co sie stanie,
* bo przy wydaniu masowym dotyczy to takze osob, ktore ledwie zaczely kurs.
*/
function ForceIssueModal({
name,
bulk,
pendingCount,
onClose,
onConfirm,
}: {
name: string
bulk: boolean
pendingCount: number
onClose: () => void
onConfirm: (note: string) => void
}) {
const [note, setNote] = useState('')
return (
<Modal
title={bulk ? 'Wydanie wszystkim, także nieukończonym' : `Certyfikat dla: ${name}`}
onClose={onClose}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Anuluj
</Button>
<Button icon="award" onClick={() => onConfirm(note)} disabled={!note.trim()}>
Wydaj certyfikat
</Button>
</>
}
>
<div className="space-y-4">
<Alert tone="warn" icon="alert-triangle">
{bulk ? (
<>
Certyfikat dostanie <strong>{pendingCount}</strong> osób, także te, które kursu nie
ukończyły. Dokument potwierdza ukończenie, więc wydanie go osobie, która kursu nie
przeszła, jest świadomą decyzją.
</>
) : (
<>
<strong>{name}</strong> nie ukończył(a) tego kursu. Certyfikat potwierdza
ukończenie, więc wydanie go teraz jest świadomą decyzją.
</>
)}
</Alert>
<Field
label="Uzasadnienie"
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego dokument powstał."
>
<Textarea
rows={3}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="np. kurs zaliczony na szkoleniu stacjonarnym, potwierdzone przez prowadzącego"
autoFocus
/>
</Field>
</div>
</Modal>
)
}
@@ -0,0 +1,337 @@
import { Suspense, lazy, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Course, CourseEnrollment, EnrollmentStatus } from '../../api/types'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Select,
Spinner,
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { CurriculumSection } from './CurriculumSection'
import { CourseCertificateSection } from './CourseCertificateSection'
const CourseStatsSection = lazy(() =>
import('./CourseStatsSection').then((m) => ({ default: m.CourseStatsSection })))
import {
COURSE_LEVEL_LABEL,
COURSE_STATUS_LABEL,
COURSE_STATUS_TONE,
ENROLLMENT_STATUS_LABEL,
ENROLLMENT_STATUS_TONE,
formatDuration,
formatPrice,
} from './courseLabels'
import { panelPath } from '../../lib/routes'
type Tab = 'program' | 'zapisani' | 'statystyki' | 'certyfikaty' | 'informacje'
/**
* Karta kursu.
*
* Wszystko, co nalezy do jednego kursu, jest jego zakladka. Ta sama regula co
* przy karcie webinaru: uzytkownik pracuje kursami, wiec szukajac czegokolwiek
* o kursie wchodzi w ten kurs.
*/
export function CourseDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('program')
const course = useQuery({
queryKey: ['courses', 'detail', id],
queryFn: async () => (await client.get<Course>(`/admin/courses/${id}`)).data,
enabled: Boolean(id),
})
if (course.isPending) return <Spinner />
if (course.isError) {
return <ErrorState message={errorMessage(course.error, 'Nie udało się wczytać kursu')} />
}
const data = course.data
const courseId = Number(id)
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<Button variant="ghost" icon="arrow-left" onClick={() => navigate(panelPath('/kursy'))}>
Wróć
</Button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold text-slate-900">{data.title}</h1>
<Badge tone={COURSE_STATUS_TONE[data.status]}>
{COURSE_STATUS_LABEL[data.status]}
</Badge>
</div>
{data.subtitle && <p className="text-sm text-slate-500">{data.subtitle}</p>}
</div>
</div>
<Button icon="pencil" onClick={() => navigate(panelPath(`/kursy/${courseId}/edycja`))}>
Edytuj kurs
</Button>
</div>
<nav
role="tablist"
aria-label="Sekcje kursu"
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
>
{(
[
['program', 'Program kursu', 'file-text', data.lessonCount],
['zapisani', 'Zapisani', 'users', data.enrolledCount],
['statystyki', 'Statystyki', 'check-circle', null],
['certyfikaty', 'Certyfikaty', 'award', null],
['informacje', 'Informacje', 'settings', null],
] as const
).map(([value, label, icon, count]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
tab === value
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<Icon name={icon} size={16} />
{label}
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
</button>
))}
</nav>
{tab === 'program' && <CurriculumSection courseId={courseId} />}
{tab === 'zapisani' && <EnrollmentsTab courseId={courseId} />}
{/* wykresy leniwie: @mui/x-charts wazy wiecej niz reszta panelu */}
{tab === 'statystyki' && (
<Suspense fallback={<Spinner />}>
<CourseStatsSection courseId={courseId} />
</Suspense>
)}
{tab === 'certyfikaty' && <CourseCertificateSection course={data} />}
{tab === 'informacje' && <InfoTab course={data} />}
</div>
)
}
function InfoTab({ course }: { course: Course }) {
return (
<Card title="Informacje o kursie">
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
<Row label="Adres strony">/kurs/{course.slug}</Row>
<Row label="Kategoria">{course.category ?? '-'}</Row>
<Row label="Poziom">{course.level ? COURSE_LEVEL_LABEL[course.level] : '-'}</Row>
<Row label="Czas trwania">{formatDuration(course.durationMinutes)}</Row>
<Row label="Cena">{formatPrice(course)}</Row>
<Row label="Zapisy">{course.enrollmentOpen ? 'otwarte' : 'zamknięte'}</Row>
<Row label="Certyfikat">
{course.certificateEnabled
? `tak${course.certificateHours ? `, ${course.certificateHours} godz.` : ''}`
: 'nie'}
</Row>
<Row label="Próg zaliczenia">{course.passThresholdPercent}%</Row>
<Row label="Utworzono">{formatLocal(course.createdAt)}</Row>
<Row label="Zmieniono">{formatLocal(course.updatedAt)}</Row>
</dl>
{course.description && (
<div className="mt-4 border-t border-slate-100 pt-4">
<p className="text-xs font-semibold uppercase tracking-caps text-slate-400">Krótki opis</p>
<p className="mt-1 text-sm text-slate-700">{course.description}</p>
</div>
)}
</Card>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs font-semibold uppercase tracking-caps text-slate-400">{label}</dt>
<dd className="hx-data mt-0.5 text-sm text-slate-800">{children}</dd>
</div>
)
}
/**
* Lista zapisanych z postepem.
*
* Postep jest liczony **wylacznie z lekcji wymaganych**, co widac w kolumnie
* obok procentu: material dodatkowy nie wplywa na wynik.
*/
function EnrollmentsTab({ courseId }: { courseId: number }) {
const toasts = useToasts()
const [status, setStatus] = useState<EnrollmentStatus | ''>('')
const enrollments = useQuery({
queryKey: ['courses', 'enrollments', courseId, status],
queryFn: async () => {
const params = status ? `?status=${status}` : ''
return (
await client.get<CourseEnrollment[]>(`/admin/courses/${courseId}/enrollments${params}`)
).data
},
})
/*
* Eksport idzie przez axios jako blob, bo endpoint wymaga naglowka
* Authorization: zwykly link go nie doklada.
*/
const download = async () => {
try {
const params = status ? `?status=${status}` : ''
const response = await client.get(`/admin/courses/${courseId}/enrollments/export${params}`, {
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = `kursanci-${courseId}.csv`
link.click()
URL.revokeObjectURL(url)
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się pobrać pliku'))
}
}
return (
<div className="space-y-4">
<Card>
<div className="flex flex-wrap items-center gap-3">
<Select
className="max-w-xs"
value={status}
onChange={(e) => setStatus(e.target.value as EnrollmentStatus | '')}
aria-label="Filtr statusu zapisu"
>
<option value="">Wszystkie statusy</option>
{(Object.keys(ENROLLMENT_STATUS_LABEL) as EnrollmentStatus[]).map((value) => (
<option key={value} value={value}>
{ENROLLMENT_STATUS_LABEL[value]}
</option>
))}
</Select>
<Button variant="secondary" icon="download" onClick={download} className="ml-auto">
Eksport CSV
</Button>
</div>
</Card>
{enrollments.isPending && <Spinner />}
{enrollments.isError && (
<ErrorState message={errorMessage(enrollments.error, 'Nie udało się wczytać listy')} />
)}
{enrollments.data && enrollments.data.length === 0 && (
<EmptyState
icon="users"
title="Nikt nie jest jeszcze zapisany"
description="Zapisy kursantów dojdą w kolejnym kroku, razem z częścią publiczną."
/>
)}
{enrollments.data && enrollments.data.length > 0 && (
<Card className="overflow-hidden">
<ul className="divide-y divide-slate-100 lg:hidden">
{enrollments.data.map((item) => (
<li key={item.id} className="py-3 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">
{item.firstName} {item.lastName}
</p>
<p className="hx-data text-xs text-slate-500">{item.email}</p>
</div>
<Badge tone={ENROLLMENT_STATUS_TONE[item.status]}>
{ENROLLMENT_STATUS_LABEL[item.status]}
</Badge>
</div>
<div className="mt-2">
<ProgressBar value={item.progressPercent} />
<p className="hx-data mt-1 text-xs text-slate-500">
{item.progressPercent}% · {item.completedRequired}/{item.totalRequired} lekcji
wymaganych
</p>
</div>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-left text-xs uppercase tracking-caps text-slate-500">
<th className="px-3 py-2 font-medium">Kursant</th>
<th className="px-3 py-2 font-medium">Firma</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Postęp</th>
<th className="px-3 py-2 font-medium">Zapisano</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{enrollments.data.map((item) => (
<tr key={item.id} className="hover:bg-slate-25">
<td className="px-3 py-2.5">
<p className="font-medium text-slate-900">
{item.firstName} {item.lastName}
</p>
<p className="hx-data text-xs text-slate-500">{item.email}</p>
</td>
<td className="px-3 py-2.5 text-slate-600">{item.company ?? '-'}</td>
<td className="px-3 py-2.5">
<Badge tone={ENROLLMENT_STATUS_TONE[item.status]}>
{ENROLLMENT_STATUS_LABEL[item.status]}
</Badge>
</td>
<td className="px-3 py-2.5">
<div className="w-40">
<ProgressBar value={item.progressPercent} />
<p className="hx-data mt-1 text-xs text-slate-500">
{item.progressPercent}% · {item.completedRequired}/{item.totalRequired}
</p>
</div>
</td>
<td className="hx-data px-3 py-2.5 text-slate-600">
{formatLocal(item.enrolledAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)
}
function ProgressBar({ value }: { value: number }) {
return (
<div
className="h-2 w-full overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Postęp ${value} procent`}
>
<div
className="h-full rounded-full bg-blue-500 transition-all duration-base ease-standard"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
)
}
@@ -0,0 +1,436 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Course, CourseLevel } from '../../api/types'
import {
Alert,
Button,
Card,
ErrorState,
Field,
Input,
Select,
Spinner,
Textarea,
} from '../../components/ui'
import { ContentEditor } from '../../components/ContentEditor'
import { MediaField } from '../../components/MediaField'
import { FormTabs } from '../../components/FormTabs'
import type { FormTab } from '../../components/FormTabs'
import { useToasts } from '../../components/Toasts'
import { usePublicConfig } from '../../lib/usePublicConfig'
import { COURSE_LEVEL_LABEL } from './courseLabels'
import { panelPath } from '../../lib/routes'
type Form = {
title: string
slug: string
subtitle: string
description: string
contentHtml: string
coverMediaId: string
category: string
level: CourseLevel | ''
durationMinutes: string
priceGross: string
currency: string
enrollmentOpen: boolean
certificateEnabled: boolean
certificateHours: string
passThresholdPercent: string
}
const EMPTY: Form = {
title: '',
slug: '',
subtitle: '',
description: '',
contentHtml: '',
coverMediaId: '',
category: '',
level: '',
durationMinutes: '',
priceGross: '',
currency: 'PLN',
enrollmentOpen: true,
certificateEnabled: false,
certificateHours: '',
passThresholdPercent: '75',
}
/**
* Zakladki formularza.
*
* Przelaczaja widocznosc, nie trasy: formularz zostaje jeden, z jednym
* przyciskiem zapisu, wiec przejscie miedzy zakladkami nie gubi niezapisanych
* zmian. Lista `fields` pozwala oznaczyc zakladke z bledem walidacji.
*/
const TABS: FormTab[] = [
{ id: 'podstawowe', label: 'Podstawowe', icon: 'file-text', fields: ['title', 'slug', 'subtitle', 'description'] },
{ id: 'tresc', label: 'Opis kursu', icon: 'pencil', fields: ['contentHtml'] },
{ id: 'cennik', label: 'Cennik', icon: 'send', fields: ['priceGross', 'currency'] },
{ id: 'certyfikat', label: 'Certyfikat', icon: 'check-circle', fields: ['certificateHours', 'passThresholdPercent'] },
]
export function CourseFormPage() {
const { id } = useParams<{ id: string }>()
const editing = Boolean(id)
const navigate = useNavigate()
const queryClient = useQueryClient()
const toasts = useToasts()
const config = usePublicConfig()
const [form, setForm] = useState<Form>(EMPTY)
const [errors, setErrors] = useState<Record<string, string>>({})
const [activeTab, setActiveTab] = useState<string>(TABS[0].id)
const [slugTouched, setSlugTouched] = useState(false)
const course = useQuery({
queryKey: ['courses', 'detail', id],
queryFn: async () => (await client.get<Course>(`/admin/courses/${id}`)).data,
enabled: editing,
})
useEffect(() => {
if (!course.data) return
const data = course.data
setForm({
title: data.title,
slug: data.slug,
subtitle: data.subtitle ?? '',
description: data.description ?? '',
contentHtml: data.contentHtml ?? '',
coverMediaId: data.coverMediaId ? String(data.coverMediaId) : '',
category: data.category ?? '',
level: data.level ?? '',
durationMinutes: data.durationMinutes ? String(data.durationMinutes) : '',
priceGross: data.priceGross === null ? '' : String(data.priceGross),
currency: data.currency,
enrollmentOpen: data.enrollmentOpen,
certificateEnabled: data.certificateEnabled,
certificateHours: data.certificateHours === null ? '' : String(data.certificateHours),
passThresholdPercent: String(data.passThresholdPercent),
})
setSlugTouched(true)
}, [course.data])
const update = <K extends keyof Form>(key: K, value: Form[K]) => {
setForm((prev) => ({ ...prev, [key]: value }))
setErrors((prev) => ({ ...prev, [key]: '' }))
}
const save = useMutation({
mutationFn: async () => {
const body = {
title: form.title,
slug: form.slug || null,
subtitle: form.subtitle || null,
description: form.description || null,
contentHtml: form.contentHtml || null,
coverMediaId: form.coverMediaId ? Number(form.coverMediaId) : null,
category: form.category || null,
level: form.level || null,
durationMinutes: form.durationMinutes ? Number(form.durationMinutes) : null,
// pusta cena oznacza kurs darmowy, a nie cene zerowa
priceGross: form.priceGross === '' ? null : Number(form.priceGross),
currency: form.currency || 'PLN',
enrollmentOpen: form.enrollmentOpen,
certificateEnabled: form.certificateEnabled,
certificateHours: form.certificateHours === '' ? null : Number(form.certificateHours),
passThresholdPercent: Number(form.passThresholdPercent || '75'),
}
return editing
? (await client.put<Course>(`/admin/courses/${id}`, body)).data
: (await client.post<Course>('/admin/courses', body)).data
},
onSuccess: (saved) => {
toasts.success(editing ? 'Kurs zapisany' : 'Kurs utworzony')
queryClient.invalidateQueries({ queryKey: ['courses'] })
navigate(panelPath(`/kursy/${saved.id}`))
},
onError: (error) => {
const fields = fieldErrors(error)
setErrors(fields)
// blad na nieaktywnej zakladce byl by niewidoczny, wiec przeskakujemy
const faulty = TABS.find((tab) => (tab.fields ?? []).some((field) => fields[field]))
if (faulty) setActiveTab(faulty.id)
toasts.error(errorMessage(error, 'Nie udało się zapisać kursu'))
},
})
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
const found: Record<string, string> = {}
if (!form.title.trim()) found.title = 'Tytuł jest wymagany'
const threshold = Number(form.passThresholdPercent)
if (Number.isNaN(threshold) || threshold < 0 || threshold > 100) {
found.passThresholdPercent = 'Próg musi mieścić się między 0 a 100'
}
if (form.priceGross !== '' && Number(form.priceGross) < 0) {
found.priceGross = 'Cena nie może być ujemna'
}
if (Object.keys(found).length > 0) {
setErrors(found)
const faulty = TABS.find((tab) => (tab.fields ?? []).some((field) => found[field]))
if (faulty) setActiveTab(faulty.id)
toasts.error('Formularz zawiera błędy')
return
}
save.mutate()
}
if (editing && course.isPending) return <Spinner />
if (editing && course.isError) {
return <ErrorState message={errorMessage(course.error, 'Nie udało się wczytać kursu')} />
}
const publicUrl = config.publicUrl ?? ''
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="flex items-start gap-2">
<Button variant="ghost" icon="arrow-left" type="button" onClick={() => navigate(panelPath('/kursy'))}>
Wróć
</Button>
<div>
<h1 className="text-xl font-semibold text-slate-900">
{editing ? 'Edycja kursu' : 'Nowy kurs'}
</h1>
{editing && <p className="text-sm text-slate-500">{course.data?.title}</p>}
</div>
</div>
<FormTabs tabs={TABS} active={activeTab} onChange={setActiveTab} errors={errors} />
{activeTab === 'podstawowe' && (
<Card title="Podstawowe dane">
<div className="space-y-4">
<Field label="Tytuł" error={errors.title}>
<Input
value={form.title}
onChange={(e) => {
update('title', e.target.value)
if (!slugTouched) update('slug', '')
}}
placeholder="np. Ocena ryzyka w wodociągach"
required
/>
</Field>
<Field
label="Adres strony kursu"
error={errors.slug}
hint="Puste oznacza wygenerowanie z tytułu. Konflikt rozstrzyga sufiks liczbowy."
>
<div className="flex flex-wrap items-center gap-2">
<span className="hx-data shrink-0 rounded-md bg-slate-50 px-2 py-2 text-xs text-slate-500">
{publicUrl}/kurs/
</span>
<Input
className="min-w-0 flex-1"
value={form.slug}
onChange={(e) => {
setSlugTouched(true)
update('slug', e.target.value)
}}
placeholder="powstanie z tytułu"
/>
</div>
</Field>
<Field label="Podtytuł" error={errors.subtitle} hint="Jedno zdanie pod tytułem, na karcie kursu.">
<Input value={form.subtitle} onChange={(e) => update('subtitle', e.target.value)} />
</Field>
<Field
label="Krótki opis"
error={errors.description}
hint="Jedno, dwa zdania. Zajawka na liście kursów. Bez formatowania."
>
<Textarea
rows={3}
value={form.description}
onChange={(e) => update('description', e.target.value)}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Kategoria" error={errors.category}>
<Input
value={form.category}
onChange={(e) => update('category', e.target.value)}
placeholder="np. Bezpieczeństwo wody"
/>
</Field>
<Field label="Poziom" error={errors.level}>
<Select
value={form.level}
onChange={(e) => update('level', e.target.value as CourseLevel | '')}
>
<option value="">Nie określono</option>
{(Object.keys(COURSE_LEVEL_LABEL) as CourseLevel[]).map((value) => (
<option key={value} value={value}>
{COURSE_LEVEL_LABEL[value]}
</option>
))}
</Select>
</Field>
</div>
<Field
label="Deklarowany czas trwania"
error={errors.durationMinutes}
hint="W minutach. Wartość pokazywana na karcie, niezależna od sumy czasów lekcji."
>
<Input
type="number"
min={1}
value={form.durationMinutes}
onChange={(e) => update('durationMinutes', e.target.value)}
placeholder="np. 480"
/>
</Field>
<Field label="Okładka kursu" hint="Plik z biblioteki mediów. Obcy adres nie jest dozwolony.">
<MediaField
value={form.coverMediaId}
onChange={(value) => update('coverMediaId', value)}
/>
</Field>
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
<input
type="checkbox"
checked={form.enrollmentOpen}
onChange={(e) => update('enrollmentOpen', e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
<strong className="text-slate-900">Zapisy otwarte</strong>
<span className="mt-0.5 block text-xs text-slate-500">
To Twoja decyzja, a nie stan wyliczany. Kurs niepublikowany i tak nie przyjmuje
zapisów.
</span>
</span>
</label>
</div>
</Card>
)}
{activeTab === 'tresc' && (
<Card title="Opis kursu" description="Treść strony kursu. Pojawi się nad programem.">
<Field label="Treść" wide>
<ContentEditor
value={form.contentHtml}
onChange={(html) => update('contentHtml', html)}
/>
</Field>
</Card>
)}
{activeTab === 'cennik' && (
<Card title="Cennik">
<div className="space-y-4">
<Alert tone="info">
Puste pole ceny oznacza <strong>kurs darmowy</strong>. To co innego niż cena 0,00:
pusta cena mówi, że kurs jest bezpłatny z założenia, a cena zerowa, że cennik
ustalono na zero.
</Alert>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Cena brutto" error={errors.priceGross} hint="Puste = kurs darmowy.">
<Input
type="number"
min={0}
step="0.01"
value={form.priceGross}
onChange={(e) => update('priceGross', e.target.value)}
placeholder="darmowy"
/>
</Field>
<Field label="Waluta" error={errors.currency}>
<Input
value={form.currency}
onChange={(e) => update('currency', e.target.value.toUpperCase())}
maxLength={3}
/>
</Field>
</div>
<Alert tone="warn">
Obsługa płatności dojdzie osobno. Na razie cena jest wyłącznie informacją w cenniku,
a nie rozliczeniem.
</Alert>
</div>
</Card>
)}
{activeTab === 'certyfikat' && (
<Card title="Certyfikat i zaliczenie">
<div className="space-y-4">
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
<input
type="checkbox"
checked={form.certificateEnabled}
onChange={(e) => update('certificateEnabled', e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
<strong className="text-slate-900">Kurs wydaje certyfikat</strong>
<span className="mt-0.5 block text-xs text-slate-500">
Po zaliczeniu kursu, czyli po przekroczeniu progu poniżej.
</span>
</span>
</label>
<Field
label="Liczba godzin szkoleniowych"
error={errors.certificateHours}
hint="Trafia na certyfikat. Puste oznacza, że dokument jej nie podaje."
>
<Input
type="number"
min={0}
step="0.5"
value={form.certificateHours}
onChange={(e) => update('certificateHours', e.target.value)}
/>
</Field>
<Field
label="Próg zaliczenia (%)"
error={errors.passThresholdPercent}
hint="Liczony wyłącznie z lekcji oznaczonych jako wymagane. Materiały dodatkowe nie wpływają na wynik."
>
<Input
type="number"
min={0}
max={100}
value={form.passThresholdPercent}
onChange={(e) => update('passThresholdPercent', e.target.value)}
/>
</Field>
</div>
</Card>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button variant="secondary" type="button" onClick={() => navigate(panelPath('/kursy'))}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
{!editing && (
<p className="text-sm text-slate-500">
Program kursu, czyli moduły i lekcje, dodasz po zapisaniu.
</p>
)}
</form>
)
}
@@ -0,0 +1,403 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Course, CourseStatus } from '../../api/types'
import type { Page } from '../../api/types'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Input,
Select,
Spinner,
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { SortableHeader } from '../../components/SortableHeader'
import type { SortState } from '../../components/SortableHeader'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels'
import { panelPath } from '../../lib/routes'
/**
* Lista kursow.
*
* Filtrowanie i paginacja po stronie backendu, tak samo jak przy webinarach:
* baza kursow rosnie, a filtrowanie pobranej calosci w przegladarce przestaje
* dzialac dokladnie wtedy, gdy zaczyna byc potrzebne.
*/
export function CourseListPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const toasts = useToasts()
const [page, setPage] = useState(0)
const [status, setStatus] = useState<CourseStatus | ''>('')
const [category, setCategory] = useState('')
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [sort, setSort] = useState<SortState>({ field: 'createdAt', direction: 'desc' })
// wpisywanie frazy nie moze wysylac zadania przy kazdej literze
useEffect(() => {
const timer = setTimeout(() => setDebounced(search.trim()), 300)
return () => clearTimeout(timer)
}, [search])
// zmiana filtru cofa na pierwsza strone: inaczej po zawezeniu wyniku
// uzytkownik stojacy na stronie 2 widzi pusta tabele
useEffect(() => {
setPage(0)
}, [status, category, debounced, sort])
const courses = useQuery({
queryKey: ['courses', page, status, category, debounced, sort],
queryFn: async () => {
const params = new URLSearchParams({
page: String(page),
size: String(PAGE_SIZE),
sort: sort.field,
direction: sort.direction,
})
if (status) params.set('status', status)
if (category) params.set('category', category)
if (debounced) params.set('search', debounced)
return (await client.get<Page<Course>>(`/admin/courses?${params}`)).data
},
})
const categories = useQuery({
queryKey: ['courses', 'categories'],
queryFn: async () => (await client.get<string[]>('/admin/courses/categories')).data,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['courses'] })
const publish = useMutation({
mutationFn: (id: number) => client.post(`/admin/courses/${id}/publish`),
onSuccess: () => {
toasts.success('Kurs został opublikowany')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się opublikować kursu')),
})
const archive = useMutation({
mutationFn: (id: number) => client.post(`/admin/courses/${id}/archive`),
onSuccess: () => {
toasts.success('Kurs został zarchiwizowany')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zarchiwizować kursu')),
})
const copy = useMutation({
mutationFn: (id: number) =>
client.post<{ id: number; slug: string; title: string }>(`/admin/courses/${id}/copy`, {}),
onSuccess: (response) => {
toasts.success('Kurs skopiowany. Kopia jest wersją roboczą.')
invalidate()
// od razu do edycji kopii: zwykle trzeba poprawic tytul i program
navigate(panelPath(`/kursy/${response.data.id}/edycja`))
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się skopiować kursu')),
})
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-slate-900">Kursy</h1>
<p className="text-sm text-slate-500">Kursy on-line Akademii</p>
</div>
<Button icon="plus" onClick={() => navigate(panelPath('/kursy/nowy'))}>
Nowy kurs
</Button>
</div>
<Card>
<div className="grid gap-3 sm:grid-cols-3">
<Input
placeholder="Szukaj po tytule..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Szukaj kursu"
/>
<Select
value={status}
onChange={(e) => setStatus(e.target.value as CourseStatus | '')}
aria-label="Filtr statusu"
>
<option value="">Wszystkie statusy</option>
<option value="DRAFT">Wersja robocza</option>
<option value="PUBLISHED">Opublikowany</option>
<option value="ARCHIVED">Zarchiwizowany</option>
</Select>
<Select
value={category}
onChange={(e) => setCategory(e.target.value)}
aria-label="Filtr kategorii"
>
<option value="">Wszystkie kategorie</option>
{(categories.data ?? []).map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</Select>
</div>
</Card>
{courses.isPending && <Spinner />}
{courses.isError && (
<ErrorState message={errorMessage(courses.error, 'Nie udało się wczytać kursów')} />
)}
{courses.data && courses.data.content.length === 0 && (
<EmptyState
icon="file-text"
title="Nie ma jeszcze żadnego kursu"
description="Utwórz pierwszy kurs, dodaj moduły i lekcje, a potem opublikuj."
/>
)}
{courses.data && courses.data.content.length > 0 && (
<Card className="overflow-hidden">
{/* karty do lg, tabela od lg: tabela z min-w rozpychalaby telefon */}
<ul className="divide-y divide-slate-100 lg:hidden">
{courses.data.content.map((course) => (
<li key={course.id} className="py-3 first:pt-0 last:pb-0">
<CourseCard
course={course}
onPublish={() => publish.mutate(course.id)}
onArchive={() => archive.mutate(course.id)}
onCopy={() => copy.mutate(course.id)}
/>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-left text-xs uppercase tracking-caps text-slate-500">
<SortableHeader field="title" sort={sort} onSort={setSort}>
Tytuł
</SortableHeader>
<SortableHeader field="category" sort={sort} onSort={setSort}>
Kategoria
</SortableHeader>
<SortableHeader field="priceGross" sort={sort} onSort={setSort}>
Cena
</SortableHeader>
<SortableHeader field="status" sort={sort} onSort={setSort}>
Status
</SortableHeader>
{/*
Zapisani i Lekcje celowo nie sa sortowalne: obie liczby
powstaja osobnym zapytaniem do juz pobranej strony, wiec
baza nie potrafi po nich sortowac, a sortowanie samej
biezacej strony wprowadzaloby w blad.
*/}
<th className="px-3 py-2 font-medium">Zapisani</th>
<th className="px-3 py-2 font-medium">Lekcje</th>
<th className="px-3 py-2 text-right font-medium">Akcje</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{courses.data.content.map((course) => (
<tr key={course.id} className="hover:bg-slate-25">
<td className="px-3 py-2.5">
<Link
to={panelPath(`/kursy/${course.id}`)}
className="font-medium text-slate-900 hover:text-blue-700"
>
{course.title}
</Link>
{course.subtitle && (
<p className="mt-0.5 text-xs text-slate-500">{course.subtitle}</p>
)}
</td>
<td className="px-3 py-2.5 text-slate-600">{course.category ?? '-'}</td>
<td className="hx-data px-3 py-2.5">{formatPrice(course)}</td>
<td className="px-3 py-2.5">
<Badge tone={COURSE_STATUS_TONE[course.status]}>
{COURSE_STATUS_LABEL[course.status]}
</Badge>
</td>
<td className="hx-data px-3 py-2.5 text-slate-600">{course.enrolledCount}</td>
<td className="hx-data px-3 py-2.5 text-slate-600">{course.lessonCount}</td>
<td className="px-3 py-2.5">
<div className="flex justify-end gap-1">
<RowActions
course={course}
onPublish={() => publish.mutate(course.id)}
onArchive={() => archive.mutate(course.id)}
onCopy={() => copy.mutate(course.id)}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pt-3">
<Pagination
page={page}
totalPages={courses.data.totalPages}
totalElements={courses.data.totalElements}
onChange={setPage}
itemLabel="kursów"
/>
</div>
</Card>
)}
</div>
)
}
function CourseCard({
course,
onPublish,
onArchive,
onCopy,
}: {
course: Course
onPublish: () => void
onArchive: () => void
onCopy: () => void
}) {
return (
<div>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<Link
to={panelPath(`/kursy/${course.id}`)}
className="text-sm font-medium text-slate-900 hover:text-blue-700"
>
{course.title}
</Link>
<p className="hx-data mt-0.5 text-xs text-slate-500">
{formatPrice(course)} · {course.lessonCount} lekcji · {course.enrolledCount} zapisanych
</p>
<p className="mt-0.5 text-xs text-slate-400">{formatLocal(course.createdAt)}</p>
</div>
<Badge tone={COURSE_STATUS_TONE[course.status]}>
{COURSE_STATUS_LABEL[course.status]}
</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-1">
<RowActions
course={course}
onPublish={onPublish}
onArchive={onArchive}
onCopy={onCopy}
/>
</div>
</div>
)
}
function RowActions({
course,
onPublish,
onArchive,
onCopy,
}: {
course: Course
onPublish: () => void
onArchive: () => void
onCopy: () => void
}) {
return (
<>
<IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" />
{course.status !== 'PUBLISHED' && course.status !== 'ARCHIVED' && (
<ActionButton
icon="upload"
label="Publikuj kurs"
onClick={() => {
if (window.confirm(`Opublikować kurs „${course.title}”?`)) {
onPublish()
}
}}
/>
)}
{course.status !== 'ARCHIVED' && (
<ActionButton
icon="history"
label="Archiwizuj kurs"
onClick={() => {
if (
window.confirm(
`Zarchiwizować kurs „${course.title}”?\n\n` +
'Kurs zniknie z oferty, ale osoby już zapisane zachowają dostęp.',
)
) {
onArchive()
}
}}
/>
)}
<ActionButton
icon="copy"
label="Kopiuj kurs"
onClick={() => {
if (
window.confirm(
`Skopiować kurs „${course.title}”?\n\n` +
'Kopia przejmie program, opis i ustawienia certyfikatu.\n' +
'Nie przejdą zapisy kursantów ani ich postęp.',
)
) {
onCopy()
}
}}
/>
</>
)
}
const ACTION_CLASS =
'flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900'
function ActionButton({
icon,
label,
onClick,
}: {
icon: Parameters<typeof Icon>[0]['name']
label: string
onClick: () => void
}) {
return (
<button type="button" onClick={onClick} title={label} aria-label={label} className={ACTION_CLASS}>
<Icon name={icon} size={18} />
</button>
)
}
function IconLink({
to,
icon,
label,
}: {
to: string
icon: Parameters<typeof Icon>[0]['name']
label: string
}) {
return (
<Link to={to} title={label} aria-label={label} className={ACTION_CLASS}>
<Icon name={icon} size={18} />
</Link>
)
}
@@ -0,0 +1,207 @@
import { useQuery } from '@tanstack/react-query'
import { BarChart } from '@mui/x-charts/BarChart'
import { client, errorMessage } from '../../api/client'
import type { CourseStats } from '../../api/types'
import { Icon } from '../../components/Icon'
import type { IconName } from '../../components/Icon'
import { Card, ErrorState, Spinner } from '../../components/ui'
/*
* Kolory podane wprost, bo biblioteka wykresow nie czyta klas Tailwinda.
* Musza odpowiadac tokenom z tailwind.config.js.
*/
const HYDRO_AZURE = '#2C8ABD'
const OK = '#16935C'
const ALARM = '#D8392F'
/**
* Statystyki kursu.
*
* Wszystkie liczby licza sie w bazie, zapytaniami agregujacymi: ten sam
* wzorzec co przy statystykach webinaru. Ten plik importuje wykresy statycznie,
* a leniwe ladowanie siedzi w stronie nadrzednej, dokladnie tak jak przy
* `StatsSection`: `@mui/x-charts` wazy wiecej niz reszta panelu, a statystyki
* ogladane sa rzadziej niz sam kurs.
*/
export function CourseStatsSection({ courseId }: { courseId: number }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ['course-stats', courseId],
queryFn: async () =>
(await client.get<CourseStats>(`/admin/academy/courses/${courseId}/stats`)).data,
})
if (isPending) return <Spinner />
if (isError || !data) {
return <ErrorState message={errorMessage(error, 'Nie udało się wczytać statystyk')} />
}
const hasEnrollments = data.enrolled > 0
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Metric icon="users" label="Zapisani" value={String(data.enrolled)} />
<Metric
icon="check-circle"
label="Ukończyli"
value={`${data.completed} (${data.completionRate}%)`}
tone={data.completed > 0 ? 'ok' : 'neutral'}
/>
<Metric icon="book-open" label="Średni postęp" value={`${data.averageProgress}%`} />
<Metric icon="award" label="Certyfikaty" value={String(data.certificatesIssued)} />
</div>
{/* --- rozklad postepu --- */}
<Card
title="Rozkład postępu"
description="Ilu kursantów jest na jakim etapie. Liczone z lekcji wymaganych."
>
{hasEnrollments ? (
<div className="overflow-x-auto">
<BarChart
height={240}
xAxis={[{ scaleType: 'band', data: data.distribution.map((b) => b.label) }]}
yAxis={[{ min: 0 }]}
series={[
{
data: data.distribution.map((b) => b.count),
label: 'Kursanci',
color: HYDRO_AZURE,
},
]}
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
/>
</div>
) : (
<EmptyChart text="Nikt jeszcze nie zapisał się na ten kurs." />
)}
</Card>
{/* --- quiz --- */}
<Card
title="Sprawdziany"
description="Liczone wyłącznie z podejść zakończonych: trwające nie mają wyniku."
>
{data.quizPassRate === null ? (
<EmptyChart text="Ten kurs nie ma sprawdzianu albo nikt jeszcze go nie rozwiązał." />
) : (
<div className="grid gap-3 sm:grid-cols-2">
<Metric
icon="list-checks"
label="Zdawalność"
value={`${data.quizPassRate}%`}
tone={data.quizPassRate >= 50 ? 'ok' : 'warn'}
/>
<Metric
icon="check"
label="Średni wynik"
value={`${data.quizAverageScore}%`}
/>
</div>
)}
</Card>
{/* --- porzucane lekcje --- */}
<Card
title="Lekcje, na których kursanci przestają"
description="Kursant doszedł do tej lekcji i nie ukończył żadnej późniejszej. To one wymagają poprawy."
>
{data.dropOff.length === 0 || data.dropOff.every((l) => l.stoppedHere === 0) ? (
<EmptyChart text="Nikt jeszcze nie utknął na żadnej lekcji." />
) : (
<>
<div className="overflow-x-auto">
<BarChart
height={260}
xAxis={[
{
scaleType: 'band',
data: data.dropOff.slice(0, 8).map((l) => shorten(l.lessonTitle)),
},
]}
yAxis={[{ min: 0 }]}
series={[
{
data: data.dropOff.slice(0, 8).map((l) => l.stoppedHere),
label: 'Przestali tutaj',
color: ALARM,
},
{
data: data.dropOff.slice(0, 8).map((l) => l.completedCount),
label: 'Ukończyli',
color: OK,
},
]}
margin={{ left: 40, right: 16, top: 16, bottom: 60 }}
/>
</div>
<ul className="mt-4 space-y-2">
{data.dropOff
.filter((l) => l.stoppedHere > 0)
.slice(0, 5)
.map((lesson) => (
<li
key={lesson.lessonId}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{lesson.lessonTitle}</p>
<p className="text-xs text-slate-500">{lesson.moduleTitle}</p>
</div>
<p className="text-sm text-slate-600">
przestało:{' '}
<strong className="hx-data text-alarm-600">{lesson.stoppedHere}</strong>
<span className="mx-2 text-slate-300">·</span>
ukończyło: <span className="hx-data">{lesson.completedCount}</span>
</p>
</li>
))}
</ul>
</>
)}
</Card>
</div>
)
}
/** Dlugi tytul lekcji rozpycha os wykresu, wiec go skracamy. */
function shorten(title: string) {
return title.length > 18 ? title.slice(0, 17) + '…' : title
}
function Metric({
icon,
label,
value,
tone = 'neutral',
}: {
icon: IconName
label: string
value: string
tone?: 'neutral' | 'ok' | 'warn'
}) {
const tones = {
neutral: 'text-slate-400',
ok: 'text-ok-500',
warn: 'text-warn-500',
}
return (
<div className="rounded-lg border border-slate-100 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<Icon name={icon} size={16} className={tones[tone]} />
<p className="hx-eyebrow">{label}</p>
</div>
<p className="hx-data mt-1.5 text-2xl font-bold text-slate-900">{value}</p>
</div>
)
}
function EmptyChart({ text }: { text: string }) {
return (
<p className="rounded-md border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">
{text}
</p>
)
}
@@ -0,0 +1,430 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Curriculum, CourseModule, Lesson } from '../../api/types'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Input,
Spinner,
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, formatDuration } from './courseLabels'
import { LessonEditor } from './LessonEditor'
/**
* Program kursu: drzewo modulow i lekcji.
*
* ZMIANA KOLEJNOSCI: PRZECIAGANIE **I** PRZYCISKI
* ---------------------------------------------------------------------------
* Przeciaganie korzysta z natywnego HTML5 drag and drop, bez zadnej
* biblioteki: dziala w kazdej przegladarce i nic nie dokladamy do zaleznosci.
*
* Przyciski gora i dol nie sa dodatkiem "gdyby cos": natywne przeciaganie jest
* **nieosiagalne z klawiatury**, a na ekranie dotykowym niewygodne. Bez nich
* osoba nieuzywajaca myszy nie zmienilaby kolejnosci w ogole. Kazdy przycisk
* ma etykiete dla czytnika ekranu i normalne pole dotykowe.
*
* Zapis idzie **cala lista naraz**, nie po jednym przesunieciu: docelowy uklad
* i tak znamy, a zapis "przesun o jeden" wymagalby tylu zadan, ile pozycji
* przeskoczyl element.
*/
export function CurriculumSection({ courseId }: { courseId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [newModuleTitle, setNewModuleTitle] = useState('')
const [editingLesson, setEditingLesson] = useState<{ lesson: Lesson | null; moduleId: number } | null>(null)
const [dragged, setDragged] = useState<{ kind: 'module' | 'lesson'; id: number; moduleId?: number } | null>(null)
const curriculum = useQuery({
queryKey: ['courses', 'curriculum', courseId],
queryFn: async () =>
(await client.get<Curriculum>(`/admin/courses/${courseId}/curriculum`)).data,
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['courses', 'curriculum', courseId] })
// liczba lekcji jest na liscie kursow i na karcie
queryClient.invalidateQueries({ queryKey: ['courses'] })
}
const addModule = useMutation({
mutationFn: (title: string) =>
client.post(`/admin/courses/${courseId}/modules`, { title }),
onSuccess: () => {
setNewModuleTitle('')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać modułu')),
})
const renameModule = useMutation({
mutationFn: ({ id, title }: { id: number; title: string }) =>
client.put(`/admin/courses/${courseId}/modules/${id}`, { title }),
onSuccess: invalidate,
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić nazwy')),
})
const deleteModule = useMutation({
mutationFn: (id: number) => client.delete(`/admin/courses/${courseId}/modules/${id}`),
onSuccess: () => {
toasts.success('Moduł usunięty')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć modułu')),
})
const reorderModules = useMutation({
mutationFn: (ids: number[]) =>
client.put(`/admin/courses/${courseId}/modules/order`, { ids }),
onSuccess: invalidate,
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')),
})
const reorderLessons = useMutation({
mutationFn: ({ moduleId, ids }: { moduleId: number; ids: number[] }) =>
client.put(`/admin/courses/${courseId}/modules/${moduleId}/lessons/order`, { ids }),
onSuccess: invalidate,
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')),
})
const deleteLesson = useMutation({
mutationFn: (id: number) => client.delete(`/admin/courses/${courseId}/lessons/${id}`),
onSuccess: () => {
toasts.success('Lekcja usunięta')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć lekcji')),
})
/** Przenosi element listy o podana liczbe pozycji i zwraca nowa kolejnosc. */
const movedOrder = (ids: number[], id: number, delta: number): number[] | null => {
const from = ids.indexOf(id)
const to = from + delta
if (from < 0 || to < 0 || to >= ids.length) {
return null
}
const next = [...ids]
next.splice(to, 0, next.splice(from, 1)[0])
return next
}
const moveModule = (module: CourseModule, delta: number) => {
const ids = (curriculum.data?.modules ?? []).map((m) => m.id)
const next = movedOrder(ids, module.id, delta)
if (next) reorderModules.mutate(next)
}
const moveLesson = (module: CourseModule, lesson: Lesson, delta: number) => {
const ids = module.lessons.map((l) => l.id)
const next = movedOrder(ids, lesson.id, delta)
if (next) reorderLessons.mutate({ moduleId: module.id, ids: next })
}
/** Upuszczenie: przenosi przeciagany element na miejsce elementu docelowego. */
const dropModule = (target: CourseModule) => {
if (!dragged || dragged.kind !== 'module' || dragged.id === target.id) return
const ids = (curriculum.data?.modules ?? []).map((m) => m.id)
const next = ids.filter((id) => id !== dragged.id)
next.splice(ids.indexOf(target.id), 0, dragged.id)
reorderModules.mutate(next)
setDragged(null)
}
const dropLesson = (module: CourseModule, target: Lesson) => {
if (!dragged || dragged.kind !== 'lesson' || dragged.id === target.id) return
// przeciaganie miedzy modulami idzie przez edycje lekcji, nie przez upuszczenie:
// zmiana modulu i kolejnosci naraz mylilaby sie z samym przestawieniem
if (dragged.moduleId !== module.id) {
toasts.error('Lekcję przenosisz między modułami w jej edycji')
setDragged(null)
return
}
const ids = module.lessons.map((l) => l.id)
const next = ids.filter((id) => id !== dragged.id)
next.splice(ids.indexOf(target.id), 0, dragged.id)
reorderLessons.mutate({ moduleId: module.id, ids: next })
setDragged(null)
}
if (curriculum.isPending) return <Spinner />
if (curriculum.isError) {
return <ErrorState message={errorMessage(curriculum.error, 'Nie udało się wczytać programu')} />
}
const modules = curriculum.data?.modules ?? []
return (
<div className="space-y-4">
{modules.length === 0 && (
<EmptyState
icon="file-text"
title="Program jest pusty"
description="Dodaj pierwszy moduł, a w nim lekcje. Kursu bez lekcji nie da się opublikować."
/>
)}
{modules.map((module, moduleIndex) => (
<Card
key={module.id}
className={dragged?.kind === 'module' && dragged.id === module.id ? 'opacity-50' : ''}
>
<div
draggable
onDragStart={() => setDragged({ kind: 'module', id: module.id })}
onDragEnd={() => setDragged(null)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropModule(module)}
className="flex flex-wrap items-center gap-2"
>
<span
aria-hidden="true"
title="Przeciągnij, żeby zmienić kolejność"
className="cursor-grab text-slate-300"
>
<Icon name="menu" size={18} />
</span>
<ModuleTitle
title={module.title}
onSave={(title) => renameModule.mutate({ id: module.id, title })}
/>
<span className="hx-data text-xs text-slate-400">
{module.lessons.length} {module.lessons.length === 1 ? 'lekcja' : 'lekcji'}
</span>
<div className="ml-auto flex items-center gap-1">
<MoveButtons
label={`moduł ${module.title}`}
canUp={moduleIndex > 0}
canDown={moduleIndex < modules.length - 1}
onUp={() => moveModule(module, -1)}
onDown={() => moveModule(module, 1)}
/>
<IconAction
icon="trash"
label={`Usuń moduł ${module.title}`}
onClick={() => {
if (
window.confirm(
`Usunąć moduł „${module.title}”?\n\n` +
`Przepadnie razem z lekcjami: ${module.lessons.length}.`,
)
) {
deleteModule.mutate(module.id)
}
}}
/>
</div>
</div>
<ul className="mt-3 space-y-1.5">
{module.lessons.map((lesson, lessonIndex) => (
<li
key={lesson.id}
draggable
onDragStart={() => setDragged({ kind: 'lesson', id: lesson.id, moduleId: module.id })}
onDragEnd={() => setDragged(null)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropLesson(module, lesson)}
className={`flex flex-wrap items-center gap-2 rounded-md border border-slate-100 p-2.5 ${
dragged?.kind === 'lesson' && dragged.id === lesson.id ? 'opacity-50' : ''
}`}
>
<span aria-hidden="true" className="cursor-grab text-slate-300">
<Icon name="menu" size={16} />
</span>
<Icon name={LESSON_TYPE_ICON[lesson.type]} size={16} className="text-slate-400" />
<button
type="button"
onClick={() => setEditingLesson({ lesson, moduleId: module.id })}
className="min-w-0 flex-1 truncate text-left text-sm font-medium text-slate-900 hover:text-blue-700"
>
{lesson.title}
</button>
<span className="hidden text-xs text-slate-400 sm:inline">
{LESSON_TYPE_LABEL[lesson.type]}
</span>
{lesson.durationMinutes && (
<span className="hx-data hidden text-xs text-slate-400 sm:inline">
{formatDuration(lesson.durationMinutes)}
</span>
)}
{!lesson.required && <Badge tone="neutral">dodatkowa</Badge>}
{lesson.preview && <Badge tone="info">próbka</Badge>}
<div className="ml-auto flex items-center gap-1">
<MoveButtons
label={`lekcję ${lesson.title}`}
canUp={lessonIndex > 0}
canDown={lessonIndex < module.lessons.length - 1}
onUp={() => moveLesson(module, lesson, -1)}
onDown={() => moveLesson(module, lesson, 1)}
/>
<IconAction
icon="trash"
label={`Usuń lekcję ${lesson.title}`}
onClick={() => {
if (window.confirm(`Usunąć lekcję „${lesson.title}”?`)) {
deleteLesson.mutate(lesson.id)
}
}}
/>
</div>
</li>
))}
</ul>
<div className="mt-3">
<Button
variant="secondary"
icon="plus"
onClick={() => setEditingLesson({ lesson: null, moduleId: module.id })}
>
Dodaj lekcję
</Button>
</div>
</Card>
))}
<Card title="Nowy moduł">
<form
onSubmit={(e) => {
e.preventDefault()
if (newModuleTitle.trim()) addModule.mutate(newModuleTitle.trim())
}}
className="flex flex-wrap gap-2"
>
<Input
className="min-w-0 flex-1"
value={newModuleTitle}
onChange={(e) => setNewModuleTitle(e.target.value)}
placeholder="np. Podstawy prawne"
aria-label="Tytuł nowego modułu"
/>
<Button type="submit" icon="plus" disabled={!newModuleTitle.trim() || addModule.isPending}>
Dodaj moduł
</Button>
</form>
</Card>
{editingLesson && (
<LessonEditor
courseId={courseId}
moduleId={editingLesson.moduleId}
lesson={editingLesson.lesson}
modules={modules}
onClose={() => setEditingLesson(null)}
onSaved={() => {
setEditingLesson(null)
invalidate()
}}
/>
)}
</div>
)
}
/** Nazwa modulu edytowana w miejscu, bez otwierania osobnego okna. */
function ModuleTitle({ title, onSave }: { title: string; onSave: (title: string) => void }) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(title)
if (!editing) {
return (
<button
type="button"
onClick={() => {
setValue(title)
setEditing(true)
}}
className="text-sm font-semibold text-slate-900 hover:text-blue-700"
title="Kliknij, żeby zmienić nazwę"
>
{title}
</button>
)
}
const commit = () => {
const next = value.trim()
if (next && next !== title) onSave(next)
setEditing(false)
}
return (
<input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit()
if (e.key === 'Escape') setEditing(false)
}}
aria-label="Nazwa modułu"
className="min-h-[36px] rounded-md border border-slate-200 px-2 text-sm font-semibold text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-[3px] focus:ring-blue-500/45"
/>
)
}
/**
* Przyciski gora i dol.
*
* Jedyna droga zmiany kolejnosci dla osoby nieuzywajacej myszy: natywne
* przeciaganie jest z klawiatury nieosiagalne. Etykieta mowi wprost, co
* przesuwa, bo sama strzalka nic czytnikowi ekranu nie mowi.
*/
function MoveButtons({
label,
canUp,
canDown,
onUp,
onDown,
}: {
label: string
canUp: boolean
canDown: boolean
onUp: () => void
onDown: () => void
}) {
return (
<>
<IconAction icon="chevron-up" label={`Przesuń ${label} w górę`} onClick={onUp} disabled={!canUp} />
<IconAction icon="chevron-down" label={`Przesuń ${label} w dół`} onClick={onDown} disabled={!canDown} />
</>
)
}
function IconAction({
icon,
label,
onClick,
disabled = false,
}: {
icon: Parameters<typeof Icon>[0]['name']
label: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent"
>
<Icon name={icon} size={16} />
</button>
)
}
+324
View File
@@ -0,0 +1,324 @@
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { CourseModule, Lesson, LessonType, Page, Webinar } from '../../api/types'
import { Alert, Button, Field, Input, Select } from '../../components/ui'
import { ContentEditor } from '../../components/ContentEditor'
import { MediaField } from '../../components/MediaField'
import { Modal } from '../../components/Modal'
import { useToasts } from '../../components/Toasts'
import { LESSON_TYPE_LABEL } from './courseLabels'
import { QuizEditor } from './QuizEditor'
type Form = {
title: string
type: LessonType
moduleId: number
contentHtml: string
mediaId: string
externalVideoUrl: string
webinarId: string
durationMinutes: string
required: boolean
preview: boolean
}
/**
* Edycja lekcji.
*
* POLA ZALEZA OD RODZAJU LEKCJI
* ---------------------------------------------------------------------------
* Pokazywanie wszystkich pol naraz zmuszaloby do zgadywania, ktore dotycza
* wybranego rodzaju. Zamiast tego widac wylacznie to, co ma znaczenie:
* nagranie chce pliku albo adresu, tekst edytora, webinar listy wydarzen.
*
* Wartosci pol nieuzywanych **nie sa czyszczone przy przelaczaniu rodzaju**:
* ktos, kto pomylil sie w wyborze i wraca, nie traci wpisanej tresci. Backend
* i tak zapisuje tylko to, co pasuje do rodzaju.
*/
export function LessonEditor({
courseId,
moduleId,
lesson,
modules,
onClose,
onSaved,
}: {
courseId: number
moduleId: number
lesson: Lesson | null
modules: CourseModule[]
onClose: () => void
onSaved: () => void
}) {
const toasts = useToasts()
const editing = Boolean(lesson)
const [form, setForm] = useState<Form>({
title: lesson?.title ?? '',
type: lesson?.type ?? 'TEXT',
moduleId: lesson?.moduleId ?? moduleId,
contentHtml: lesson?.contentHtml ?? '',
mediaId: lesson?.mediaId ? String(lesson.mediaId) : '',
externalVideoUrl: lesson?.externalVideoUrl ?? '',
webinarId: lesson?.webinarId ? String(lesson.webinarId) : '',
durationMinutes: lesson?.durationMinutes ? String(lesson.durationMinutes) : '',
required: lesson?.required ?? true,
preview: lesson?.preview ?? false,
})
const [error, setError] = useState<string | null>(null)
const [quizOpen, setQuizOpen] = useState(false)
const update = <K extends keyof Form>(key: K, value: Form[K]) =>
setForm((prev) => ({ ...prev, [key]: value }))
// lista webinarow potrzebna wylacznie przy lekcji typu WEBINAR
const webinars = useQuery({
queryKey: ['courses', 'webinars-for-lesson'],
queryFn: async () =>
(await client.get<Page<Webinar>>('/admin/webinars?size=100&sort=startsAt&direction=desc')).data,
enabled: form.type === 'WEBINAR',
})
const save = useMutation({
mutationFn: async () => {
const body = {
title: form.title,
type: form.type,
moduleId: form.moduleId,
// wysylamy wylacznie to, co pasuje do rodzaju: backend i tak odrzuci
// webinar w lekcji tekstowej, ale nie ma powodu go tam wysylac
contentHtml: form.type === 'TEXT' ? form.contentHtml || null : null,
mediaId:
(form.type === 'VIDEO' || form.type === 'FILE') && form.mediaId
? Number(form.mediaId)
: null,
externalVideoUrl:
form.type === 'VIDEO' && form.externalVideoUrl ? form.externalVideoUrl : null,
webinarId: form.type === 'WEBINAR' && form.webinarId ? Number(form.webinarId) : null,
durationMinutes: form.durationMinutes ? Number(form.durationMinutes) : null,
required: form.required,
preview: form.preview,
}
return editing
? client.put(`/admin/courses/${courseId}/lessons/${lesson!.id}`, body)
: client.post(`/admin/courses/${courseId}/lessons`, body)
},
onSuccess: () => {
toasts.success(editing ? 'Lekcja zapisana' : 'Lekcja dodana')
onSaved()
},
onError: (err) => setError(errorMessage(err, 'Nie udało się zapisać lekcji')),
})
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
setError(null)
if (!form.title.trim()) {
setError('Tytuł lekcji jest wymagany')
return
}
save.mutate()
}
return (
<Modal title={editing ? 'Edycja lekcji' : 'Nowa lekcja'} onClose={onClose}>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{error && <Alert tone="alarm">{error}</Alert>}
<Field label="Tytuł lekcji">
<Input
value={form.title}
onChange={(e) => update('title', e.target.value)}
placeholder="np. Trzy warstwy oceny ryzyka"
required
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Rodzaj">
<Select
value={form.type}
onChange={(e) => update('type', e.target.value as LessonType)}
>
{(Object.keys(LESSON_TYPE_LABEL) as LessonType[]).map((type) => (
<option key={type} value={type}>
{LESSON_TYPE_LABEL[type]}
</option>
))}
</Select>
</Field>
<Field label="Moduł" hint="Zmiana przenosi lekcję do innego modułu.">
<Select
value={String(form.moduleId)}
onChange={(e) => update('moduleId', Number(e.target.value))}
>
{modules.map((module) => (
<option key={module.id} value={module.id}>
{module.title}
</option>
))}
</Select>
</Field>
</div>
{/* --- pola zalezne od rodzaju --- */}
{form.type === 'TEXT' && (
<Field label="Treść lekcji" wide>
<ContentEditor value={form.contentHtml} onChange={(html) => update('contentHtml', html)} />
</Field>
)}
{form.type === 'VIDEO' && (
<>
<Field
label="Nagranie z biblioteki"
hint="Plik we własnym składzie. Zostaw puste, jeśli podajesz adres zewnętrzny."
>
<MediaField value={form.mediaId} onChange={(value) => update('mediaId', value)} />
</Field>
<Field
label="Albo adres zewnętrzny"
hint="Dla nagrań trzymanych poza systemem. Wyłącznie http:// albo https://"
>
<Input
type="url"
value={form.externalVideoUrl}
onChange={(e) => update('externalVideoUrl', e.target.value)}
placeholder="https://..."
/>
</Field>
</>
)}
{form.type === 'FILE' && (
<Field label="Plik do pobrania" hint="Wybierz z biblioteki mediów.">
<MediaField value={form.mediaId} onChange={(value) => update('mediaId', value)} />
</Field>
)}
{form.type === 'WEBINAR' && (
<Field label="Webinar" hint="Wydarzenie na żywo będące tą lekcją.">
{webinars.isPending ? (
<p className="text-sm text-slate-500">Wczytywanie listy webinarów...</p>
) : (
<Select
value={form.webinarId}
onChange={(e) => update('webinarId', e.target.value)}
>
<option value="">Nie wybrano</option>
{(webinars.data?.content ?? []).map((webinar) => (
<option key={webinar.id} value={webinar.id}>
{webinar.title}
</option>
))}
</Select>
)}
</Field>
)}
{form.type === 'QUIZ' && (
editing ? (
/*
* Quiz edytuje sie po zapisaniu lekcji: zanim lekcja istnieje,
* nie ma do czego przypiac pytan. Osobne okno, bo pytan bywa
* kilkadziesiat i nie zmieszcza sie obok pol lekcji.
*/
<div className="rounded-md border border-slate-200 p-3">
<p className="text-sm font-medium text-slate-900">Sprawdzian</p>
<p className="mt-0.5 text-xs text-slate-500">
Pytania, odpowiedzi, próg zaliczenia, limit czasu i podejść.
</p>
<Button
type="button"
variant="secondary"
icon="settings"
className="mt-2"
onClick={() => setQuizOpen(true)}
>
Otwórz edytor sprawdzianu
</Button>
</div>
) : (
<Alert tone="info">
Pytania sprawdzianu dodasz po zapisaniu lekcji: zanim lekcja istnieje, nie ma do
czego ich przypiąć.
</Alert>
)
)}
{/* --- wspolne --- */}
<Field label="Czas trwania" hint="W minutach. Puste oznacza, że lekcja go nie podaje.">
<Input
type="number"
min={1}
value={form.durationMinutes}
onChange={(e) => update('durationMinutes', e.target.value)}
/>
</Field>
<div className="space-y-2">
<Toggle
checked={form.required}
onChange={(value) => update('required', value)}
title="Lekcja wymagana"
description="Liczy się do progu zaliczenia kursu. Materiały dodatkowe zostaw odznaczone: nie wpływają na wynik."
/>
<Toggle
checked={form.preview}
onChange={(value) => update('preview', value)}
title="Próbka dostępna bez zapisu"
description="To jedyna droga, którą treść kursu wychodzi na zewnątrz. Włączaj świadomie."
/>
</div>
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
<Button variant="secondary" type="button" onClick={onClose}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz lekcję'}
</Button>
</div>
</form>
{quizOpen && lesson && (
<QuizEditor
courseId={courseId}
lessonId={lesson.id}
onClose={() => setQuizOpen(false)}
/>
)}
</Modal>
)
}
function Toggle({
checked,
onChange,
title,
description,
}: {
checked: boolean
onChange: (value: boolean) => void
title: string
description: string
}) {
return (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
<strong className="text-slate-900">{title}</strong>
<span className="mt-0.5 block text-xs text-slate-500">{description}</span>
</span>
</label>
)
}
+355
View File
@@ -0,0 +1,355 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Course, ProgressRow } from '../../api/types'
import { Icon } from '../../components/Icon'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Input,
Select,
Spinner,
} from '../../components/ui'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
import { panelPath } from '../../lib/routes'
/** Gotowe przedzialy postepu, zeby nie kazac wpisywac liczb recznie. */
const RANGES: { id: string; label: string; min?: number; max?: number }[] = [
{ id: '', label: 'Dowolny postęp' },
{ id: 'none', label: 'Bez postępu (0%)', min: 0, max: 0 },
{ id: 'started', label: 'Rozpoczęte (1-49%)', min: 1, max: 49 },
{ id: 'half', label: 'Powyżej połowy (50-99%)', min: 50, max: 99 },
{ id: 'done', label: 'Ukończone (100%)', min: 100, max: 100 },
]
/**
* Przekrojowa lista postepow, dla wszystkich kursow naraz.
*
* Ekran dla prowadzacego, ktory chce zobaczyc, kto utknal: stad wyroznienie
* zapisow bez aktywnosci i sortowanie od najnowszej aktywnosci.
*/
export function ProgressPage() {
const toasts = useToasts()
const [courseId, setCourseId] = useState('')
const [status, setStatus] = useState('')
const [range, setRange] = useState('')
const [search, setSearch] = useState('')
const [exporting, setExporting] = useState(false)
const selectedRange = RANGES.find((r) => r.id === range)
const courses = useQuery({
queryKey: ['courses', 'for-filter'],
queryFn: async () =>
(await client.get<{ content: Course[] }>('/admin/courses', { params: { size: 100 } }))
.data.content,
})
const settings = useQuery({
queryKey: ['progress-settings'],
queryFn: async () =>
(await client.get<{ inactivityDays: number }>('/admin/academy/progress/settings')).data,
})
const params = {
courseId: courseId || undefined,
status: status || undefined,
search: search || undefined,
minPercent: selectedRange?.min,
maxPercent: selectedRange?.max,
}
const { data, isPending, isError, error } = useQuery({
queryKey: ['progress', courseId, status, range, search],
queryFn: async () =>
(await client.get<ProgressRow[]>('/admin/academy/progress', { params })).data,
})
const exportCsv = async () => {
setExporting(true)
try {
const response = await client.get('/admin/academy/progress/export', {
params,
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = 'postepy.csv'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (e) {
toasts.error(errorMessage(e, 'Nie udało się pobrać pliku'))
} finally {
setExporting(false)
}
}
const rows = data ?? []
const inactiveCount = rows.filter((r) => r.inactive).length
const inactivityDays = settings.data?.inactivityDays ?? 14
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">Postępy</h1>
<p className="text-sm text-slate-500">
Zapisy na wszystkie kursy, od ostatnio aktywnych.
</p>
</div>
<Button variant="secondary" icon="download" onClick={exportCsv} disabled={exporting}>
{exporting ? 'Przygotowuję...' : 'Eksport CSV'}
</Button>
</div>
<Card>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-course">
Kurs
</label>
<Select
id="progress-course"
className="w-full sm:w-64"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
>
<option value="">Wszystkie kursy</option>
{(courses.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-status">
Status
</label>
<Select
id="progress-status"
className="w-full sm:w-44"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="">Wszystkie</option>
<option value="ACTIVE">Aktywne</option>
<option value="COMPLETED">Ukończone</option>
<option value="CANCELLED">Anulowane</option>
<option value="EXPIRED">Wygasłe</option>
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-range">
Postęp
</label>
<Select
id="progress-range"
className="w-full sm:w-52"
value={range}
onChange={(e) => setRange(e.target.value)}
>
{RANGES.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-search">
Szukaj
</label>
<Input
id="progress-search"
className="w-full sm:w-56"
placeholder="Nazwisko albo adres"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<p className="ml-auto text-sm text-slate-500">
Znaleziono: <strong className="hx-data">{rows.length}</strong>
</p>
</div>
</Card>
{inactiveCount > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-warn-500/25 bg-warn-50 p-3 text-sm text-warn-600">
<Icon name="alert-triangle" size={18} className="mt-0.5 shrink-0" />
<p className="leading-relaxed">
<strong className="hx-data">{inactiveCount}</strong>{' '}
{inactiveCount === 1 ? 'zapis nie ma' : 'zapisów nie ma'} postępu od ponad{' '}
<span className="hx-data">{inactivityDays}</span> dni. Próg zmienisz w ustawieniach.
</p>
</div>
)}
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{isPending && (
<div className="p-5">
<Spinner />
</div>
)}
{isError && (
<div className="p-5">
<ErrorState message={errorMessage(error, 'Nie udało się wczytać postępów')} />
</div>
)}
{data && rows.length === 0 && (
<div className="p-5">
<EmptyState
icon="check-circle"
title="Brak zapisów"
description="Żaden zapis nie spełnia wybranych warunków."
/>
</div>
)}
{rows.length > 0 && (
<>
<ul className="divide-y divide-slate-100 lg:hidden">
{rows.map((row) => (
<li key={row.enrollmentId} className={row.inactive ? 'bg-warn-50/40 p-4' : 'p-4'}>
<div className="flex flex-wrap items-center gap-2">
<Link
to={panelPath(`/kursy/kursanci/${row.contactId}`)}
className="text-sm font-medium text-slate-900 hover:text-blue-600"
>
{row.studentName}
</Link>
{row.inactive && <Badge tone="warn">bez aktywności</Badge>}
</div>
<p className="mt-0.5 text-xs text-slate-500">{row.courseTitle}</p>
<div className="mt-2 max-w-xs">
<ProgressBar row={row} />
</div>
<p className="hx-data mt-2 text-xs text-slate-500">
{row.lastActivityAt ? formatLocal(row.lastActivityAt) : '-'}
{row.lastQuizScore !== null && ` · quiz ${row.lastQuizScore}%`}
</p>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
<th className="px-4 py-2.5">Kursant</th>
<th className="px-4 py-2.5">Kurs</th>
<th className="px-4 py-2.5">Status</th>
{/*
Postep i wynik quizu NIE sa sortowalne: powstaja
z podzapytan, a sortowanie samej pobranej listy
wprowadzaloby w blad przy wiekszej liczbie zapisow.
*/}
<th className="px-4 py-2.5 w-56">Postęp</th>
<th className="px-4 py-2.5 text-right">Quiz</th>
<th className="px-4 py-2.5">Ostatnia aktywność</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.enrollmentId}
className={`border-b border-slate-50 transition duration-fast last:border-b-0 hover:bg-slate-25 ${
row.inactive ? 'bg-warn-50/40' : ''
}`}
>
<td className="px-4 py-3">
<Link
to={panelPath(`/kursy/kursanci/${row.contactId}`)}
className="font-medium text-slate-900 hover:text-blue-600"
>
{row.studentName}
</Link>
<p className="hx-data text-xs text-slate-500">{row.email}</p>
</td>
<td className="px-4 py-3">
<Link
to={panelPath(`/kursy/${row.courseId}`)}
className="text-slate-700 hover:text-blue-600"
>
{row.courseTitle}
</Link>
</td>
<td className="px-4 py-3">
<Badge tone={ENROLLMENT_STATUS_TONE[row.status]}>
{ENROLLMENT_STATUS_LABEL[row.status]}
</Badge>
</td>
<td className="px-4 py-3">
<ProgressBar row={row} />
</td>
<td className="px-4 py-3 text-right">
{row.lastQuizScore === null ? (
<span className="text-slate-400">-</span>
) : (
<span
className={`hx-data ${
row.lastQuizPassed ? 'text-ok-600' : 'text-alarm-600'
}`}
>
{row.lastQuizScore}%
</span>
)}
</td>
<td className="px-4 py-3">
<span className="hx-data text-slate-600">
{row.lastActivityAt ? formatLocal(row.lastActivityAt) : '-'}
</span>
{row.inactive && (
<Badge tone="warn" className="ml-2">
bez aktywności
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
)
}
function ProgressBar({ row }: { row: ProgressRow }) {
return (
<div>
<div className="mb-1 flex items-center justify-between text-xs text-slate-600">
<span>
{row.completedRequired}/{row.totalRequired}
</span>
<span className="hx-data font-medium text-slate-900">{row.progressPercent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full ${
row.progressPercent === 100 ? 'bg-ok-500' : 'bg-blue-500'
}`}
style={{ width: `${row.progressPercent}%` }}
/>
</div>
</div>
)
}
+840
View File
@@ -0,0 +1,840 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Quiz, QuizImportResult, QuizQuestionAdmin, QuizStats } from '../../api/types'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Input,
Spinner,
Textarea,
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { useToasts } from '../../components/Toasts'
type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad'
/**
* Edytor quizu, otwierany z lekcji typu Sprawdzian.
*
* Cztery zakladki, bo to cztery rozne zadania: uklada sie pytania, ustawia
* zasady, oglada wyniki albo sprawdza, jak test wyglada oczami kursanta.
* Wszystko w jednym oknie oznaczaloby przewijanie przez kilkadziesiat pytan
* za kazdym razem, gdy trzeba poprawic limit czasu.
*/
export function QuizEditor({
courseId,
lessonId,
onClose,
}: {
courseId: number
lessonId: number
onClose: () => void
}) {
const [tab, setTab] = useState<Tab>('pytania')
const base = `/admin/courses/${courseId}/lessons/${lessonId}/quiz`
const quiz = useQuery({
queryKey: ['quiz', courseId, lessonId],
queryFn: async () => (await client.get<Quiz>(base)).data,
})
return (
<Modal title="Sprawdzian" onClose={onClose} wide>
{quiz.isPending && <Spinner />}
{quiz.isError && (
<ErrorState message={errorMessage(quiz.error, 'Nie udało się wczytać quizu')} />
)}
{quiz.data && (
<div className="space-y-4">
{quiz.data.questionsInvalid > 0 && (
<Alert tone="warn">
<strong>
{quiz.data.questionsInvalid}{' '}
{quiz.data.questionsInvalid === 1 ? 'pytanie wymaga' : 'pytania wymagają'} poprawy.
</strong>{' '}
Każde pytanie musi mieć dokładnie jedną odpowiedź poprawną i co najmniej dwie
odpowiedzi. Kursu z takim pytaniem nie da się opublikować.
</Alert>
)}
<nav role="tablist" aria-label="Sekcje quizu" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{(
[
['pytania', 'Pytania', 'file-text', quiz.data.questionsTotal],
['ustawienia', 'Ustawienia', 'settings', null],
['statystyki', 'Statystyki', 'presentation', null],
['podglad', 'Podgląd', 'eye', null],
] as const
).map(([value, label, icon, count]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
tab === value
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<Icon name={icon} size={16} />
{label}
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
</button>
))}
</nav>
{tab === 'pytania' && <QuestionsTab base={base} courseId={courseId} lessonId={lessonId} />}
{tab === 'ustawienia' && (
<SettingsTab base={base} courseId={courseId} lessonId={lessonId} quiz={quiz.data} />
)}
{tab === 'statystyki' && <StatsTab base={base} />}
{tab === 'podglad' && <PreviewTab base={base} quiz={quiz.data} />}
</div>
)}
</Modal>
)
}
// --- pytania ---
type AnswerDraft = { content: string; correct: boolean }
type QuestionDraft = { content: string; explanation: string; answers: AnswerDraft[]; active: boolean }
const EMPTY_DRAFT: QuestionDraft = {
content: '',
explanation: '',
// dwie puste odpowiedzi od razu: minimum wymagane regula, wiec zaczynamy od niego
answers: [
{ content: '', correct: true },
{ content: '', correct: false },
],
active: true,
}
function QuestionsTab({
base,
courseId,
lessonId,
}: {
base: string
courseId: number
lessonId: number
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const fileRef = useRef<HTMLInputElement>(null)
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
const [importResult, setImportResult] = useState<QuizImportResult | null>(null)
const questions = useQuery({
queryKey: ['quiz', courseId, lessonId, 'questions'],
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${base}/questions`)).data,
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] })
}
const save = useMutation({
mutationFn: ({ id, draft }: { id: number | null; draft: QuestionDraft }) => {
const body = {
content: draft.content,
explanation: draft.explanation || null,
active: draft.active,
answers: draft.answers.map((a) => ({ content: a.content, correct: a.correct })),
}
return id === null
? client.post(`${base}/questions`, body)
: client.put(`${base}/questions/${id}`, body)
},
onSuccess: () => {
setEditing(null)
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać pytania')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`${base}/questions/${id}`),
onSuccess: invalidate,
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć pytania')),
})
const reorder = useMutation({
mutationFn: (ids: number[]) => client.put(`${base}/questions/order`, { ids }),
onSuccess: invalidate,
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')),
})
const upload = useMutation({
mutationFn: async (file: File) => {
const form = new FormData()
form.append('file', file)
return (await client.post<QuizImportResult>(`${base}/questions/import`, form)).data
},
onSuccess: (result) => {
setImportResult(result)
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wczytać pliku')),
})
/** Przesuwa pytanie o jedna pozycje. Jedyna droga dla osoby bez myszy. */
const move = (id: number, delta: number) => {
const ids = (questions.data ?? []).map((q) => q.id)
const from = ids.indexOf(id)
const to = from + delta
if (from < 0 || to < 0 || to >= ids.length) return
const next = [...ids]
next.splice(to, 0, next.splice(from, 1)[0])
reorder.mutate(next)
}
if (questions.isPending) return <Spinner />
if (questions.isError) {
return <ErrorState message={errorMessage(questions.error, 'Nie udało się wczytać pytań')} />
}
const list = questions.data ?? []
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button icon="plus" onClick={() => setEditing({ id: null, draft: EMPTY_DRAFT })}>
Dodaj pytanie
</Button>
<Button variant="secondary" icon="upload" onClick={() => fileRef.current?.click()}>
Import z CSV
</Button>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) upload.mutate(file)
e.target.value = ''
}}
/>
</div>
<details className="rounded-md border border-slate-200 p-3 text-sm">
<summary className="cursor-pointer font-medium text-slate-700">Format pliku CSV</summary>
<div className="mt-2 space-y-1 text-slate-600">
<p>Jedno pytanie w wierszu, kolumny rozdzielone średnikiem:</p>
<p className="hx-data rounded bg-slate-50 p-2 text-xs">
Treść pytania;odpowiedź;*odpowiedź poprawna;kolejna;#wyjaśnienie
</p>
<p>
Gwiazdka na początku oznacza odpowiedź poprawną, krzyżyk wyjaśnienie. Wiersz z błędem
jest pomijany, reszta pliku wczytuje się normalnie.
</p>
</div>
</details>
{importResult && (
<Alert tone={importResult.skipped > 0 ? 'warn' : 'ok'}>
<p>
Wczytano pytań: <strong>{importResult.imported}</strong>
{importResult.skipped > 0 && <>, pominięto: <strong>{importResult.skipped}</strong></>}
</p>
{importResult.problems.length > 0 && (
<ul className="mt-1 list-disc pl-5 text-xs">
{importResult.problems.slice(0, 5).map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
)}
</Alert>
)}
{list.length === 0 && (
<EmptyState
icon="file-text"
title="Quiz nie ma jeszcze pytań"
description="Dodaj pytanie ręcznie albo wczytaj listę z pliku CSV."
/>
)}
<ul className="space-y-2">
{list.map((question, index) => (
<li
key={question.id}
className={`rounded-md border p-3 ${
question.valid ? 'border-slate-100' : 'border-warn-500/40 bg-warn-50'
}`}
>
<div className="flex flex-wrap items-start gap-2">
<span className="hx-data mt-0.5 text-xs text-slate-400">{index + 1}.</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-900">{question.content}</p>
<ul className="mt-1 space-y-0.5">
{question.answers.map((answer) => (
<li
key={answer.id}
className={`flex items-center gap-1.5 text-xs ${
answer.correct ? 'font-medium text-ok-600' : 'text-slate-500'
}`}
>
<Icon name={answer.correct ? 'check-circle' : 'x'} size={13} />
{answer.content}
</li>
))}
</ul>
{!question.valid && (
<p className="mt-1 text-xs font-medium text-warn-600">
Wymagana dokładnie jedna odpowiedź poprawna i co najmniej dwie odpowiedzi.
</p>
)}
</div>
<div className="flex items-center gap-1">
{!question.active && <Badge tone="neutral">wyłączone</Badge>}
<IconAction
icon="chevron-up"
label={`Przesuń pytanie ${index + 1} w górę`}
disabled={index === 0}
onClick={() => move(question.id, -1)}
/>
<IconAction
icon="chevron-down"
label={`Przesuń pytanie ${index + 1} w dół`}
disabled={index === list.length - 1}
onClick={() => move(question.id, 1)}
/>
<IconAction
icon="pencil"
label={`Edytuj pytanie ${index + 1}`}
onClick={() =>
setEditing({
id: question.id,
draft: {
content: question.content,
explanation: question.explanation ?? '',
active: question.active,
answers: question.answers.map((a) => ({
content: a.content,
correct: a.correct,
})),
},
})
}
/>
<IconAction
icon="trash"
label={`Usuń pytanie ${index + 1}`}
onClick={() => {
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id)
}}
/>
</div>
</div>
</li>
))}
</ul>
{editing && (
<QuestionForm
draft={editing.draft}
pending={save.isPending}
onCancel={() => setEditing(null)}
onSave={(draft) => save.mutate({ id: editing.id, draft })}
/>
)}
</div>
)
}
/** Formularz pytania. Poprawna odpowiedz wybierana jednym przelacznikiem. */
function QuestionForm({
draft,
pending,
onCancel,
onSave,
}: {
draft: QuestionDraft
pending: boolean
onCancel: () => void
onSave: (draft: QuestionDraft) => void
}) {
const [form, setForm] = useState<QuestionDraft>(draft)
const [error, setError] = useState<string | null>(null)
const setAnswer = (index: number, content: string) =>
setForm((prev) => ({
...prev,
answers: prev.answers.map((a, i) => (i === index ? { ...a, content } : a)),
}))
/*
* Wybor poprawnej odpowiedzi zeruje pozostale: regula dopuszcza dokladnie
* jedna, wiec pola wyboru zachowuja sie jak przelacznik, a nie jak zestaw
* niezaleznych pol. Bez tego uzytkownik zaznaczylby dwie i dowiedzial sie
* o bledzie dopiero przy zapisie.
*/
const setCorrect = (index: number) =>
setForm((prev) => ({
...prev,
answers: prev.answers.map((a, i) => ({ ...a, correct: i === index })),
}))
const addAnswer = () =>
setForm((prev) => ({ ...prev, answers: [...prev.answers, { content: '', correct: false }] }))
const removeAnswer = (index: number) =>
setForm((prev) => {
const answers = prev.answers.filter((_, i) => i !== index)
// usuniecie poprawnej zostawiloby pytanie bez klucza: przenosimy na pierwsza
if (!answers.some((a) => a.correct) && answers.length > 0) {
answers[0] = { ...answers[0], correct: true }
}
return { ...prev, answers }
})
const submit = (event: React.FormEvent) => {
event.preventDefault()
setError(null)
if (!form.content.trim()) {
setError('Treść pytania jest wymagana')
return
}
const filled = form.answers.filter((a) => a.content.trim())
if (filled.length < 2) {
setError('Pytanie musi mieć co najmniej dwie odpowiedzi')
return
}
if (filled.filter((a) => a.correct).length !== 1) {
setError('Zaznacz dokładnie jedną odpowiedź poprawną')
return
}
onSave({ ...form, answers: filled })
}
return (
<form onSubmit={submit} noValidate className="space-y-3 rounded-md border border-blue-200 bg-blue-50/40 p-3">
{error && <Alert tone="alarm">{error}</Alert>}
<Field label="Treść pytania">
<Textarea
rows={2}
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
autoFocus
/>
</Field>
<div>
<p className="mb-1.5 text-sm font-semibold text-slate-800">
Odpowiedzi <span className="font-normal text-slate-500">(zaznacz poprawną)</span>
</p>
<ul className="space-y-2">
{form.answers.map((answer, index) => (
<li key={index} className="flex items-center gap-2">
<input
type="radio"
name="correct-answer"
checked={answer.correct}
onChange={() => setCorrect(index)}
aria-label={`Odpowiedź ${index + 1} jest poprawna`}
className="h-4 w-4 shrink-0 accent-ok-500"
/>
<Input
className="min-w-0 flex-1"
value={answer.content}
onChange={(e) => setAnswer(index, e.target.value)}
placeholder={`Odpowiedź ${index + 1}`}
aria-label={`Treść odpowiedzi ${index + 1}`}
/>
<IconAction
icon="trash"
label={`Usuń odpowiedź ${index + 1}`}
disabled={form.answers.length <= 2}
onClick={() => removeAnswer(index)}
/>
</li>
))}
</ul>
<Button type="button" variant="ghost" icon="plus" onClick={addAnswer} className="mt-1">
Dodaj odpowiedź
</Button>
</div>
<Field label="Wyjaśnienie" hint="Pokazywane po zakończeniu, gdy quiz pokazuje odpowiedzi.">
<Textarea
rows={2}
value={form.explanation}
onChange={(e) => setForm({ ...form, explanation: e.target.value })}
/>
</Field>
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm({ ...form, active: e.target.checked })}
className="h-4 w-4 accent-blue-500"
/>
Pytanie aktywne, bierze udział w losowaniu
</label>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={onCancel}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={pending}>
{pending ? 'Zapisywanie...' : 'Zapisz pytanie'}
</Button>
</div>
</form>
)
}
// --- ustawienia ---
function SettingsTab({
base,
courseId,
lessonId,
quiz,
}: {
base: string
courseId: number
lessonId: number
quiz: Quiz
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [form, setForm] = useState({
title: quiz.title,
description: quiz.description ?? '',
questionCount: quiz.questionCount === null ? '' : String(quiz.questionCount),
passThresholdPercent:
quiz.passThresholdPercent === null ? '' : String(quiz.passThresholdPercent),
timeLimitMinutes: quiz.timeLimitMinutes === null ? '' : String(quiz.timeLimitMinutes),
maxAttempts: quiz.maxAttempts === null ? '' : String(quiz.maxAttempts),
retryDelayMinutes: quiz.retryDelayMinutes === null ? '' : String(quiz.retryDelayMinutes),
shuffleQuestions: quiz.shuffleQuestions,
shuffleAnswers: quiz.shuffleAnswers,
showCorrectAnswers: quiz.showCorrectAnswers,
})
const save = useMutation({
mutationFn: () =>
client.put(base, {
title: form.title,
description: form.description || null,
questionCount: form.questionCount === '' ? null : Number(form.questionCount),
passThresholdPercent:
form.passThresholdPercent === '' ? null : Number(form.passThresholdPercent),
timeLimitMinutes: form.timeLimitMinutes === '' ? null : Number(form.timeLimitMinutes),
maxAttempts: form.maxAttempts === '' ? null : Number(form.maxAttempts),
retryDelayMinutes: form.retryDelayMinutes === '' ? null : Number(form.retryDelayMinutes),
shuffleQuestions: form.shuffleQuestions,
shuffleAnswers: form.shuffleAnswers,
showCorrectAnswers: form.showCorrectAnswers,
}),
onSuccess: () => {
toasts.success('Ustawienia zapisane')
queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
save.mutate()
}}
noValidate
className="space-y-4"
>
<Field label="Tytuł sprawdzianu">
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</Field>
<Field label="Opis" hint="Pokazywany kursantowi przed rozpoczęciem.">
<Textarea
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="Ile pytań losować"
hint={`Puste = wszystkie aktywne (teraz ${quiz.questionsActive}).`}
>
<Input
type="number"
min={1}
value={form.questionCount}
onChange={(e) => setForm({ ...form, questionCount: e.target.value })}
placeholder="wszystkie"
/>
</Field>
<Field
label="Próg zaliczenia (%)"
hint={`Puste = próg kursu, czyli ${quiz.effectiveThreshold}%.`}
>
<Input
type="number"
min={0}
max={100}
value={form.passThresholdPercent}
onChange={(e) => setForm({ ...form, passThresholdPercent: e.target.value })}
placeholder={`${quiz.effectiveThreshold}`}
/>
</Field>
<Field label="Limit czasu (minuty)" hint="Puste = bez limitu. Pilnuje go serwer.">
<Input
type="number"
min={1}
value={form.timeLimitMinutes}
onChange={(e) => setForm({ ...form, timeLimitMinutes: e.target.value })}
placeholder="bez limitu"
/>
</Field>
<Field label="Liczba podejść" hint="Puste = bez limitu.">
<Input
type="number"
min={1}
value={form.maxAttempts}
onChange={(e) => setForm({ ...form, maxAttempts: e.target.value })}
placeholder="bez limitu"
/>
</Field>
<Field
label="Odstęp między podejściami (minuty)"
hint="Liczony od zakończenia poprzedniego podejścia."
>
<Input
type="number"
min={0}
value={form.retryDelayMinutes}
onChange={(e) => setForm({ ...form, retryDelayMinutes: e.target.value })}
placeholder="brak"
/>
</Field>
</div>
<div className="space-y-2">
<Toggle
checked={form.shuffleQuestions}
onChange={(v) => setForm({ ...form, shuffleQuestions: v })}
title="Losuj kolejność pytań"
description="Zestaw jest losowany raz, przy starcie podejścia, i nie zmienia się po odświeżeniu."
/>
<Toggle
checked={form.shuffleAnswers}
onChange={(v) => setForm({ ...form, shuffleAnswers: v })}
title="Losuj kolejność odpowiedzi"
description="Utrudnia przepisywanie odpowiedzi od innego kursanta."
/>
<Toggle
checked={form.showCorrectAnswers}
onChange={(v) => setForm({ ...form, showCorrectAnswers: v })}
title="Pokaż poprawne odpowiedzi po zakończeniu"
description="Przy quizie z wieloma podejściami warto wyłączyć: inaczej pierwsze podejście służy do zebrania klucza."
/>
</div>
<div className="flex justify-end">
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</Button>
</div>
</form>
)
}
// --- statystyki ---
function StatsTab({ base }: { base: string }) {
const stats = useQuery({
queryKey: [base, 'stats'],
queryFn: async () => (await client.get<QuizStats>(`${base}/stats`)).data,
})
if (stats.isPending) return <Spinner />
if (stats.isError) {
return <ErrorState message={errorMessage(stats.error, 'Nie udało się wczytać statystyk')} />
}
const data = stats.data!
if (data.finishedAttempts === 0) {
return (
<EmptyState
icon="presentation"
title="Nikt jeszcze nie rozwiązał sprawdzianu"
description="Statystyki pojawią się po pierwszym zakończonym podejściu."
/>
)
}
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="Podejścia" value={String(data.attempts)} />
<Stat label="Średni wynik" value={`${data.averageScore}%`} />
<Stat label="Zdawalność" value={`${data.passRate}%`} />
</div>
<Card title="Pytania najczęściej mylone" description="Najniższa skuteczność na górze.">
<ul className="space-y-2">
{data.questions.map((question) => (
<li key={question.questionId} className="rounded-md border border-slate-100 p-2.5">
<p className="text-sm text-slate-900">{question.content}</p>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-2 w-32 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full ${
question.correctRate < 50 ? 'bg-alarm-500' : 'bg-ok-500'
}`}
style={{ width: `${question.correctRate}%` }}
/>
</div>
<span className="hx-data text-xs text-slate-500">
{question.correctRate}% · {question.correct}/{question.answered}
</span>
</div>
</li>
))}
</ul>
</Card>
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-slate-100 bg-white p-3">
<p className="hx-data text-xl font-semibold text-slate-900">{value}</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
)
}
// --- podglad ---
/**
* Podglad oczami kursanta.
*
* Pokazuje pytania **bez zaznaczonej poprawnej odpowiedzi**, czyli dokladnie
* tak, jak zobaczy je kursant. Sluzy do sprawdzenia, czy tresc jest zrozumiala
* bez klucza: pytanie oczywiste dla autora bywa niejasne dla odbiorcy.
*/
function PreviewTab({ base, quiz }: { base: string; quiz: Quiz }) {
const questions = useQuery({
queryKey: [base, 'questions', 'preview'],
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${base}/questions`)).data,
})
if (questions.isPending) return <Spinner />
const active = (questions.data ?? []).filter((q) => q.active)
return (
<div className="space-y-3">
<Alert tone="info">
Tak zobaczy sprawdzian kursant: bez zaznaczonych odpowiedzi poprawnych. Losowana jest{' '}
<strong>{quiz.questionCount ?? quiz.questionsActive}</strong> z {active.length} aktywnych
pytań, próg zaliczenia to <strong>{quiz.effectiveThreshold}%</strong>.
</Alert>
{active.length === 0 && (
<EmptyState icon="file-text" title="Brak aktywnych pytań" description="Dodaj pytania." />
)}
<ol className="space-y-3">
{active.map((question, index) => (
<li key={question.id} className="rounded-md border border-slate-100 p-3">
<p className="text-sm font-medium text-slate-900">
{index + 1}. {question.content}
</p>
<ul className="mt-2 space-y-1.5">
{question.answers.map((answer) => (
<li key={answer.id} className="flex items-center gap-2 text-sm text-slate-700">
<span className="h-4 w-4 shrink-0 rounded-full border-2 border-slate-300" />
{answer.content}
</li>
))}
</ul>
</li>
))}
</ol>
</div>
)
}
// --- wspolne ---
function Toggle({
checked,
onChange,
title,
description,
}: {
checked: boolean
onChange: (value: boolean) => void
title: string
description: string
}) {
return (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
<strong className="text-slate-900">{title}</strong>
<span className="mt-0.5 block text-xs text-slate-500">{description}</span>
</span>
</label>
)
}
function IconAction({
icon,
label,
onClick,
disabled = false,
}: {
icon: Parameters<typeof Icon>[0]['name']
label: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent"
>
<Icon name={icon} size={16} />
</button>
)
}
@@ -0,0 +1,584 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Course, StudentDetail } from '../../api/types'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Select,
Spinner,
Textarea,
} from '../../components/ui'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
import { panelPath } from '../../lib/routes'
/*
* Etykiety zgod. CONSENT_LABEL nie zna CONTACT_SALES, bo tamten enum opisuje
* zgody zapisu na webinar; tutaj przychodza wszystkie typy, wiec brakujaca
* nazwe pokazujemy wprost zamiast pustego miejsca.
*/
const CONSENT_LABELS: Record<string, string> = {
TERMS: 'Regulamin',
MARKETING_EMAIL: 'Marketing e-mail',
MARKETING_SMS: 'Marketing SMS',
CONTACT_SALES: 'Kontakt handlowy',
}
type Tab = 'kursy' | 'zgody' | 'webinary' | 'certyfikaty' | 'zamowienia'
/**
* Karta kursanta.
*
* CZEGO TU CELOWO NIE MA: podgladu hasla ani wejscia na konto kursanta.
* Haslo jest skrotem BCrypt i nie da sie go odczytac, a logowanie sie jako
* ktos inny sprawiloby, ze slad w dzienniku nie odroznia dzialania kursanta
* od dzialania administratora. Wsparcie ma tu reset hasla i ponowny link
* potwierdzajacy, czyli tyle, ile potrzeba, zeby odblokowac kogos bez
* przejmowania jego tozsamosci.
*/
export function StudentDetailPage() {
const { id } = useParams()
const contactId = Number(id)
const queryClient = useQueryClient()
const toasts = useToasts()
const [tab, setTab] = useState<Tab>('kursy')
const [enrolling, setEnrolling] = useState(false)
const key = ['student', contactId]
const { data, isPending, isError, error } = useQuery({
queryKey: key,
queryFn: async () =>
(await client.get<StudentDetail>(`/admin/academy/students/${contactId}`)).data,
enabled: Number.isFinite(contactId),
})
const setEnabled = useMutation({
mutationFn: async (value: boolean) => {
await client.post(`/admin/academy/students/${contactId}/enabled`, null, {
params: { value },
})
},
onSuccess: () => {
toasts.success('Zapisano')
queryClient.invalidateQueries({ queryKey: key })
queryClient.invalidateQueries({ queryKey: ['students'] })
},
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się zmienić stanu konta')),
})
const resendVerification = useMutation({
mutationFn: async () => {
await client.post(`/admin/academy/students/${contactId}/resend-verification`)
},
onSuccess: () => toasts.success('Link potwierdzający wysłany'),
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać linku')),
})
const resetPassword = useMutation({
mutationFn: async () => {
await client.post(`/admin/academy/students/${contactId}/password-reset`)
},
onSuccess: () => toasts.success('Link do ustawienia nowego hasła wysłany'),
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać linku')),
})
if (isPending) return <Spinner />
if (isError || !data) {
return <ErrorState message={errorMessage(error, 'Nie udało się wczytać kursanta')} />
}
const student = data.student
return (
<div className="space-y-4">
<div>
<Link
to={panelPath('/kursy/kursanci')}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600"
>
<Icon name="arrow-left" size={16} />
Kursanci
</Link>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="text-xl font-semibold text-slate-900">
{student.firstName} {student.lastName}
</h1>
{!student.accountEnabled && <Badge tone="alarm">Konto wyłączone</Badge>}
{!student.emailVerified && <Badge tone="warn">Adres niepotwierdzony</Badge>}
{data.unsubscribedAt && <Badge tone="neutral">Wypisany z marketingu</Badge>}
</div>
</div>
{/* --- dane i dzialania --- */}
<Card title="Dane konta">
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
<Row label="Adres e-mail" value={student.email} mono />
<Row label="Telefon" value={student.phone || '-'} mono />
<Row label="Firma" value={student.company || '-'} />
<Row label="Rejestracja" value={formatLocal(student.registeredAt)} mono />
<Row
label="Ostatnie logowanie"
value={student.lastLoginAt ? formatLocal(student.lastLoginAt) : 'nigdy'}
mono
/>
<Row
label="Adres potwierdzony"
value={student.emailVerified ? 'tak' : 'nie'}
/>
</dl>
{data.notes && (
<div className="mt-4 rounded-md border border-slate-100 bg-slate-25 p-3">
<p className="hx-eyebrow mb-1">Notatki</p>
<p className="text-sm text-slate-700">{data.notes}</p>
</div>
)}
<div className="mt-5 flex flex-wrap gap-2">
<Button
variant="secondary"
icon="user-plus"
onClick={() => setEnrolling(true)}
>
Zapisz na kurs
</Button>
{!student.emailVerified && (
<Button
variant="secondary"
icon="mail"
onClick={() => resendVerification.mutate()}
disabled={resendVerification.isPending}
>
Wyślij link potwierdzający
</Button>
)}
<Button
variant="secondary"
icon="key"
onClick={() => {
if (
window.confirm(
`Wysłać ${student.email} link do ustawienia nowego hasła? ` +
'Nie poznasz jego hasła: kursant ustawi je sam.',
)
) {
resetPassword.mutate()
}
}}
disabled={resetPassword.isPending}
>
Reset hasła
</Button>
<Button
variant={student.accountEnabled ? 'danger' : 'primary'}
icon={student.accountEnabled ? 'user-off' : 'check'}
onClick={() => {
const next = !student.accountEnabled
const question = next
? `Włączyć konto ${student.email}?`
: `Wyłączyć konto ${student.email}? Kursant straci możliwość zalogowania się, ` +
'ale jego zapisy, postęp i certyfikaty zostaną zachowane.'
if (window.confirm(question)) {
setEnabled.mutate(next)
}
}}
disabled={setEnabled.isPending}
>
{student.accountEnabled ? 'Wyłącz konto' : 'Włącz konto'}
</Button>
</div>
<p className="mt-3 text-xs leading-relaxed text-slate-500">
Hasło jest przechowywane jako skrót i nie da się go odczytać. Nie ma też możliwości
zalogowania się jako kursant: dziennik musi odróżniać jego działania od Twoich.
</p>
</Card>
{/* --- zakladki --- */}
<nav
role="tablist"
aria-label="Zakładki kursanta"
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
>
{(
[
['kursy', 'Kursy', 'graduation-cap', data.enrollments.length],
['zgody', 'Zgody', 'file-text', data.consents.length],
['webinary', 'Webinary', 'presentation', data.webinars.length],
['certyfikaty', 'Certyfikaty', 'award', data.certificates.length],
['zamowienia', 'Zamówienia', 'credit-card', data.orders.length],
] as const
).map(([value, label, icon, count]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
tab === value
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<Icon name={icon} size={16} />
{label}
{count > 0 && <span className="hx-data text-xs text-slate-400">{count}</span>}
</button>
))}
</nav>
{tab === 'kursy' && <EnrollmentsTab data={data} />}
{tab === 'zgody' && <ConsentsTab data={data} />}
{tab === 'webinary' && <WebinarsTab data={data} />}
{tab === 'certyfikaty' && <CertificatesTab data={data} />}
{tab === 'zamowienia' && <OrdersTab data={data} />}
{enrolling && (
<ManualEnrollModal
contactId={contactId}
studentName={`${student.firstName} ${student.lastName}`}
onClose={() => setEnrolling(false)}
onSaved={() => {
setEnrolling(false)
queryClient.invalidateQueries({ queryKey: key })
}}
/>
)}
</div>
)
}
function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<dt className="hx-eyebrow">{label}</dt>
<dd className={`mt-0.5 text-sm text-slate-800 ${mono ? 'hx-data' : ''}`}>{value}</dd>
</div>
)
}
function EnrollmentsTab({ data }: { data: StudentDetail }) {
if (data.enrollments.length === 0) {
return (
<EmptyState
icon="graduation-cap"
title="Brak zapisów na kursy"
description="Możesz zapisać kursanta ręcznie, podając uzasadnienie."
/>
)
}
return (
<Card title="Kursy">
<ul className="space-y-3">
{data.enrollments.map((e) => (
<li key={e.enrollmentId} className="rounded-md border border-slate-100 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Link
to={panelPath(`/kursy/${e.courseId}`)}
className="text-sm font-medium text-slate-900 hover:text-blue-600"
>
{e.courseTitle}
</Link>
<Badge tone={ENROLLMENT_STATUS_TONE[e.status]}>
{ENROLLMENT_STATUS_LABEL[e.status]}
</Badge>
</div>
<div className="mt-2 max-w-md">
<div className="mb-1 flex items-center justify-between text-xs text-slate-600">
<span>
Ukończono {e.completedRequired} z {e.totalRequired}
</span>
<span className="hx-data font-medium text-slate-900">{e.progressPercent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-blue-500"
style={{ width: `${e.progressPercent}%` }}
/>
</div>
</div>
<p className="hx-data mt-2 text-xs text-slate-500">
Zapisany {formatLocal(e.enrolledAt)}
{e.lastActivityAt && ` · ostatnia aktywność ${formatLocal(e.lastActivityAt)}`}
</p>
</li>
))}
</ul>
</Card>
)
}
/**
* Zgody z historia.
*
* Zgody sa dopisywane, nigdy nadpisywane, wiec kursant ma ich kilka na ten sam
* typ. Pokazujemy wszystkie, bo to slad rozliczalny, a obowiazujaca oznaczamy:
* bez tego nie da sie odczytac stanu biezacego z samej listy.
*/
function ConsentsTab({ data }: { data: StudentDetail }) {
if (data.consents.length === 0) {
return <EmptyState icon="file-text" title="Brak zapisanych zgód" />
}
return (
<Card
title="Zgody"
description="Pełna historia. Zgody są dopisywane, nigdy nadpisywane, więc obowiązuje najnowsza każdego typu."
>
<ul className="space-y-2">
{data.consents.map((consent) => (
<li
key={consent.id}
className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${
consent.current ? 'border-slate-200 bg-white' : 'border-slate-100 bg-slate-25'
}`}
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-slate-900">
{CONSENT_LABELS[consent.type] ?? consent.type}
</p>
{consent.current && <Badge tone="info">obowiązuje</Badge>}
</div>
<p className="hx-data mt-0.5 text-xs text-slate-500">
wersja {consent.version} · {formatLocal(consent.grantedAt)}
</p>
</div>
<Badge tone={consent.granted ? 'ok' : 'neutral'}>
{consent.granted ? 'udzielona' : 'odmowa'}
</Badge>
</li>
))}
</ul>
</Card>
)
}
function WebinarsTab({ data }: { data: StudentDetail }) {
if (data.webinars.length === 0) {
return <EmptyState icon="presentation" title="Brak zapisów na webinary" />
}
return (
<Card title="Webinary">
<ul className="space-y-2">
{data.webinars.map((w) => (
<li
key={w.webinarId}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
>
<div className="min-w-0">
<Link
to={panelPath(`/webinary/${w.webinarId}`)}
className="text-sm font-medium text-slate-900 hover:text-blue-600"
>
{w.title}
</Link>
<p className="hx-data mt-0.5 text-xs text-slate-500">{formatLocal(w.startsAt)}</p>
</div>
{w.attended ? (
<Badge tone="ok">obecny</Badge>
) : (
<Badge tone="neutral">nieobecny</Badge>
)}
</li>
))}
</ul>
</Card>
)
}
function CertificatesTab({ data }: { data: StudentDetail }) {
if (data.certificates.length === 0) {
return <EmptyState icon="award" title="Brak certyfikatów" />
}
return (
<Card title="Certyfikaty">
<ul className="space-y-2">
{data.certificates.map((c) => (
<li
key={c.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-slate-900">{c.subjectTitle}</p>
<Badge tone={c.forCourse ? 'brand' : 'info'}>
{c.forCourse ? 'kurs' : 'webinar'}
</Badge>
</div>
<p className="hx-data mt-0.5 text-xs text-slate-500">
nr {c.number} · {formatLocal(c.issuedAt)}
</p>
</div>
{c.revoked ? (
<Badge tone="alarm">unieważniony</Badge>
) : (
<a
href={`/api/public/certificates/${c.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-700"
>
Pobierz
</a>
)}
</li>
))}
</ul>
</Card>
)
}
function OrdersTab({ data }: { data: StudentDetail }) {
if (data.orders.length === 0) {
return <EmptyState icon="credit-card" title="Brak zamówień" />
}
return (
<Card title="Zamówienia">
<ul className="space-y-2">
{data.orders.map((o) => (
<li
key={o.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{o.courseTitle ?? 'Kurs usunięty'}</p>
<p className="hx-data mt-0.5 text-xs text-slate-500">
{o.orderNumber} · {formatLocal(o.createdAt)}
</p>
</div>
<div className="flex items-center gap-3">
{o.amount && (
<span className="hx-data text-sm text-slate-700">
{o.amount} {o.currency}
</span>
)}
<Badge tone={o.status === 'PAID' ? 'ok' : 'neutral'}>{o.status}</Badge>
</div>
</li>
))}
</ul>
</Card>
)
}
/**
* Reczne zapisanie na kurs.
*
* NOTATKA JEST WYMAGANA. Zapis z pominieciem platnosci to decyzja, ktora po
* miesiacu musi dac sie wyjasnic: bez uzasadnienia nie wiadomo, czy kursant
* zaplacil przelewem, czy dostal dostep przez pomylke.
*/
function ManualEnrollModal({
contactId,
studentName,
onClose,
onSaved,
}: {
contactId: number
studentName: string
onClose: () => void
onSaved: () => void
}) {
const toasts = useToasts()
const [courseId, setCourseId] = useState('')
const [note, setNote] = useState('')
const courses = useQuery({
queryKey: ['courses', 'for-enroll'],
queryFn: async () =>
(
await client.get<{ content: Course[] }>('/admin/courses', {
params: { size: 100 },
})
).data.content,
})
const save = useMutation({
mutationFn: async () => {
await client.post(`/admin/academy/students/${contactId}/enroll`, {
courseId: Number(courseId),
note,
})
},
onSuccess: () => {
toasts.success('Kursant zapisany na kurs')
onSaved()
},
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się zapisać na kurs')),
})
return (
<Modal
title={`Zapisz na kurs: ${studentName}`}
onClose={onClose}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Anuluj
</Button>
<Button
icon="check"
onClick={() => save.mutate()}
disabled={!courseId || !note.trim() || save.isPending}
>
{save.isPending ? 'Zapisywanie...' : 'Zapisz na kurs'}
</Button>
</>
}
>
<div className="space-y-4">
<Alert tone="warn" icon="alert-triangle">
Ręczny zapis omija płatność i akceptację regulaminu. Kursant dostanie dostęp od razu.
</Alert>
<Field label="Kurs">
<Select value={courseId} onChange={(e) => setCourseId(e.target.value)}>
<option value="">Wybierz kurs</option>
{(courses.data ?? [])
.filter((c) => c.status !== 'DRAFT')
.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</Select>
</Field>
<Field
label="Uzasadnienie"
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego kursant dostał dostęp."
>
<Textarea
rows={3}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="np. opłacone przelewem tradycyjnym, faktura FV/2026/14"
/>
</Field>
</div>
</Modal>
)
}
@@ -0,0 +1,343 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { StudentRow } from '../../api/types'
import { Icon } from '../../components/Icon'
import { Pagination, PAGE_SIZE } from '../../components/Pagination'
import { SortableHeader } from '../../components/SortableHeader'
import type { SortState } from '../../components/SortableHeader'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Input,
Select,
Spinner,
} from '../../components/ui'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
import { panelPath } from '../../lib/routes'
/**
* Lista kursantow, czyli kontaktow z zalozonym kontem.
*
* Kursant to kontakt z haslem, a nie osobna tabela z ludzmi: ta granica
* jest jedna kolumna w bazie i tutaj widac jej skutek. Ekran pokazuje
* wylacznie osoby, ktore konto faktycznie zalozyly.
*/
export function StudentListPage() {
const toasts = useToasts()
const [search, setSearch] = useState('')
const [verified, setVerified] = useState('')
const [enabled, setEnabled] = useState('')
const [withCourses, setWithCourses] = useState('')
const [page, setPage] = useState(0)
const [sort, setSort] = useState<SortState>({ field: 'created_at', direction: 'desc' })
const [exporting, setExporting] = useState(false)
const { data, isPending, isError, error } = useQuery({
queryKey: ['students', search, verified, enabled, withCourses, page, sort.field, sort.direction],
queryFn: async () =>
(
await client.get<{
content: StudentRow[]
number: number
totalPages: number
totalElements: number
}>('/admin/academy/students', {
params: {
search: search || undefined,
verified: verified === '' ? undefined : verified === 'tak',
enabled: enabled === '' ? undefined : enabled === 'tak',
withCourses: withCourses === '' ? undefined : withCourses === 'tak',
sort: sort.field,
direction: sort.direction,
page,
size: PAGE_SIZE,
},
})
).data,
})
/** Zmiana filtru cofa na pierwsza strone: inaczej widac pusta tabele. */
const changeFilter = (setter: (value: string) => void) => (value: string) => {
setter(value)
setPage(0)
}
const changeSort = (next: SortState) => {
setSort(next)
setPage(0)
}
const exportCsv = async () => {
setExporting(true)
try {
// przez axios jako blob: endpoint wymaga naglowka Authorization,
// ktorego zwykly odnosnik nie doklada
const response = await client.get('/admin/academy/students/export', {
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = 'kursanci.csv'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (e) {
toasts.error(errorMessage(e, 'Nie udało się pobrać pliku'))
} finally {
setExporting(false)
}
}
const students = data?.content ?? []
const filtersActive = Boolean(search || verified || enabled || withCourses)
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">Kursanci</h1>
<p className="text-sm text-slate-500">
Osoby, które założyły konto w Akademii.
</p>
</div>
<Button variant="secondary" icon="download" onClick={exportCsv} disabled={exporting}>
{exporting ? 'Przygotowuję...' : 'Eksport CSV'}
</Button>
</div>
<Card>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-search">
Szukaj
</label>
<Input
id="student-search"
className="w-full sm:w-72"
placeholder="Nazwisko, imię albo adres"
value={search}
onChange={(e) => changeFilter(setSearch)(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-verified">
Adres potwierdzony
</label>
<Select
id="student-verified"
className="w-full sm:w-44"
value={verified}
onChange={(e) => changeFilter(setVerified)(e.target.value)}
>
<option value="">Wszyscy</option>
<option value="tak">Potwierdzony</option>
<option value="nie">Niepotwierdzony</option>
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-enabled">
Konto
</label>
<Select
id="student-enabled"
className="w-full sm:w-40"
value={enabled}
onChange={(e) => changeFilter(setEnabled)(e.target.value)}
>
<option value="">Wszystkie</option>
<option value="tak">Aktywne</option>
<option value="nie">Wyłączone</option>
</Select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-courses">
Kursy
</label>
<Select
id="student-courses"
className="w-full sm:w-44"
value={withCourses}
onChange={(e) => changeFilter(setWithCourses)(e.target.value)}
>
<option value="">Wszyscy</option>
<option value="tak">Z kursami</option>
<option value="nie">Bez kursów</option>
</Select>
</div>
<p className="ml-auto text-sm text-slate-500">
Znaleziono: <strong className="hx-data">{data?.totalElements ?? 0}</strong>
</p>
</div>
</Card>
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{isPending && (
<div className="p-5">
<Spinner />
</div>
)}
{isError && (
<div className="p-5">
<ErrorState message={errorMessage(error, 'Nie udało się wczytać listy kursantów')} />
</div>
)}
{data && students.length === 0 && (
<div className="p-5">
<EmptyState
icon="users"
title="Brak kursantów"
description={
filtersActive
? 'Żaden kursant nie spełnia wybranych warunków.'
: 'Kursanci pojawią się tutaj, gdy ktoś założy konto w Akademii.'
}
/>
</div>
)}
{students.length > 0 && (
<>
{/* karty do lg, tabela od lg: tabela na telefonie rozpychalaby strone */}
<ul className="divide-y divide-slate-100 lg:hidden">
{students.map((student) => (
<li key={student.id} className="p-4">
<Link
to={panelPath(`/kursy/kursanci/${student.id}`)}
className="text-sm font-medium text-slate-900 hover:text-blue-600"
>
{student.firstName} {student.lastName}
</Link>
<p className="hx-data mt-0.5 text-xs text-slate-500">{student.email}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<StatusBadges student={student} />
</div>
<p className="mt-2 text-xs text-slate-500">
Kursy: <span className="hx-data">{student.courseCount}</span> · ukończone:{' '}
<span className="hx-data">{student.completedCount}</span> · certyfikaty:{' '}
<span className="hx-data">{student.certificateCount}</span>
</p>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
<SortableHeader field="last_name" sort={sort} onSort={changeSort}>
Kursant
</SortableHeader>
<SortableHeader field="email" sort={sort} onSort={changeSort}>
Adres
</SortableHeader>
<SortableHeader field="created_at" sort={sort} onSort={changeSort}>
Rejestracja
</SortableHeader>
<SortableHeader field="last_login_at" sort={sort} onSort={changeSort}>
Ostatnie logowanie
</SortableHeader>
<th className="px-4 py-2.5">Stan</th>
{/*
Liczniki NIE sa sortowalne: powstaja osobnym zapytaniem
zbiorczym do juz pobranej strony, wiec baza nie ma po czym
sortowac, a sortowanie samej strony wprowadzaloby w blad.
*/}
<th className="px-4 py-2.5 text-right">Kursy</th>
<th className="px-4 py-2.5 text-right">Ukończone</th>
<th className="px-4 py-2.5 text-right">Certyfikaty</th>
</tr>
</thead>
<tbody>
{students.map((student) => (
<tr
key={student.id}
className="border-b border-slate-50 transition duration-fast ease-standard last:border-b-0 hover:bg-slate-25"
>
<td className="px-4 py-3">
<Link
to={panelPath(`/kursy/kursanci/${student.id}`)}
className="font-medium text-slate-900 hover:text-blue-600"
>
{student.firstName} {student.lastName}
</Link>
{student.company && (
<p className="text-xs text-slate-500">{student.company}</p>
)}
</td>
<td className="hx-data px-4 py-3 text-slate-600">{student.email}</td>
<td className="hx-data px-4 py-3 text-slate-600">
{formatLocal(student.registeredAt)}
</td>
<td className="hx-data px-4 py-3 text-slate-600">
{student.lastLoginAt ? formatLocal(student.lastLoginAt) : '-'}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1.5">
<StatusBadges student={student} />
</div>
</td>
<td className="hx-data px-4 py-3 text-right text-slate-700">
{student.courseCount || '-'}
</td>
<td className="hx-data px-4 py-3 text-right text-slate-700">
{student.completedCount || '-'}
</td>
<td className="hx-data px-4 py-3 text-right text-slate-700">
{student.certificateCount || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={data!.number}
totalPages={data!.totalPages}
totalElements={data!.totalElements}
onChange={setPage}
itemLabel="kursantów"
/>
</>
)}
</div>
</div>
)
}
/**
* Odznaki stanu konta.
*
* Pokazujemy wylacznie to, co odbiega od normy: konto potwierdzone i aktywne
* nie dostaje zadnej odznaki, bo to stan spodziewany, a odznaka przy kazdym
* wierszu przestaje cokolwiek znaczyc.
*/
function StatusBadges({ student }: { student: StudentRow }) {
return (
<>
{!student.accountEnabled && <Badge tone="alarm">Konto wyłączone</Badge>}
{!student.emailVerified && <Badge tone="warn">Adres niepotwierdzony</Badge>}
{student.accountEnabled && student.emailVerified && (
<span className="flex items-center gap-1 text-xs text-ok-600">
<Icon name="check-circle" size={14} />
Aktywne
</span>
)}
</>
)
}
@@ -0,0 +1,89 @@
import type { Course, CourseLevel, CourseStatus, EnrollmentStatus, LessonType } from '../../api/types'
import type { IconName } from '../../components/Icon'
/**
* Etykiety i tony obszaru kursow.
*
* Jedno miejsce dla calego obszaru: te same nazwy pojawiaja sie na liscie,
* na karcie kursu i w programie, wiec wpisane osobno w kazdym ekranie
* rozjezdzalyby sie po pierwszej poprawce.
*/
export const COURSE_STATUS_LABEL: Record<CourseStatus, string> = {
DRAFT: 'Wersja robocza',
PUBLISHED: 'Opublikowany',
ARCHIVED: 'Zarchiwizowany',
}
export const COURSE_STATUS_TONE: Record<CourseStatus, 'ok' | 'warn' | 'neutral'> = {
DRAFT: 'neutral',
PUBLISHED: 'ok',
ARCHIVED: 'warn',
}
export const COURSE_LEVEL_LABEL: Record<CourseLevel, string> = {
BASIC: 'Podstawowy',
INTERMEDIATE: 'Średnio zaawansowany',
ADVANCED: 'Zaawansowany',
}
export const LESSON_TYPE_LABEL: Record<LessonType, string> = {
VIDEO: 'Nagranie',
TEXT: 'Tekst',
FILE: 'Plik do pobrania',
WEBINAR: 'Webinar',
QUIZ: 'Sprawdzian',
}
export const LESSON_TYPE_ICON: Record<LessonType, IconName> = {
VIDEO: 'video',
TEXT: 'file-text',
FILE: 'download',
WEBINAR: 'presentation',
QUIZ: 'check-circle',
}
export const ENROLLMENT_STATUS_LABEL: Record<EnrollmentStatus, string> = {
ACTIVE: 'Aktywny',
COMPLETED: 'Ukończony',
CANCELLED: 'Wycofany',
EXPIRED: 'Wygasł',
}
export const ENROLLMENT_STATUS_TONE: Record<EnrollmentStatus, 'ok' | 'info' | 'neutral' | 'warn'> = {
ACTIVE: 'info',
COMPLETED: 'ok',
CANCELLED: 'neutral',
EXPIRED: 'warn',
}
/**
* Cena kursu.
*
* Brak ceny oznacza kurs darmowy, co jest czym innym niz cena zerowa: kurs
* z cena 0,00 ma ustalony cennik, a kurs bez ceny jest bezplatny z zalozenia.
* Pokazujemy to inaczej, zeby administrator widzial, ktora decyzje podjeto.
*/
export function formatPrice(course: Pick<Course, 'priceGross' | 'currency' | 'free'>): string {
if (course.free || course.priceGross === null) {
return 'darmowy'
}
const value = Number(course.priceGross)
if (Number.isNaN(value)) {
return 'darmowy'
}
return `${value.toFixed(2).replace('.', ',')} ${course.currency}`
}
/** Czas trwania w formie czytelnej dla czlowieka, a nie w samych minutach. */
export function formatDuration(minutes: number | null | undefined): string {
if (!minutes || minutes <= 0) {
return '-'
}
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) {
return `${rest} min`
}
return rest === 0 ? `${hours} godz.` : `${hours} godz. ${rest} min`
}
+380
View File
@@ -0,0 +1,380 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Order, OrderDetail, OrderStatus, Page } from '../../api/types'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Select,
Spinner,
Textarea,
} from '../../components/ui'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import { formatLocal } from '../../lib/dates'
const STATUS_LABEL: Record<OrderStatus, string> = {
NEW: 'Nowe',
PENDING: 'Oczekuje na płatność',
PAID: 'Opłacone',
FAILED: 'Nieudane',
CANCELLED: 'Anulowane',
EXPIRED: 'Wygasło',
}
const STATUS_TONE: Record<OrderStatus, 'ok' | 'info' | 'warn' | 'alarm' | 'neutral'> = {
NEW: 'neutral',
PENDING: 'info',
PAID: 'ok',
FAILED: 'alarm',
CANCELLED: 'neutral',
EXPIRED: 'warn',
}
function formatAmount(order: Pick<Order, 'amountGross' | 'currency'>): string {
const value = Number(order.amountGross)
return `${value.toFixed(2).replace('.', ',')} ${order.currency}`
}
/**
* Zamowienia i platnosci.
*
* Lista sluzy przede wszystkim do rozstrzygania sporow o platnosc, wiec kazde
* zamowienie prowadzi do historii komunikatow ITN wraz z ich surowa trescia.
*/
export function OrderListPage() {
const [page, setPage] = useState(0)
const [status, setStatus] = useState<OrderStatus | ''>('')
const [openOrder, setOpenOrder] = useState<number | null>(null)
useEffect(() => {
setPage(0)
}, [status])
const orders = useQuery({
queryKey: ['orders', page, status],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), size: String(PAGE_SIZE) })
if (status) params.set('status', status)
return (await client.get<Page<Order>>(`/admin/orders?${params}`)).data
},
})
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Zamówienia</h1>
<p className="text-sm text-slate-500">Płatności za kursy Akademii</p>
</div>
<Card>
<Select
className="max-w-xs"
value={status}
onChange={(e) => setStatus(e.target.value as OrderStatus | '')}
aria-label="Filtr statusu zamówienia"
>
<option value="">Wszystkie statusy</option>
{(Object.keys(STATUS_LABEL) as OrderStatus[]).map((value) => (
<option key={value} value={value}>
{STATUS_LABEL[value]}
</option>
))}
</Select>
</Card>
{orders.isPending && <Spinner />}
{orders.isError && (
<ErrorState message={errorMessage(orders.error, 'Nie udało się wczytać zamówień')} />
)}
{orders.data && orders.data.content.length === 0 && (
<EmptyState
icon="send"
title="Nie ma jeszcze żadnych zamówień"
description="Zamówienie powstaje, gdy kursant kupuje kurs płatny."
/>
)}
{orders.data && orders.data.content.length > 0 && (
<Card className="overflow-hidden">
{/* karty do lg, tabela od lg: tabela z min-w rozpychalaby telefon */}
<ul className="divide-y divide-slate-100 lg:hidden">
{orders.data.content.map((order) => (
<li key={order.id} className="py-3 first:pt-0 last:pb-0">
<button
type="button"
onClick={() => setOpenOrder(order.id)}
className="w-full text-left"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="hx-data text-sm font-medium text-slate-900">
{order.orderNumber}
</p>
<p className="text-xs text-slate-500">{order.courseTitle ?? '(kurs usunięty)'}</p>
<p className="text-xs text-slate-400">{order.contactEmail}</p>
</div>
<div className="text-right">
<Badge tone={STATUS_TONE[order.status]}>{STATUS_LABEL[order.status]}</Badge>
<p className="hx-data mt-1 text-sm text-slate-700">{formatAmount(order)}</p>
</div>
</div>
</button>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-left text-xs uppercase tracking-caps text-slate-500">
<th className="px-3 py-2 font-medium">Numer</th>
<th className="px-3 py-2 font-medium">Kursant</th>
<th className="px-3 py-2 font-medium">Kurs</th>
<th className="px-3 py-2 font-medium">Kwota</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Złożono</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{orders.data.content.map((order) => (
<tr key={order.id} className="hover:bg-slate-25">
<td className="hx-data px-3 py-2.5 text-slate-900">{order.orderNumber}</td>
<td className="px-3 py-2.5">
<p className="text-slate-900">{order.contactName ?? '-'}</p>
<p className="hx-data text-xs text-slate-500">{order.contactEmail}</p>
</td>
<td className="px-3 py-2.5 text-slate-600">
{order.courseTitle ?? '(kurs usunięty)'}
</td>
<td className="hx-data px-3 py-2.5">{formatAmount(order)}</td>
<td className="px-3 py-2.5">
<Badge tone={STATUS_TONE[order.status]}>{STATUS_LABEL[order.status]}</Badge>
</td>
<td className="hx-data px-3 py-2.5 text-slate-500">
{formatLocal(order.createdAt)}
</td>
<td className="px-3 py-2.5 text-right">
<button
type="button"
onClick={() => setOpenOrder(order.id)}
title="Szczegóły zamówienia"
aria-label={`Szczegóły zamówienia ${order.orderNumber}`}
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900"
>
<Icon name="eye" size={18} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pt-3">
<Pagination
page={page}
totalPages={orders.data.totalPages}
totalElements={orders.data.totalElements}
onChange={setPage}
itemLabel="zamówień"
/>
</div>
</Card>
)}
{openOrder !== null && (
<OrderDetailModal orderId={openOrder} onClose={() => setOpenOrder(null)} />
)}
</div>
)
}
/**
* Karta zamowienia z historia komunikatow ITN.
*
* Surowa tresc komunikatu jest tu celowo dostepna: przy sporze o platnosc to
* jedyny dowod na to, co bramka faktycznie przyslala.
*/
function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () => void }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [note, setNote] = useState('')
const [granting, setGranting] = useState(false)
const detail = useQuery({
queryKey: ['orders', 'detail', orderId],
queryFn: async () => (await client.get<OrderDetail>(`/admin/orders/${orderId}`)).data,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['orders'] })
const grant = useMutation({
mutationFn: () => client.post(`/admin/orders/${orderId}/grant-access`, { note }),
onSuccess: () => {
toasts.success('Dostęp nadany')
setGranting(false)
setNote('')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się nadać dostępu')),
})
const cancel = useMutation({
mutationFn: () => client.post(`/admin/orders/${orderId}/cancel`),
onSuccess: () => {
toasts.success('Zamówienie anulowane')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się anulować')),
})
return (
<Modal title="Zamówienie" onClose={onClose} wide>
{detail.isPending && <Spinner />}
{detail.isError && (
<ErrorState message={errorMessage(detail.error, 'Nie udało się wczytać zamówienia')} />
)}
{detail.data && (
<div className="space-y-4">
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
<Row label="Numer">{detail.data.order.orderNumber}</Row>
<Row label="Status">
<Badge tone={STATUS_TONE[detail.data.order.status]}>
{STATUS_LABEL[detail.data.order.status]}
</Badge>
</Row>
<Row label="Kursant">
{detail.data.order.contactName} · {detail.data.order.contactEmail}
</Row>
<Row label="Kurs">{detail.data.order.courseTitle ?? '(kurs usunięty)'}</Row>
<Row label="Kwota">{formatAmount(detail.data.order)}</Row>
<Row label="Złożono">{formatLocal(detail.data.order.createdAt)}</Row>
{detail.data.order.paidAt && (
<Row label="Opłacono">{formatLocal(detail.data.order.paidAt)}</Row>
)}
{detail.data.order.remoteId && (
<Row label="Identyfikator transakcji">{detail.data.order.remoteId}</Row>
)}
</dl>
{/* --- reczne nadanie dostepu --- */}
{detail.data.order.status !== 'PAID' && (
<Card title="Ręczne nadanie dostępu">
{granting ? (
<form
onSubmit={(e) => {
e.preventDefault()
if (note.trim()) grant.mutate()
}}
className="space-y-3"
>
<Alert tone="warn">
Użyj tego wyłącznie, gdy płatność wpłynęła poza bramką, na przykład przelewem
tradycyjnym. Zamówienie zostanie oznaczone jako opłacone.
</Alert>
<Field
label="Notatka"
hint="Wymagana. Bez niej po miesiącu nie da się ustalić, na jakiej podstawie nadano dostęp."
>
<Textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="np. przelew z 30.08, potwierdzenie w załączniku do sprawy 123"
autoFocus
/>
</Field>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setGranting(false)}>
Anuluj
</Button>
<Button type="submit" icon="check" disabled={!note.trim() || grant.isPending}>
{grant.isPending ? 'Nadawanie...' : 'Nadaj dostęp'}
</Button>
</div>
</form>
) : (
<div className="flex flex-wrap gap-2">
<Button variant="secondary" icon="check" onClick={() => setGranting(true)}>
Nadaj dostęp ręcznie
</Button>
<Button
variant="ghost"
icon="x"
onClick={() => {
if (window.confirm('Anulować to zamówienie?')) cancel.mutate()
}}
>
Anuluj zamówienie
</Button>
</div>
)}
</Card>
)}
{/* --- historia ITN --- */}
<Card
title="Komunikaty od operatora płatności"
description="Pełny dziennik, także komunikaty odrzucone. Przy sporze o płatność to jedyny dowód."
>
{detail.data.notifications.length === 0 && (
<p className="text-sm text-slate-500">Nie przyszedł jeszcze żaden komunikat.</p>
)}
<ul className="space-y-2">
{detail.data.notifications.map((notification) => (
<li key={notification.id} className="rounded-md border border-slate-100 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="hx-data text-sm font-medium text-slate-900">
{notification.paymentStatus ?? '(brak statusu)'}
</span>
{notification.hashValid ? (
<Badge tone="ok">podpis poprawny</Badge>
) : (
<Badge tone="alarm">podpis niepoprawny</Badge>
)}
{notification.processed && <Badge tone="info">przetworzony</Badge>}
<span className="hx-data ml-auto text-xs text-slate-400">
{formatLocal(notification.receivedAt)}
</span>
</div>
<p className="hx-data mt-1 text-xs text-slate-500">
{notification.remoteId ?? '-'} · {notification.amount ?? '-'}
{notification.paymentStatusDetails && ` · ${notification.paymentStatusDetails}`}
</p>
<details className="mt-1.5">
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-700">
Pokaż surową treść
</summary>
<pre className="hx-data mt-1 max-h-48 overflow-auto rounded bg-slate-50 p-2 text-2xs text-slate-600">
{notification.rawXml}
</pre>
</details>
</li>
))}
</ul>
</Card>
</div>
)}
</Modal>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs font-semibold uppercase tracking-caps text-slate-400">{label}</dt>
<dd className="hx-data mt-0.5 text-sm text-slate-800">{children}</dd>
</div>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect } from 'react'
import type { BannerPlacement, PublicBanner } from '../../api/types'
import { Icon } from '../../components/Icon'
import { useRotation } from '../../lib/useBanners'
/**
* Miejsce na baner w sali.
*
* ROTACJA JEST NA MIEJSCE, NIE NA CALA SALE
* ------------------------------------------------------------------------
* Kazde miejsce rotuje niezaleznie: baner boczny i pas pod wideo nie musza
* zmieniac sie w tej samej chwili, a wspolny licznik wiazalby je bez powodu.
*
* KLIKNIECIE MA DWIE DROGI
* ------------------------------------------------------------------------
* Zwykly baner otwiera `targetUrl` w nowej karcie. Baner z tematem oferty
* otwiera **formularz zapytania** w oknie, bez opuszczania sali: to domyka
* droge od reklamy do kontaktu handlowego. Uczestnik, ktory wyszedl na obcy
* serwis w trakcie prezentacji, zwykle juz nie wraca.
*
* `rel="noopener noreferrer"` odcina nowej karcie dostep do `window.opener`
* i nie zdradza obcemu serwisowi, z jakiego adresu przyszedl uczestnik.
*/
export function BannerSlot({
placement,
banners,
rotationSeconds,
onImpression,
onClick,
onDismiss,
onOfferTopic,
className = '',
}: {
placement: BannerPlacement
banners: PublicBanner[]
rotationSeconds: number
onImpression: (bannerId: number) => void
onClick: (bannerId: number) => void
/** Zamkniecie, wylacznie przy OVERLAY. */
onDismiss?: (bannerId: number) => void
/** Otwarcie formularza oferty z wybranym z gory tematem. */
onOfferTopic?: (topic: string) => void
className?: string
}) {
const index = useRotation(banners.length, rotationSeconds)
const banner = banners[index]
/*
* Wyswietlenie liczymy, gdy baner faktycznie wchodzi na ekran, a nie gdy
* przychodzi z serwera: baner z rotacji, ktorego uczestnik nigdy nie
* zobaczyl, nie zostal wyswietlony.
*/
useEffect(() => {
if (banner) onImpression(banner.id)
}, [banner, onImpression])
if (!banner) return null
const isOffer = Boolean(banner.offerTopicName)
const clickable = isOffer || Boolean(banner.targetUrl)
function handleClick() {
onClick(banner.id)
if (isOffer && banner.offerTopicName && onOfferTopic) {
onOfferTopic(banner.offerTopicName)
}
}
const content = (
<>
{banner.mediaUrl ? (
<img
src={banner.mediaUrl}
alt={banner.title}
className="block w-full object-contain"
loading="lazy"
/>
) : (
/*
Tresc jest oczyszczona sanitizerem na backendzie, tym samym co tresc
strony zapisu, wiec nie czyscimy jej drugi raz w przegladarce.
*/
<div
className="prose prose-sm max-w-none p-3"
dangerouslySetInnerHTML={{ __html: banner.contentHtml ?? '' }}
/>
)}
</>
)
const frame =
placement === 'OVERLAY'
? 'relative overflow-hidden rounded-lg border border-white/20 bg-white shadow-xl'
: 'relative overflow-hidden rounded-lg border border-slate-200 bg-white'
return (
<div className={`${frame} ${className}`}>
{/*
Baner z adresem zewnetrznym jest prawdziwym linkiem, a nie przyciskiem:
tylko wtedy dziala srodkowy przycisk myszy i menu kontekstowe.
Baner otwierajacy formularz oferty musi byc przyciskiem, bo nigdzie
nie prowadzi.
*/}
{isOffer ? (
<button type="button" onClick={handleClick} className="block w-full text-left">
{content}
</button>
) : banner.targetUrl ? (
<a
href={banner.targetUrl}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
className="block"
>
{content}
</a>
) : (
content
)}
{isOffer && (
<p className="border-t border-slate-100 px-3 py-1.5 text-xs font-medium text-blue-700">
Kliknij, żeby zapytać o ofertę
</p>
)}
{/*
OVERLAY zaslania czesc obrazu, wiec musi dac sie zamknac: baner nie do
usuniecia w trakcie prezentacji jest gorszy niz brak banera.
*/}
{placement === 'OVERLAY' && onDismiss && (
<button
type="button"
onClick={() => onDismiss(banner.id)}
aria-label="Zamknij baner"
className="absolute right-1 top-1 flex h-8 w-8 items-center justify-center rounded-md bg-white/90 text-slate-600 shadow-sm ring-1 ring-slate-200 transition-colors duration-fast hover:bg-white hover:text-slate-900"
>
<Icon name="x" size={16} />
</button>
)}
{/* wskaznik rotacji: uczestnik widzi, ze jest wiecej niz jeden baner */}
{banners.length > 1 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
{banners.map((item, i) => (
<span
key={item.id}
className={`h-1.5 w-1.5 rounded-full ${
i === index ? 'bg-blue-500' : 'bg-slate-300'
}`}
/>
))}
</div>
)}
{!clickable && <span className="sr-only">{banner.title}</span>}
</div>
)
}
@@ -0,0 +1,120 @@
import { 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 { formatLocal } from '../../lib/dates'
/**
* Weryfikacja certyfikatu.
*
* 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.
*
* 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.
*/
export function CertificateVerifyPage() {
/*
* Numer odczytujemy z wildcardu, nie ze zwyklego parametru: domyslny format
* to "2026/1", a ukosnik rozbilby parametr na dwa segmenty i trasa nigdy
* by sie nie dopasowala. `*` lapie cala reszte sciezki wraz z ukosnikami.
*/
const params = useParams()
const number = decodeURIComponent(params['*'] ?? '')
const query = useQuery({
queryKey: ['certificate-verify', number],
queryFn: async () => {
const response = await publicClient.get<CertificateVerification>(
'/public/certificates/verify',
// numer w parametrze, nie w sciezce: domyslny wzor zawiera ukosnik
{ params: { number } },
)
return response.data
},
})
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>
)
}
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>
)
}
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>
<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>
)
}
function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-2 py-3">
<dt className="text-sm text-slate-500">{label}</dt>
<dd className={`text-sm font-medium text-slate-900 ${mono ? 'hx-data' : ''}`}>{value}</dd>
</div>
)
}
@@ -0,0 +1,376 @@
import { useEffect, useRef, useState } from 'react'
import type { SpeakEventPayload } from '../../lib/useInteractions'
import { Icon } from '../../components/Icon'
import { Button, Textarea } from '../../components/ui'
import { useInteractions, type InteractionView } from '../../lib/useInteractions'
import { OfferTab } from './OfferTab'
import { TasksTab } from './TasksTab'
/**
* Panel interakcji uczestnika: czat i pytania.
*
* Cztery zakladki, bo to cztery niezalezne strumienie. Czat plynie szybko
* i jest do czytania na biezaco, pytania czekaja na odpowiedz i zbieraja glosy,
* zadania i oferta to formularze. Mieszanie ich w jednej liscie utrudnialoby
* kazde z osobna.
*
* LICZNIK PRZY ZAKLADCE NIEAKTYWNEJ
* ------------------------------------------------------------------------
* Licznik pokazuje **nowe pozycje od ostatniego zajrzenia**, a nie laczna
* liczbe wpisow. Laczna liczba przy czacie rosnie caly czas i po kwadransie
* nie niesie zadnej informacji: uczestnik nie wie, czy pojawilo sie cos, czego
* jeszcze nie widzial. Wejscie w zakladke zeruje jej licznik.
*/
export function InteractionPanel({
token,
onSpeakEvent,
onOpenMicChange,
onOpenVideoChange,
onModeration,
onHistoryLoaded,
}: {
token: string
/** Zdarzenia zgloszen do glosu ida do paska akcji nad panelem. */
onSpeakEvent?: (payload: SpeakEventPayload) => void
/** Przelaczenie trybu otwartego mikrofonu przez prowadzacego. */
onOpenMicChange?: (enabled: boolean) => void
/** Przelaczenie trybu wideo dla wszystkich przez prowadzacego. */
onOpenVideoChange?: (enabled: boolean) => void
/** Moderacja tego uczestnika (wyciszenie/blokada) - reaguje strona spotkania. */
onModeration?: (event: import('../../lib/useInteractions').ModerationEvent) => void
/**
* Stan poczatkowy z historii.
*
* Historia jest pobierana tutaj, a tryby otwartego mikrofonu i wideo dla
* wszystkich sa w niej zawarte. Drugie zapytanie o to samo z poziomu strony
* spotkania byloby zbedne, wiec przekazujemy wartosc wyzej.
*/
onHistoryLoaded?: (history: { openMic: boolean; openVideo: boolean }) => void
}) {
// wyciszenie czatu: gdy prowadzacy wyciszy uczestnika, chowamy pole pisania
const [chatMuted, setChatMuted] = useState(false)
const { history, chat, questions, connected, error, clearError, sendChat, sendQuestion, vote } =
useInteractions(token, onSpeakEvent, onOpenMicChange, (event) => {
if (event.kind === 'MUTED') setChatMuted(true)
if (event.kind === 'UNMUTED') setChatMuted(false)
onModeration?.(event)
}, onOpenVideoChange)
// stan poczatkowy wyciszenia z historii: uczestnik wyciszony wczesniej ma
// zablokowane pole od wejscia, a nie dopiero po zdarzeniu
useEffect(() => {
if (history) setChatMuted(history.chatMuted)
}, [history])
const [tab, setTab] = useState<'chat' | 'questions' | 'tasks' | 'offer'>('chat')
/*
* Ile pozycji bylo widocznych, gdy uczestnik ostatnio patrzyl na zakladke.
* Roznica wobec stanu biezacego to liczba nowych.
*/
const [seen, setSeen] = useState({ chat: 0, questions: 0 })
const historyHandler = useRef(onHistoryLoaded)
historyHandler.current = onHistoryLoaded
useEffect(() => {
if (history) historyHandler.current?.({ openMic: history.openMic, openVideo: history.openVideo })
}, [history])
useEffect(() => {
if (tab === 'chat') setSeen((c) => ({ ...c, chat: chat.length }))
}, [tab, chat.length])
useEffect(() => {
if (tab === 'questions') setSeen((c) => ({ ...c, questions: questions.length }))
}, [tab, questions.length])
const unread = {
chat: tab === 'chat' ? 0 : Math.max(0, chat.length - seen.chat),
questions: tab === 'questions' ? 0 : Math.max(0, questions.length - seen.questions),
}
if (!history) return null
const chatOff = history.chatMode === 'DISABLED'
const speakersOnly = history.chatMode === 'SPEAKERS_ONLY' && !history.speaker
return (
<div className="flex h-full min-h-0 flex-col bg-white">
<nav role="tablist" aria-label="Interakcje" className="flex border-b border-slate-200">
{(
[
['chat', 'Czat', 'message-square', unread.chat],
['questions', 'Pytania', 'circle-alert', unread.questions],
['tasks', 'Zadania', 'check-circle', 0],
['offer', 'Oferta', 'send', 0],
] as const
).map(([id, label, icon, count]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
onClick={() => setTab(id)}
className={`flex min-h-[44px] flex-1 items-center justify-center gap-2 text-sm transition-colors duration-fast ease-standard ${
tab === id
? 'border-b-2 border-blue-500 font-medium text-blue-700'
: 'text-slate-600 hover:text-slate-900'
}`}
>
<Icon name={icon} size={16} />
{label}
{count > 0 && (
<span className="hx-data inline-flex min-w-[18px] items-center justify-center rounded-full bg-blue-500 px-1 text-2xs font-semibold text-white">
{count > 99 ? '99+' : count}
</span>
)}
</button>
))}
</nav>
{/*
Utrata polaczenia jest widoczna, a wznowienie dzieje sie samo
(`reconnectDelay` w kliencie STOMP). Uczestnik ma wiedziec, ze czat
chwilowo nie dziala, i ze nie musi nic robic.
*/}
{!connected && (
<p className="flex items-center gap-2 border-b border-warn-500/20 bg-warn-50 px-3 py-2 text-xs text-warn-600">
<Icon name="loader" size={14} className="animate-spin" />
Połączenie przerwane, wznawiamy...
</p>
)}
{error && (
<div className="flex items-start gap-2 border-b border-alarm-500/20 bg-alarm-50 px-3 py-2">
<Icon name="circle-alert" size={14} className="mt-0.5 shrink-0 text-alarm-600" />
<p className="flex-1 text-xs text-alarm-600">{error}</p>
<button type="button" onClick={clearError} aria-label="Zamknij komunikat">
<Icon name="x" size={14} className="text-alarm-600" />
</button>
</div>
)}
{tab === 'tasks' && <TasksTab token={token} webinarId={history.webinarId} />}
{tab === 'offer' && <OfferTab token={token} />}
{tab === 'chat' ? (
<ChatTab
messages={chat}
disabled={chatOff || speakersOnly || chatMuted}
disabledReason={
chatMuted
? 'Prowadzący wyłączył Ci możliwość pisania na czacie.'
: chatOff
? 'Czat jest wyłączony na tym webinarze.'
: 'Na tym webinarze piszą wyłącznie prowadzący.'
}
onSend={sendChat}
/>
) : tab === 'questions' ? (
<QuestionsTab
questions={questions}
requiresApproval={history.questionsRequireApproval}
canVote={!history.speaker}
onSend={sendQuestion}
onVote={vote}
/>
) : null}
</div>
)
}
function ChatTab({
messages,
disabled,
disabledReason,
onSend,
}: {
messages: InteractionView[]
disabled: boolean
disabledReason: string
onSend: (content: string) => void
}) {
const [text, setText] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
// nowa wiadomosc ma byc widoczna bez przewijania recznie
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [messages.length])
function submit() {
const content = text.trim()
if (!content) return
onSend(content)
setText('')
}
return (
<>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{messages.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
Nikt jeszcze nic nie napisał.
</p>
)}
{messages.map((message) => (
<div key={message.id} className="text-sm">
<span
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
>
{message.authorName}
{message.fromSpeaker && ' (prowadzący)'}
</span>
{/* tresc jest oczyszczona do samego tekstu na backendzie, wiec
wstawiamy ja jako tekst; adresy zostaja nieklikalne */}
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
</div>
))}
<div ref={bottomRef} />
</div>
{disabled ? (
<p className="border-t border-slate-200 bg-slate-25 p-3 text-center text-xs text-slate-500">
{disabledReason}
</p>
) : (
<div className="border-t border-slate-200 p-2">
<Textarea
rows={2}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
// Enter wysyla, Shift+Enter robi nowa linie
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
placeholder="Napisz wiadomość..."
/>
<Button
type="button"
icon="send"
size="sm"
className="mt-2 w-full"
onClick={submit}
disabled={!text.trim()}
>
Wyślij
</Button>
</div>
)}
</>
)
}
function QuestionsTab({
questions,
requiresApproval,
canVote,
onSend,
onVote,
}: {
questions: InteractionView[]
requiresApproval: boolean
canVote: boolean
onSend: (content: string) => void
onVote: (id: number) => void
}) {
const [text, setText] = useState('')
const [sent, setSent] = useState(false)
// przypiete na gorze, potem po liczbie glosow: prowadzacy tak wlasnie
// ustawia kolejnosc, a uczestnik ma widziec to samo
const sorted = [...questions].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
return b.votes - a.votes
})
function submit() {
const content = text.trim()
if (!content) return
onSend(content)
setText('')
setSent(true)
window.setTimeout(() => setSent(false), 4000)
}
return (
<>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{sorted.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
Nie ma jeszcze pytań. Zadaj pierwsze.
</p>
)}
{sorted.map((question) => (
<div
key={question.id}
className={`rounded-md border p-2.5 ${
question.pinned ? 'border-blue-300 bg-blue-50' : 'border-slate-200'
}`}
>
<div className="flex items-start gap-2">
<button
type="button"
onClick={() => onVote(question.id)}
disabled={!canVote || question.votedByMe}
aria-label={question.votedByMe ? 'Już zagłosowano' : 'Zagłosuj na to pytanie'}
className={`flex min-h-[44px] w-11 shrink-0 flex-col items-center justify-center rounded-md text-xs transition-colors duration-fast ${
question.votedByMe
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 disabled:opacity-50'
}`}
>
<Icon name="chevron-up" size={14} />
<span className="hx-data">{question.votes}</span>
</button>
<div className="min-w-0 flex-1">
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.content}
</p>
<p className="mt-0.5 text-xs text-slate-400">{question.authorName}</p>
{question.answer && (
<div className="mt-2 rounded-md bg-ok-50 p-2">
<p className="text-xs font-medium text-ok-600">Odpowiedź prowadzącego</p>
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.answer}
</p>
</div>
)}
</div>
</div>
</div>
))}
</div>
<div className="border-t border-slate-200 p-2">
{sent && (
<p className="mb-2 rounded-md bg-ok-50 px-2 py-1.5 text-xs text-ok-600">
{requiresApproval
? 'Pytanie czeka na zatwierdzenie przez prowadzącego.'
: 'Pytanie zostało dodane.'}
</p>
)}
<Textarea
rows={2}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Zadaj pytanie..."
/>
<Button
type="button"
icon="send"
size="sm"
className="mt-2 w-full"
onClick={submit}
disabled={!text.trim()}
>
Wyślij pytanie
</Button>
</div>
</>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { JitsiMeeting } from '@jitsi/react-sdk'
/**
* Pokoj Jitsi osadzony w naszym ekranie spotkania.
*
* OGRANICZENIE INTERFEJSU, NIE SERWERA
* ------------------------------------------------------------------------
* **To jest najwazniejsza rzecz w tym pliku.** Ustawienia ponizej ukrywaja
* przycisk kamery uczestnikowi bez prawa do obrazu, ale **Jitsi nie zna
* naszych rol**. Serwer nie wie, ze ktos jest VIEWER-em, i niczego mu nie
* zabrania: wystarczy wejsc wlasnym klientem, otworzyc ten sam pokoj
* bezposrednio na domenie Jitsi albo podmienic konfiguracje w przegladarce,
* zeby wlaczyc kamere mimo ukrytego przycisku.
*
* Przy wlaczonym JWT serwer egzekwuje **wylacznie role moderatora**. Jitsi
* nie ma odpowiednika uprawnienia "publikuj tylko audio", wiec roznica miedzy
* VIEWER a SPEAKER jest tu w calosci umowna.
*
* Gwarancje po stronie serwera daje **LiveKit**, gdzie uprawnienia sa
* w podpisanym tokenie i uczestnik bez prawa publikacji zostanie odrzucony.
* Panel mowi o tej roznicy wprost przy wyborze dostawcy.
*/
export function JitsiRoom({
domain,
roomName,
displayName,
jwt,
canPublishVideo,
canPublishAudio,
onLeave,
}: {
domain: string
roomName: string
displayName: string
/** Null, gdy serwer nie wymaga tokenu. Pokoj jest wtedy otwarty. */
jwt: string | null
/** Wylacznie do ustawienia interfejsu. Serwer tego nie egzekwuje. */
canPublishVideo: boolean
canPublishAudio: boolean
onLeave?: () => void
}) {
return (
<JitsiMeeting
domain={domain}
roomName={roomName}
jwt={jwt ?? undefined}
configOverwrite={{
/*
* Wchodzimy wyciszeni i z wylaczona kamera, niezaleznie od roli:
* przypadkowo otwarty mikrofon slychac na calej sali, a obraz
* z kamery uczestnika nigdy nie ma isc automatycznie.
*/
startWithAudioMuted: true,
startWithVideoMuted: true,
prejoinPageEnabled: false,
disableDeepLinking: true,
}}
interfaceConfigOverwrite={{
/*
* Ukrycie przyciskow to **wygoda, nie zabezpieczenie**: uczestnik bez
* prawa do obrazu nie widzi przycisku kamery, ale serwer Jitsi nadal
* przyjmie od niego strumien, jesli obejdzie interfejs. Zapisujemy to
* tutaj, zeby nikt nie wzial tej listy za kontrole dostepu.
*/
TOOLBAR_BUTTONS: [
...(canPublishAudio ? ['microphone'] : []),
...(canPublishVideo ? ['camera', 'desktop'] : []),
'chat',
'raisehand',
'tileview',
'fullscreen',
'settings',
'hangup',
],
SHOW_JITSI_WATERMARK: false,
SHOW_BRAND_WATERMARK: false,
MOBILE_APP_PROMO: false,
}}
userInfo={{ displayName, email: '' }}
onReadyToClose={onLeave}
getIFrameRef={(iframe) => {
iframe.style.height = '100%'
iframe.style.width = '100%'
iframe.style.border = '0'
}}
/>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { PublicLayout } from './PublicLayout'
import { useLegal } from '../../lib/useLegal'
/**
* Strona regulaminu albo polityki prywatnosci.
*
* Tresc pochodzi z gotowych, wbudowanych dokumentow (backend sklada je z danych
* firmy z ustawien). Administrator nie pisze prawa - dokumenty sa zawsze
* obecne, a w miejsce danych firmy podstawiane sa wartosci z konfiguracji.
* Obowiazek informacyjny RODO jest juz czescia tresci polityki, wiec strona
* tylko ja wyswietla.
*/
export function LegalPage({ kind }: { kind: 'terms' | 'privacy' }) {
const legal = useLegal()
const title = kind === 'terms' ? 'Regulamin' : 'Polityka prywatności'
const html = kind === 'terms' ? legal.data?.termsHtml : legal.data?.privacyHtml
return (
<PublicLayout wide>
<article className="space-y-6">
<h1 className="text-2xl font-semibold text-slate-900">{title}</h1>
{legal.isPending ? (
<p className="text-sm text-slate-500">Wczytywanie...</p>
) : (
// tresc jest skladana po stronie serwera z zaufanych stalych i
// escapowanych danych firmy - nie przechodzi przez edytor uzytkownika
<div
className="prose prose-slate max-w-none"
dangerouslySetInnerHTML={{ __html: html ?? '' }}
/>
)}
</article>
</PublicLayout>
)
}
+85
View File
@@ -0,0 +1,85 @@
import '@livekit/components-styles'
import {
LiveKitRoom as LiveKitRoomProvider,
RoomAudioRenderer,
VideoConference,
} from '@livekit/components-react'
import { RoomOptions } from 'livekit-client'
import { useMemo } from 'react'
/**
* Pokoj LiveKit osadzony w naszym ekranie spotkania.
*
* GOTOWY KOMPONENT, NIE WLASNY INTERFEJS
* ------------------------------------------------------------------------
* `VideoConference` daje uklad kafelkow, przelaczanie na aktywnego mowce,
* wybor urzadzen i wskazniki polaczenia. Odtwarzanie tego u siebie oznaczaloby
* miesiace pracy nad rzecza, ktora nie jest tym, co ta platforma wnosi.
* **Nasze zostaje to, czego dostawca nie ma**: czat, pytania, zadania,
* zapytania ofertowe, banery i pasek akcji.
*
* UPRAWNIENIA POCHODZA Z TOKENU, NIE STAD
* ------------------------------------------------------------------------
* Ten komponent niczego nie zabrania. Uczestnik bez prawa publikacji nie ma
* przycisku kamery, bo **serwer nie pozwoli mu publikowac**, a biblioteka to
* odczytuje z tokenu. Ukrywanie przyciskow tutaj byloby kosmetyka.
*
* SUBSKRYPCJA OGRANICZONA DO AKTYWNYCH MOWCOW
* ------------------------------------------------------------------------
* Przy otwartym mikrofonie i stu uczestnikach kazda przegladarka odbieralaby
* sto strumieni audio naraz, a ruch rosnie wtedy liniowo z liczba uczestnikow.
* `adaptiveStream` i `dynacast` sprawiaja, ze klient pobiera wylacznie to, co
* faktycznie widac i slychac: strumienie spoza kadru sa wstrzymywane, a jakosc
* dobierana do rozmiaru kafelka.
*/
export function LiveKitRoom({
serverUrl,
token,
onDisconnected,
}: {
serverUrl: string
token: string
onDisconnected?: () => void
}) {
const options = useMemo<RoomOptions>(
() => ({
/*
* Klient pobiera tylko strumienie faktycznie widoczne na ekranie
* i w jakosci dobranej do rozmiaru kafelka. Bez tego uczestnik na
* telefonie sciagalby obraz w pelnej rozdzielczosci od kazdego mowcy.
*/
adaptiveStream: true,
/*
* Serwer wstrzymuje warstwy, ktorych nikt nie oglada. Dziala po stronie
* nadawcy, wiec oszczedza pasmo takze prelegentowi.
*/
dynacast: true,
publishDefaults: {
// wideo uczestnika jest wylaczone na poziomie tokenu; tutaj ustawiamy
// wylacznie parametry dla tych, ktorzy publikowac moga
simulcast: true,
},
}),
[],
)
return (
<LiveKitRoomProvider
serverUrl={serverUrl}
token={token}
options={options}
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
connect
video={false}
audio={false}
onDisconnected={onDisconnected}
className="h-full w-full"
data-lk-theme="default"
>
<VideoConference />
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
<RoomAudioRenderer />
</LiveKitRoomProvider>
)
}
@@ -0,0 +1,246 @@
import { useEffect, useState } from 'react'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { OfferTab } from './OfferTab'
import type { useSpeakRequest } from '../../lib/useSpeakRequest'
/**
* Pasek akcji uczestnika.
*
* DLACZEGO NAD PANELEM, A NIE W ZAKLADCE
* ------------------------------------------------------------------------
* Te trzy akcje musza byc osiagalne **niezaleznie od tego, co uczestnik
* ogląda**. Prosba o glos schowana w zakladce "Pytania" jest bezuzyteczna
* dokladnie wtedy, gdy jest potrzebna: prowadzacy wlasnie zaprosil do
* zabrania glosu, a uczestnik czyta czat.
*
* Zapytanie ofertowe otwiera sie w oknie, a nie przelacza zakladke: wysylajac
* zapytanie w trakcie prezentacji uczestnik nie chce stracic z oczu tego, co
* dzieje sie na sali.
*
* STAN PRZYCISKU PO WYSLANIU
* ------------------------------------------------------------------------
* Po wyslaniu zapytania przycisk pokazuje potwierdzenie zamiast zachety.
* Bez tego uczestnik, ktory nie widzi zadnej zmiany, klika drugi i trzeci raz,
* a handlowiec dostaje trzy zgloszenia od tej samej osoby. Kolejne zapytanie
* jest mozliwe, ale wymaga swiadomego klikniecia w "Wyslij kolejne".
*/
export function MeetingActionBar({
token,
speak,
/** Prelegent nie prosi o glos: ma go z definicji. */
isSpeaker,
/** Tryb otwartego mikrofonu: kazdy mowi bez proszenia o glos. */
openMic,
/**
* Temat wybrany klikiem w baner.
*
* Baner z tematem oferty otwiera formularz **tutaj**, a nie u siebie:
* okno oferty juz istnieje w pasku akcji wraz z obsluga zgody i stanem
* po wyslaniu, a drugie takie okno oznaczaloby dwie kopie tej samej logiki.
*/
offerTopicFromBanner,
onOfferHandled,
}: {
token: string
speak: ReturnType<typeof useSpeakRequest>
isSpeaker: boolean
openMic: boolean
offerTopicFromBanner?: string | null
onOfferHandled?: () => void
}) {
const [offerOpen, setOfferOpen] = useState<'full' | 'callback' | null>(null)
const [offerSent, setOfferSent] = useState(false)
/** Temat podstawiony z banera, przekazywany do formularza. */
const [presetTopic, setPresetTopic] = useState<string | null>(null)
/*
* Klikniecie w baner z tematem otwiera pelny formularz z tym tematem.
* Stan "wyslane" cofamy: uczestnik swiadomie kliknal reklame, wiec chce
* wyslac kolejne zapytanie, a nie zobaczyc potwierdzenie poprzedniego.
*/
useEffect(() => {
if (!offerTopicFromBanner) return
setPresetTopic(offerTopicFromBanner)
setOfferSent(false)
setOfferOpen('full')
onOfferHandled?.()
}, [offerTopicFromBanner, onOfferHandled])
return (
<>
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-3 py-2">
{/*
Prelegent nie zglasza sie o glos ani nie wysyla zapytan ofertowych.
Zamiast pustego paska pokazujemy mu, gdzie moderowac: kolejka glosu,
otwarty mikrofon i moderacja pytan sa w panelu prowadzenia, dostepnym
dla zalogowanego prowadzacego. Sala sluzy do obrazu.
*/}
{isSpeaker && (
<span className="inline-flex items-center gap-1.5 text-sm text-slate-500">
<Icon name="mic" size={16} className="text-ok-500" />
Prowadzisz spotkanie. Zgłoszeniami do głosu i pytaniami zarządzasz w panelu
prowadzenia (otwórz go w panelu, przy tym webinarze).
</span>
)}
{/*
W trybie otwartego mikrofonu przycisk zgloszenia znika: proszenie
o cos, co juz sie ma, tylko myli. Zostaje informacja, ze mikrofon
jest dostepny, bo uczestnik musi wiedziec, ze moze byc slyszany.
*/}
{!isSpeaker &&
(openMic ? (
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-medium text-ok-600">
<Icon name="mic" size={16} />
Możesz mówić bez zgłaszania się
</span>
) : (
<SpeakButton speak={speak} />
))}
{/*
Pasek oferty to akcje UCZESTNIKA (zapytanie do handlowca). Prelegent
i prowadzacy ich nie wysylaja - kontakt handlowy zglasza osoba
ogladajaca, nie ta, ktora prowadzi. Dlatego caly blok tylko dla
nie-prelegenta. Moderacja glosu i pytan jest w panelu prowadzenia,
nie tutaj.
*/}
{!isSpeaker && (
<div className="ml-auto flex flex-wrap items-center gap-2">
{offerSent ? (
/*
* Potwierdzenie zamiast przyciskow. Zachowujemy mozliwosc wyslania
* kolejnego zapytania, ale dopiero po swiadomym kliknieciu:
* uczestnik ma zobaczyc, ze pierwsze doszlo.
*/
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-medium text-ok-600">
<Icon name="check-circle" size={16} />
Zapytanie wysłane
</span>
<button
type="button"
onClick={() => {
setOfferSent(false)
setOfferOpen('full')
}}
className="min-h-[44px] rounded-md px-2 text-sm text-slate-500 underline-offset-2 hover:text-slate-900 hover:underline"
>
Wyślij kolejne
</button>
</div>
) : (
<>
<button
type="button"
onClick={() => setOfferOpen('callback')}
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-3 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
>
<Icon name="mail" size={16} />
Proszę o kontakt
</button>
{/* wyrozniony: to jest akcja, na ktorej zalezy organizatorowi */}
<button
type="button"
onClick={() => setOfferOpen('full')}
className="inline-flex min-h-[44px] items-center gap-2 rounded-md bg-blue-500 px-4 text-sm font-semibold text-white shadow-sm transition-colors duration-fast ease-standard hover:bg-blue-600"
>
<Icon name="send" size={16} />
Interesuje mnie oferta
</button>
</>
)}
</div>
)}
</div>
{offerOpen && (
<Modal
title={offerOpen === 'callback' ? 'Proszę o kontakt' : 'Zapytanie ofertowe'}
description={
offerOpen === 'callback'
? 'Zostaw numer, oddzwonimy po webinarze.'
: 'Napisz, czego potrzebujesz. Nie tracisz kontaktu ze spotkaniem.'
}
onClose={() => {
setOfferOpen(null)
setPresetTopic(null)
}}
>
<OfferTab
token={token}
callbackOnly={offerOpen === 'callback'}
presetTopic={offerOpen === 'full' ? presetTopic : null}
onSent={() => {
setOfferSent(true)
// okno zostaje otwarte na potwierdzeniu z tresc zwrotna
// z backendu; uczestnik zamyka je sam, gdy je przeczyta
}}
/>
</Modal>
)}
</>
)
}
/**
* Przycisk prosby o glos.
*
* Trzy stany, bo trzy rozne sytuacje: nie prosil, czeka, ma glos. Jeden
* przycisk przelaczajacy sie miedzy "zglos" i "wycofaj" gubilby stan
* oczekiwania, czyli dokladnie ten, w ktorym uczestnik zastanawia sie,
* czy zgloszenie w ogole doszlo.
*/
function SpeakButton({ speak }: { speak: ReturnType<typeof useSpeakRequest> }) {
if (speak.granted) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-semibold text-ok-600">
<Icon name="check-circle" size={16} />
Masz głos, mikrofon jest włączony
</span>
<button
type="button"
onClick={speak.lowerHand}
disabled={speak.busy}
className="min-h-[44px] rounded-md px-2 text-sm text-slate-500 underline-offset-2 hover:text-slate-900 hover:underline disabled:opacity-50"
>
Oddaj głos
</button>
</div>
)
}
if (speak.pending) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-warn-50 px-3 py-1.5 text-sm font-medium text-warn-600">
<Icon name="clock" size={16} />
Czekasz na głos
</span>
<button
type="button"
onClick={speak.lowerHand}
disabled={speak.busy}
className="min-h-[44px] rounded-md px-2 text-sm text-slate-500 underline-offset-2 hover:text-slate-900 hover:underline disabled:opacity-50"
>
Wycofaj
</button>
</div>
)
}
return (
<button
type="button"
onClick={speak.raiseHand}
disabled={speak.busy}
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-3 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50 disabled:opacity-50"
>
<Icon name="hand" size={16} />
Chcę zabrać głos
</button>
)
}
@@ -0,0 +1,88 @@
import { Icon } from '../../components/Icon'
import { formatWithZone } from '../../lib/dates'
/**
* Ekran wejsciowy, przed wejsciem na sale.
*
* DLACZEGO W OGOLE ISTNIEJE
* ------------------------------------------------------------------------
* Link z maila otwiera sie bez logowania i bez hasla, wiec uczestnik nie ma
* zadnego potwierdzenia, **kim jest dla systemu**. Wejscie prosto do pokoju
* oznaczaloby, ze osoba, ktora dostala link przekazany od kolegi, zaczyna
* wystepowac pod cudzym nazwiskiem na czacie i nie ma jak tego zauwazyc.
*
* Ekran pokazuje wiec imie i nazwisko z zapisu **do potwierdzenia**, zanim
* cokolwiek napisze. Przy okazji daje chwile na sprawdzenie, czy to na pewno
* ten webinar i o ktorej sie zaczyna.
*
* Prelegent widzi to samo, z wyraznym oznaczeniem roli: link prelegenta daje
* uprawnienia moderatora, wiec pomylka linkow ma byc widoczna od razu.
*/
export function MeetingLobby({
webinarTitle,
displayName,
role,
startsAt,
timezone,
onEnter,
}: {
webinarTitle: string
displayName: string
role: string
/** Godzina rozpoczecia. Null, gdy backend jej nie podal. */
startsAt: string | null
timezone: string
onEnter: () => void
}) {
const isPresenter = role === 'PRESENTER'
return (
<div className="mx-auto max-w-md">
<div className="rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm">
<Icon
name={isPresenter ? 'presentation' : 'video'}
size={28}
className="mx-auto text-blue-500"
/>
<h1 className="mt-3 text-lg font-semibold text-slate-900">{webinarTitle}</h1>
{startsAt && (
<p className="hx-data mt-1 text-sm text-slate-500">
{formatWithZone(startsAt, timezone)}
</p>
)}
{/* tozsamosc do potwierdzenia: pod tym nazwiskiem uczestnik wystapi na czacie */}
<div className="mt-5 rounded-md border border-slate-200 bg-slate-25 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Wchodzisz jako
</p>
<p className="mt-1 text-base font-medium text-slate-900">{displayName}</p>
{isPresenter && (
<p className="mt-2 inline-flex items-center gap-1.5 rounded-md bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
<Icon name="presentation" size={14} />
Prowadzący
</p>
)}
</div>
<button
type="button"
onClick={onEnter}
className="mt-5 inline-flex min-h-[52px] w-full items-center justify-center gap-2 rounded-md bg-blue-500 px-5 text-base font-semibold text-white shadow-sm transition-colors duration-fast ease-standard hover:bg-blue-600"
>
<Icon name="log-in" size={18} />
{isPresenter ? 'Wejdź i rozpocznij' : 'Dołącz do webinaru'}
</button>
<p className="mt-3 text-xs leading-relaxed text-slate-500">
{isPresenter
? 'Możesz wejść przed uczestnikami i sprawdzić sprzęt.'
: 'Twój mikrofon i kamera są wyłączone. O głos możesz poprosić w trakcie.'}
</p>
</div>
</div>
)
}
+568
View File
@@ -0,0 +1,568 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { publicClient } from '../../api/publicClient'
import { Icon } from '../../components/Icon'
import { Spinner } from '../../components/ui'
import { useBanners } from '../../lib/useBanners'
import { useSpeakRequest } from '../../lib/useSpeakRequest'
import { BannerSlot } from './BannerSlot'
/*
* Biblioteki wideo sa ladowane leniwie.
*
* `@livekit/components-react` i `@jitsi/react-sdk` wazyly razem tyle, co cala
* reszta aplikacji: glowny pakiet urosl z 859 kB do 1552 kB. Sa potrzebne
* **wylacznie na sali**, a panel administracyjny i strony publiczne otwiera
* sie znacznie czesciej niz spotkanie. Bez tego podzialu kazde wejscie
* na strone zapisu pobieralo by caly klient WebRTC.
*
* Ta sama regula co przy `@mui/x-charts` w statystykach.
*/
const LiveKitRoom = lazy(() =>
import('./LiveKitRoom').then((m) => ({ default: m.LiveKitRoom })))
const JitsiRoom = lazy(() =>
import('./JitsiRoom').then((m) => ({ default: m.JitsiRoom })))
const ZoomRoom = lazy(() =>
import('./ZoomRoom').then((m) => ({ default: m.ZoomRoom })))
import { InteractionPanel } from './InteractionPanel'
import { MeetingActionBar } from './MeetingActionBar'
import { MeetingLobby } from './MeetingLobby'
import { PublicLayout } from './PublicLayout'
/**
* Strona spotkania: jeden ekran, w ktorym wideo i wszystkie interakcje sa
* razem.
*
* JEDNA TRASA DLA OBU ROL
* ------------------------------------------------------------------------
* Token w adresie moze nalezec do uczestnika albo do prelegenta, wiec
* probujemy obu endpointow. Rozdzielanie tego na dwie trasy oznaczaloby, ze
* organizator musi pamietac, ktory link komu wyslal, a pomylka konczylaby sie
* bledem zamiast wejsciem do pokoju.
*
* DWA ETAPY: PRZEDPOKOJ I SALA
* ------------------------------------------------------------------------
* Najpierw ekran wejsciowy z potwierdzeniem tozsamosci, dopiero po kliknieciu
* sala. Wejscie prosto do pokoju oznaczaloby, ze osoba z przekazanym linkiem
* zaczyna wystepowac pod cudzym nazwiskiem, nie majac szansy tego zauwazyc.
*
* ODSWIEZENIE TOKENU PO PRZYZNANIU GLOSU
* ------------------------------------------------------------------------
* Mikrofon otwiera **token wydany u dostawcy**, a nie stan w przegladarce.
* Po przyznaniu glosu pobieramy wiec token na nowo: bez tego uczestnik
* widzialby napis "masz glos" i dalej mial zamkniety mikrofon.
*/
type JoinView = {
url: string
token: string | null
embedded: boolean
role: string
webinarTitle: string
displayName: string
opensAt: string
closesAt: string
startsAt: string | null
timezone: string
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */
provider: string
roomId: string | null
/**
* Uprawnienia publikacji.
*
* Przy LiveKit powtarzaja to, czego i tak pilnuje serwer na podstawie tokenu.
* Przy Jitsi sa jedynym, co odroznia role, bo serwer ich nie zna.
*/
canPublishVideo: boolean
canPublishAudio: boolean
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
clientKey: string | null
}
type ClosedInfo = {
message: string
opensAt: string
closesAt: string
beforeStart: boolean
}
/** Kolejnosc prob: uczestnikow jest wielokrotnie wiecej niz prelegentow. */
const JOIN_PATHS = ['/public/registrations', '/public/speakers'] as const
export function MeetingPage() {
const { token } = useParams<{ token: string }>()
const [join, setJoin] = useState<JoinView | null>(null)
const [closed, setClosed] = useState<ClosedInfo | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
/** Czy uczestnik przeszedl juz przez ekran wejsciowy. */
const [entered, setEntered] = useState(false)
const load = useCallback(async () => {
setError(null)
setClosed(null)
for (const base of JOIN_PATHS) {
try {
const response = await publicClient.get<JoinView>(`${base}/${token}/join`)
setJoin(response.data)
setLoading(false)
return
} catch (e) {
const status = (e as { response?: { status?: number; data?: ClosedInfo & { message?: string } } })
.response
// 409 oznacza, ze token jest poprawny, ale pokoj jeszcze zamkniety
if (status?.status === 409 && status.data) {
setClosed(status.data as ClosedInfo)
setLoading(false)
return
}
// 404 przy uczestniku moze znaczyc, ze to token prelegenta: probujemy dalej
if (status?.status !== 404) {
setError(status?.data?.message ?? 'Nie udało się otworzyć pokoju.')
setLoading(false)
return
}
}
}
setError('Nie znaleziono takiego linku. Sprawdź, czy adres jest kompletny.')
setLoading(false)
}, [token])
useEffect(() => {
setLoading(true)
void load()
}, [load])
if (loading) {
return (
<PublicLayout>
<Spinner />
</PublicLayout>
)
}
if (closed) {
return (
<PublicLayout>
<RoomClosed info={closed} />
</PublicLayout>
)
}
if (error) {
return (
<PublicLayout>
<InvalidToken message={error} />
</PublicLayout>
)
}
if (!join) return null
if (!entered) {
return (
<PublicLayout>
<MeetingLobby
webinarTitle={join.webinarTitle}
displayName={join.displayName}
role={join.role}
startsAt={join.startsAt}
timezone={join.timezone}
onEnter={() => setEntered(true)}
/>
</PublicLayout>
)
}
return <MeetingRoom join={join} token={token ?? ''} onRefreshTicket={load} />
}
/**
* Sala: wideo i panel interakcji na jednym ekranie.
*
* Na szerokim ekranie wideo po lewej, panel po prawej. Na waskim wideo u gory,
* panel pod nim: panel obok wideo na telefonie zostawialby obrazowi pasek
* szerokosci kilku centymetrow, na ktorym nic nie widac.
*
* W trybie NONE **nie przekierowujemy**. Obraz jest w zewnetrznym narzedziu,
* ale czat, pytania, zadania i zapytania ofertowe zostaja tutaj. Wyrzucenie
* uczestnika ze strony odcieloby go od nich calkowicie, a wielu klientow
* zostaje na tym trybie na stale.
*/
function MeetingRoom({
join,
token,
onRefreshTicket,
}: {
join: JoinView
token: string
onRefreshTicket: () => Promise<void>
}) {
const isSpeaker = join.role === 'PRESENTER'
const speak = useSpeakRequest(isSpeaker ? undefined : token)
// gdy prowadzacy zablokuje uczestnika, opuszcza on sale natychmiast
const [banned, setBanned] = useState(false)
/*
* Tryb otwartego mikrofonu. Stan poczatkowy przychodzi z historii interakcji,
* a zmiany w trakcie spotkania zdarzeniem z WebSocketu.
*/
const [openMic, setOpenMic] = useState(false)
/*
* Tryb "wideo dla wszystkich". Jak otwarty mikrofon, ale dla obrazu: gdy
* prowadzacy go wlaczy, uczestnik pobiera token na nowo i dostaje kamere.
*/
const [openVideo, setOpenVideo] = useState(false)
/*
* Banery. Prelegent ich nie potrzebuje: reklama jest kierowana do
* uczestnikow, a prowadzacemu zabieralaby miejsce na obraz.
*/
const bannerState = useBanners(isSpeaker ? undefined : token)
/** Temat oferty wybrany klikiem w baner, przekazywany do paska akcji. */
const [offerTopicFromBanner, setOfferTopicFromBanner] = useState<string | null>(null)
/*
* Przyznanie albo odebranie glosu zmienia uprawnienia w tokenie dostawcy,
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
*/
const [voiceState, setVoiceState] = useState<'none' | 'granted' | 'video'>('none')
useEffect(() => {
// otwarty mikrofon i wideo dla wszystkich zmieniaja uprawnienia w tokenie
// tak samo jak przyznany glos, wiec ticket trzeba pobrac na nowo. Wideo
// to inny poziom niz sam glos, wiec jest osobnym stanem, zeby przelaczenie
// miedzy nimi tez wymuszalo odswiezenie.
const next: 'none' | 'granted' | 'video' = openVideo
? 'video'
: speak.granted || openMic
? 'granted'
: 'none'
if (next === voiceState) return
setVoiceState(next)
if (join.embedded) void onRefreshTicket()
}, [speak.granted, openMic, openVideo, voiceState, join.embedded, onRefreshTicket])
const src = useMemo(() => {
if (!join.embedded) return null
const url = new URL(join.url)
if (join.token) url.searchParams.set('t', join.token)
return url.toString()
}, [join])
// uczestnik zablokowany przez prowadzacego opuszcza sale: pokazujemy komunikat
// zamiast obrazu i panelu, a przy probie powrotu backend i tak go nie wpusci
if (banned) {
return (
<PublicLayout>
<div className="space-y-4 text-center">
<Icon name="user-off" size={40} className="mx-auto text-alarm-500" />
<h1 className="text-xl font-semibold text-slate-900">Dostęp do spotkania zablokowany</h1>
<p className="text-sm text-slate-600">
Prowadzący zablokował Ci dostęp do tego webinaru. Jeśli uważasz, że to pomyłka,
skontaktuj się z organizatorem.
</p>
</div>
</PublicLayout>
)
}
return (
<div className="flex h-[100dvh] flex-col bg-navy-900">
<header className="flex items-center justify-between gap-3 px-4 py-2 text-white">
<p className="min-w-0 truncate text-sm font-medium">{join.webinarTitle}</p>
<p className="hx-data shrink-0 text-xs text-white/70">
{join.displayName}
{isSpeaker && ' (prowadzący)'}
{!isSpeaker && (speak.granted || openMic) && ' (głos)'}
</p>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<div className="relative flex min-h-0 flex-1 flex-col">
{/*
Kazdy dostawca ma wlasny sposob osadzenia, wiec wybor jest tutaj,
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
i banery zostaja nasze niezaleznie od tego wyboru.
*/}
<VideoArea join={join} src={src} />
{/*
OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
sesji, bo baner nie do usuniecia w trakcie prezentacji jest gorszy
niz brak banera.
*/}
<BannerSlot
placement="OVERLAY"
banners={bannerState.forPlacement('OVERLAY')}
rotationSeconds={bannerState.rotationSeconds}
onImpression={bannerState.countImpression}
onClick={bannerState.countClick}
onDismiss={bannerState.dismiss}
onOfferTopic={setOfferTopicFromBanner}
className="absolute bottom-3 right-3 z-10 w-56 max-w-[45%]"
/>
{/* BELOW_VIDEO: pas pod obrazem, na calej jego szerokosci */}
<BannerSlot
placement="BELOW_VIDEO"
banners={bannerState.forPlacement('BELOW_VIDEO')}
rotationSeconds={bannerState.rotationSeconds}
onImpression={bannerState.countImpression}
onClick={bannerState.countClick}
onOfferTopic={setOfferTopicFromBanner}
className="mx-3 mb-3 shrink-0"
/>
</div>
<aside className="flex min-h-0 flex-1 flex-col border-t border-slate-200 bg-white lg:w-96 lg:flex-none lg:border-l lg:border-t-0">
{/*
Pasek akcji nad panelem, wiec widoczny niezaleznie od wybranej
zakladki: prosba o glos schowana w zakladce jest bezuzyteczna
dokladnie wtedy, gdy jest potrzebna.
*/}
{/*
SIDE: nad zakladkami, czyli w miejscu, ktore uczestnik widzi
niezaleznie od tego, ktora zakladke ma otwarta.
*/}
<BannerSlot
placement="SIDE"
banners={bannerState.forPlacement('SIDE')}
rotationSeconds={bannerState.rotationSeconds}
onImpression={bannerState.countImpression}
onClick={bannerState.countClick}
onOfferTopic={setOfferTopicFromBanner}
className="m-3 shrink-0"
/>
<MeetingActionBar
token={token}
speak={speak}
isSpeaker={isSpeaker}
openMic={openMic}
offerTopicFromBanner={offerTopicFromBanner}
onOfferHandled={() => setOfferTopicFromBanner(null)}
/>
{speak.error && (
<p className="border-b border-alarm-500/20 bg-alarm-50 px-3 py-2 text-xs text-alarm-600">
{speak.error}
</p>
)}
<InteractionPanel
token={token}
onSpeakEvent={speak.apply}
onOpenMicChange={setOpenMic}
onOpenVideoChange={setOpenVideo}
onModeration={(event) => {
if (event.kind === 'BANNED') setBanned(true)
if (event.kind === 'UNBANNED') setBanned(false)
}}
onHistoryLoaded={(history) => {
setOpenMic(history.openMic)
setOpenVideo(history.openVideo)
}}
/>
</aside>
</div>
</div>
)
}
/**
* Obszar wideo: wlasciwy komponent dla wybranego dostawcy.
*
* LiveKit i Jitsi maja wlasne biblioteki i wlasny sposob osadzenia, wiec nie
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
* dziala jego gotowy komponent.
*/
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
// tryb NONE: obraz jest w zewnetrznym narzedziu, panel zostaje u nas
if (!join.embedded) {
return <ExternalVideo url={join.url} />
}
if (join.provider === 'LIVEKIT' && join.token) {
return (
<div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}>
{/*
key={join.token}: po przyznaniu albo odebraniu glosu backend wydaje
NOWY token z innym uprawnieniem do mikrofonu, a `load()` go pobiera.
LiveKitRoomProvider laczy sie z tokenem podanym przy montowaniu i nie
renegocjuje uprawnien na zywo - wiec zmiana samego propa `token` nie
otwiera mikrofonu, dopoki uczestnik nie przeladuje strony. Zmiana
`key` wymusza przemontowanie providera z nowym tokenem: to samo, co
robi przeladowanie, ale bez wychodzenia ze spotkania.
*/}
<LiveKitRoom key={join.token} serverUrl={join.url} token={join.token} />
</Suspense>
</div>
)
}
if (join.provider === 'JITSI' && join.roomId) {
/*
* Adres pokoju Jitsi to `https://domena/pokoj`, wiec domene wyciagamy
* z adresu, zamiast dopisywac kolejne pole do odpowiedzi.
*/
const domain = join.url.replace(/^https?:\/\//, '').split('/')[0]
return (
<div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}>
<JitsiRoom
domain={domain}
roomName={join.roomId}
displayName={join.displayName}
jwt={join.token}
canPublishVideo={join.canPublishVideo}
canPublishAudio={join.canPublishAudio}
/>
</Suspense>
</div>
)
}
if (join.provider === 'ZOOM' && join.roomId && join.token && join.clientKey) {
return (
<div className="min-h-[38vh] flex-1 lg:min-h-0">
<Suspense fallback={<VideoLoading />}>
<ZoomRoom
meetingNumber={join.roomId}
signature={join.token}
sdkKey={join.clientKey}
displayName={join.displayName}
isHost={join.role === 'PRESENTER'}
/>
</Suspense>
</div>
)
}
// Daily i pozostali: gotowy komponent dostawcy w iframe
return src ? (
<iframe
title="Pokój spotkania"
src={src}
className="min-h-[38vh] flex-1 border-0 lg:min-h-0"
// uprawnienia przegladarki; wlasciwe uprawnienia publikacji i tak
// wynikaja z tokenu wydanego przez backend
allow="camera; microphone; fullscreen; speaker; display-capture; autoplay"
/>
) : (
<ExternalVideo url={join.url} />
)
}
/** Zaslona na czas pobierania biblioteki wideo. */
function VideoLoading() {
return (
<div className="flex h-full items-center justify-center text-sm text-white/60">
Ładowanie sali...
</div>
)
}
/**
* Tryb bez osadzonego wideo.
*
* Obraz idzie zewnetrznym narzedziem, ale panel interakcji zostaje u nas,
* wiec pokazujemy wyrazny przycisk otwierajacy spotkanie w nowej karcie
* zamiast pustego miejsca.
*/
function ExternalVideo({ url }: { url: string }) {
return (
<div className="flex min-h-[38vh] flex-1 flex-col items-center justify-center gap-3 p-6 text-center lg:min-h-0">
<Icon name="video" size={32} className="text-white/60" />
<p className="text-sm text-white/70">
Obraz i dźwięk tego webinaru są w zewnętrznym narzędziu.
</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-[48px] items-center gap-2 rounded-md bg-blue-500 px-5 text-sm font-semibold text-white transition-colors duration-fast ease-standard hover:bg-blue-600"
>
<Icon name="video" size={16} />
Otwórz spotkanie w nowej karcie
</a>
<p className="text-xs text-white/50">
Czat, pytania i zgłoszenia zostają na tej stronie. Wróć tutaj, żeby z nich korzystać.
</p>
</div>
)
}
/** Token nieznany albo uszkodzony: kierujemy tam, gdzie mozna to naprawic. */
function InvalidToken({ message }: { message: string }) {
return (
<div className="rounded-lg border border-alarm-500/20 bg-alarm-50 p-6 text-center">
<Icon name="circle-alert" size={28} className="mx-auto text-alarm-600" />
<p className="mt-3 text-sm text-alarm-600">{message}</p>
<p className="mt-3 text-sm text-slate-600">
Link mógł zostać skrócony przez program pocztowy. Skopiuj go z wiadomości w całości
albo wróć na{' '}
<a href="/" className="font-medium text-blue-700 underline underline-offset-2">
stronę główną
</a>
, żeby zapisać się ponownie.
</p>
</div>
)
}
/** Pokoj jeszcze zamkniety: komunikat wraz z odliczaniem do otwarcia. */
function RoomClosed({ info }: { info: ClosedInfo }) {
const [left, setLeft] = useState(() => remaining(info.opensAt))
useEffect(() => {
if (!info.beforeStart) return
const timer = window.setInterval(() => setLeft(remaining(info.opensAt)), 1000)
return () => window.clearInterval(timer)
}, [info])
return (
<div className="rounded-lg border border-slate-200 bg-white p-6 text-center">
<Icon name="clock" size={28} className="mx-auto text-blue-500" />
<p className="mt-3 text-lg font-semibold text-slate-900">{info.message}</p>
{info.beforeStart ? (
<>
<p className="mt-1 text-sm text-slate-500">Pokój otworzy się za:</p>
<p className="hx-data mt-2 text-2xl font-semibold text-slate-900">
{left ?? 'już za chwilę'}
</p>
<p className="mt-3 text-xs text-slate-400">
Strona odświeży się sama, gdy pokój będzie gotowy.
</p>
</>
) : (
<p className="mt-1 text-sm text-slate-500">
Jeśli webinar był nagrywany, organizator udostępni nagranie osobno.
</p>
)}
</div>
)
}
/** Ile zostalo do podanego momentu, w formie "2 godz. 15 min" albo "0:42". */
function remaining(iso: string): string | null {
const ms = new Date(iso).getTime() - Date.now()
if (ms <= 0) {
// pokoj wlasnie sie otworzyl: przeladowanie wchodzi do srodka
window.location.reload()
return null
}
const total = Math.floor(ms / 1000)
const days = Math.floor(total / 86400)
const hours = Math.floor((total % 86400) / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
if (days > 0) return `${days} dni ${hours} godz.`
if (hours > 0) return `${hours} godz. ${minutes} min`
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
@@ -0,0 +1,343 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isNotFound, publicClient, publicErrorMessage } from '../../api/publicClient'
import type { MyRegistration } from '../../api/types'
import { formatDateOnly, formatTimeOnly } from '../../lib/dates'
import { useDocumentMeta } from '../../lib/useDocumentMeta'
import { PublicLayout, PublicNotice } from './PublicLayout'
import { Icon } from '../../components/Icon'
export function MyRegistrationPage() {
const { token } = useParams<{ token: string }>()
const queryClient = useQueryClient()
const [confirmOpen, setConfirmOpen] = useState(false)
const [downloadError, setDownloadError] = useState<string | null>(null)
const query = useQuery({
queryKey: ['my-registration', token],
retry: false,
queryFn: async () => {
const response = await publicClient.get<MyRegistration>(`/public/registrations/${token}`)
return response.data
},
})
const registration = query.data
useDocumentMeta({
title: registration ? `Twój zapis - ${registration.webinarTitle}` : 'Twój zapis',
})
const cancel = useMutation({
mutationFn: async () => {
const response = await publicClient.post<MyRegistration>(
`/public/registrations/${token}/cancel`,
)
return response.data
},
onSuccess: (data) => {
queryClient.setQueryData(['my-registration', token], data)
setConfirmOpen(false)
},
})
/** Pobranie ICS przez fetch, zeby moc pokazac blad zamiast pustej karty. */
async function downloadIcs() {
setDownloadError(null)
try {
const response = await publicClient.get(`/public/registrations/${token}/invite.ics`, {
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = 'invite.ics'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (error) {
setDownloadError(publicErrorMessage(error, 'Nie udało się pobrać pliku kalendarza'))
}
}
if (query.isPending) {
return (
<PublicLayout>
<p className="flex items-center justify-center gap-2 py-16 text-sm text-slate-500">
<Icon name="loader" className="animate-spin" />
Wczytywanie...
</p>
</PublicLayout>
)
}
if (query.isError && isNotFound(query.error)) {
return (
<PublicLayout>
<PublicNotice
icon="search"
title="Nie znaleziono zapisu"
text="Ten link jest nieprawidłowy albo stracił ważność. Sprawdź adres w wiadomości e-mail."
/>
</PublicLayout>
)
}
if (query.isError || !registration) {
return (
<PublicLayout>
<PublicNotice
icon="circle-alert"
title="Nie udało się wczytać zapisu"
text={publicErrorMessage(query.error, 'Spróbuj odświeżyć stronę za chwilę.')}
/>
</PublicLayout>
)
}
const isCancelled = registration.status === 'CANCELLED'
return (
<PublicLayout>
<div className="space-y-5">
{isCancelled ? (
<PublicNotice
icon="x-circle"
title="Zrezygnowałeś z udziału"
text="Twoje miejsce zostało zwolnione. Jeśli zmienisz zdanie, możesz zapisać się ponownie, o ile są jeszcze wolne miejsca."
>
<Link
to={`/w/${registration.webinarSlug}`}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-600"
>
<Icon name="user-plus" size={18} />
Zapisz się ponownie
</Link>
</PublicNotice>
) : (
<section className="rounded-lg border border-ok-500/20 bg-ok-50 p-5 text-center sm:p-6">
<Icon name="check-circle" className="text-3xl text-ok-500" />
<h1 className="mt-1 text-lg font-semibold text-slate-900">Jesteś zapisany</h1>
<p className="mt-1 text-sm text-ok-600">
Twoje zgłoszenie zostało potwierdzone.
</p>
</section>
)}
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{registration.webinarTitle}</h2>
<p className="mt-1 text-sm text-slate-600">
{formatDateOnly(registration.startsAt, registration.timezone)}, godz.{' '}
{formatTimeOnly(registration.startsAt, registration.timezone)}{' '}
<span className="text-slate-400">(strefa {registration.timezone})</span>
</p>
<dl className="mt-5 space-y-3 border-t border-slate-100 pt-5 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Imię i nazwisko</dt>
<dd className="text-right font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Adres e-mail</dt>
<dd className="min-w-0 break-all text-right font-medium text-slate-900">
{registration.email}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Status</dt>
<dd className="text-right">
{isCancelled ? (
<span className="inline-flex rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600 ring-1 ring-inset ring-slate-200">
Rezygnacja
</span>
) : (
<span className="inline-flex rounded-full bg-ok-50 px-2 py-0.5 text-xs font-medium text-ok-600 ring-1 ring-inset ring-ok-500/20">
Potwierdzony
</span>
)}
</dd>
</div>
</dl>
{!isCancelled && registration.joinUrl && (
<a
href={registration.joinUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-5 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 px-4 py-3.5 text-base font-semibold text-white transition hover:bg-blue-600"
>
<Icon name="video" size={20} />
Dołącz do spotkania
</a>
)}
</section>
{/*
Nagranie i materialy po zakonczeniu webinaru.
Backend oddaje je wylacznie przy zapisie potwierdzonym i dopiero po
`endsAt`, wiec tutaj wystarczy sprawdzic, czy cokolwiek przyszlo.
*/}
{(registration.recordings.length > 0 ||
registration.materials.length > 0 ||
registration.certificate !== null) && (
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="text-lg font-semibold text-slate-900">Po webinarze</h2>
{registration.recordings.map((recording) => (
<div key={recording.id} className="space-y-2">
<p className="text-sm font-medium text-slate-700">{recording.title}</p>
{/*
Zwykly element video z controls, bez dodatkowych bibliotek.
Backend obsluguje naglowek Range, wiec przewijanie dziala.
preload="metadata" pobiera sam poczatek pliku: bez tego
przegladarka zaczynalaby sciagac cale nagranie od razu.
*/}
<video
className="w-full rounded-lg bg-slate-900"
controls
preload="metadata"
src={recording.url}
>
Twoja przeglądarka nie odtwarza wideo.{' '}
<a href={recording.url} className="underline">
Pobierz nagranie
</a>
</video>
</div>
))}
{/*
Certyfikat: osobny, wyrozniony przycisk. To dokument, a nie
kolejny plik do pobrania, wiec nie miesza sie z materialami.
*/}
{registration.certificate && (
<div className="rounded-lg border border-ok-500/30 bg-ok-50 p-4">
<div className="flex items-start gap-3">
<Icon name="check-circle" size={22} className="shrink-0 text-ok-600" />
<div className="min-w-0 flex-1">
<p className="font-medium text-slate-900">Certyfikat uczestnictwa</p>
<p className="hx-data mt-0.5 text-xs text-slate-600">
Numer {registration.certificate.number}
</p>
</div>
</div>
<a
href={registration.certificate.url}
className="mt-3 flex min-h-[48px] w-full items-center justify-center gap-2 rounded-lg bg-ok-600 px-4 text-base font-semibold text-white transition hover:bg-ok-600/90"
>
<Icon name="download" size={20} />
Pobierz certyfikat
</a>
</div>
)}
{registration.materials.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-slate-700">Materiały do pobrania</h3>
<ul className="space-y-2">
{registration.materials.map((material) => (
<li key={material.id}>
<a
href={material.url}
className="flex min-h-[48px] items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
<Icon name="download" size={20} className="shrink-0 text-blue-600" />
<span className="min-w-0 flex-1 break-words">{material.title}</span>
</a>
</li>
))}
</ul>
</div>
)}
</section>
)}
{!isCancelled && (
<section className="space-y-3">
<button
type="button"
onClick={downloadIcs}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-3.5 text-base font-medium text-slate-700 transition hover:bg-slate-50"
>
<Icon name="calendar-plus" size={20} />
Dodaj do kalendarza
</button>
{downloadError && (
<p className="rounded-lg border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600">
{downloadError}
</p>
)}
<button
type="button"
onClick={() => setConfirmOpen(true)}
className="w-full rounded-lg px-4 py-3 text-sm font-medium text-alarm-600 transition hover:bg-alarm-50"
>
Zrezygnuj z udziału
</button>
</section>
)}
</div>
{confirmOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
/*
Naklatka i ramka jak w oknach panelu (`Modal`): to samo
przycienienie, to samo zaokraglenie i odstep od brzegu ekranu
na kazdym rozmiarze. Wlasny znacznik, a nie komponent `Modal`,
bo strona publiczna nie ma naglowka z przyciskiem zamkniecia:
decyzje podejmuje sie tu dwoma przyciskami, zeby rezygnacja nie
byla mozliwa przez przypadkowe zamkniecie okna.
*/
className="fixed inset-0 z-50 flex items-end justify-center overflow-y-auto bg-navy-900/40 p-4 sm:items-center sm:p-6"
onClick={() => setConfirmOpen(false)}
>
<div
className="w-full max-w-sm rounded-lg border border-slate-100 bg-white p-4 shadow-xl sm:p-5"
onClick={(e) => e.stopPropagation()}
>
<h3 id="confirm-title" className="text-lg font-semibold text-slate-900">
Zrezygnować z udziału?
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-slate-600">
Twoje miejsce zostanie zwolnione i może je zająć ktoś inny. Ponowny zapis będzie
możliwy tylko wtedy, gdy będą wolne miejsca.
</p>
{cancel.isError && (
<p className="mt-3 rounded-lg border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600">
{publicErrorMessage(cancel.error, 'Nie udało się zrezygnować')}
</p>
)}
<div className="mt-5 flex flex-col gap-2 sm:flex-row-reverse">
<button
type="button"
onClick={() => cancel.mutate()}
disabled={cancel.isPending}
className="flex-1 rounded-lg bg-alarm-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-alarm-600 disabled:bg-alarm-50"
>
{cancel.isPending ? 'Rezygnuję...' : 'Tak, rezygnuję'}
</button>
<button
type="button"
onClick={() => setConfirmOpen(false)}
className="flex-1 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Zostaw zapis
</button>
</div>
</div>
</div>
)}
</PublicLayout>
)
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react'
import { publicClient, publicErrorMessage } from '../../api/publicClient'
import { Icon } from '../../components/Icon'
import { Button, Input, Select, Spinner, Textarea } from '../../components/ui'
/**
* Zapytanie ofertowe.
*
* Dane kontaktowe sa podstawione z zapisu i pokazane **do potwierdzenia**:
* uczestnik widzi, co dokladnie trafi do handlowca, i moze poprawic telefon.
*
* Zgoda na kontakt handlowy jest warunkiem wyslania. Nie jest zaznaczona
* domyslnie: zaznaczenie zgody za uzytkownika jest niedopuszczalne.
*
* TRYB UPROSZCZONY (`callbackOnly`)
* ------------------------------------------------------------------------
* "Prosze o kontakt" to ta sama sciezka, ale bez pola tresci: uczestnik
* w trakcie webinaru czesto nie ma czasu ani pomyslu, co napisac, a wymuszanie
* tresci konczy sie tym, ze nie wysyla nic. Tresc jest wtedy podstawiana
* zdaniem mowiacym wprost, o co chodzi, zeby handlowiec wiedzial, ze to prosba
* o telefon, a nie puste zapytanie.
*
* **Zgoda jest wymagana tak samo w obu trybach.** Uproszczenie dotyczy tego,
* ile uczestnik pisze, a nie tego, na co sie zgadza.
*/
type OfferForm = {
topics: string[]
consentText: string
consentVersion: string
fullName: string
email: string
phone: string | null
company: string | null
}
/** Tresc podstawiana przy prosbie o kontakt, gdy uczestnik nic nie pisze. */
const CALLBACK_CONTENT = 'Proszę o kontakt telefoniczny w sprawie oferty.'
export function OfferTab({
token,
callbackOnly = false,
presetTopic = null,
sourceBannerId = null,
onSent,
}: {
token: string
/** Wersja uproszczona: bez pola tresci i bez wyboru tematu. */
callbackOnly?: boolean
/**
* Temat wybrany z gory, gdy formularz otworzyl baner.
*
* Uczestnik moze go zmienic: baner mowi, czego zapytanie **prawdopodobnie**
* dotyczy, ale to uczestnik wie, o co chce zapytac.
*/
presetTopic?: string | null
/** Baner, z ktorego przyszlo zapytanie. Sluzy wylacznie statystyce. */
sourceBannerId?: number | null
/** Wolane po udanej wysylce, zeby pasek akcji mogl zmienic stan przycisku. */
onSent?: () => void
}) {
const [form, setForm] = useState<OfferForm | null>(null)
const [content, setContent] = useState('')
const [topic, setTopic] = useState('')
const [phone, setPhone] = useState('')
const [consent, setConsent] = useState(false)
const [sending, setSending] = useState(false)
const [sent, setSent] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
publicClient
.get<OfferForm>(`/public/participation/${token}/offer-form`)
.then((response) => {
setForm(response.data)
setPhone(response.data.phone ?? '')
// temat z banera podstawiamy tylko wtedy, gdy taki temat istnieje
// w tym webinarze: lista tematow mogla sie zmienic po utworzeniu banera
if (presetTopic && response.data.topics.includes(presetTopic)) {
setTopic(presetTopic)
}
})
.catch(() => setError('Nie udało się wczytać formularza.'))
}, [token])
async function submit() {
setSending(true)
setError(null)
try {
const response = await publicClient.post<{ confirmation: string }>(
`/public/participation/${token}/offer`,
{
// w trybie uproszczonym uczestnik nic nie pisze, wiec tresc mowi
// wprost, o co prosi; handlowiec nie dostaje pustego zapytania
content: callbackOnly && !content.trim() ? CALLBACK_CONTENT : content,
topic: topic || null,
phone: phone || null,
consentGranted: consent,
sourceBannerId,
},
)
setSent(response.data.confirmation)
onSent?.()
} catch (e) {
setError(publicErrorMessage(e, 'Nie udało się wysłać zapytania.'))
} finally {
setSending(false)
}
}
if (!form) return <Spinner />
if (sent) {
return (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="rounded-md border border-ok-500/30 bg-ok-50 p-4 text-center">
<Icon name="check-circle" size={24} className="mx-auto text-ok-600" />
<p className="mt-2 text-sm text-slate-700">{sent}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-3 w-full"
onClick={() => {
setSent(null)
setContent('')
setConsent(false)
}}
>
Wyślij kolejne zapytanie
</Button>
</div>
)
}
return (
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{error && (
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-2 text-xs text-alarm-600">
{error}
</p>
)}
<p className="text-xs leading-relaxed text-slate-500">
{callbackOnly
? 'Oddzwonimy pod podany numer po webinarze. Nie musisz nic pisać.'
: 'Napisz, czego potrzebujesz. Skontaktujemy się z Tobą po webinarze.'}
</p>
{!callbackOnly && form.topics.length > 0 && (
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">
Czego dotyczy zapytanie
</span>
<Select value={topic} onChange={(e) => setTopic(e.target.value)}>
<option value="">Wybierz temat (opcjonalnie)</option>
{form.topics.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Select>
</label>
)}
{!callbackOnly && (
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">Treść zapytania</span>
<Textarea
rows={4}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="np. Proszę o wycenę wdrożenia dla 20 użytkowników"
/>
</label>
)}
{/* dane z zapisu, pokazane do potwierdzenia */}
<div className="rounded-md border border-slate-200 bg-slate-25 p-3">
<p className="mb-2 text-xs font-semibold text-slate-700">Twoje dane kontaktowe</p>
<p className="text-sm text-slate-700">{form.fullName}</p>
<p className="hx-data break-all text-xs text-slate-500">{form.email}</p>
{form.company && <p className="text-xs text-slate-500">{form.company}</p>}
<label className="mt-2 block">
<span className="mb-1 block text-xs font-semibold text-slate-700">
Telefon {form.phone ? '(możesz poprawić)' : '(opcjonalnie)'}
</span>
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="np. 600100200"
/>
</label>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 p-3">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="text-xs leading-relaxed text-slate-600">
<span className="font-medium text-slate-900">Wymagane: </span>
{form.consentText}
</span>
</label>
<Button
type="button"
icon="send"
className="w-full"
onClick={submit}
disabled={sending || (!callbackOnly && !content.trim()) || !consent}
>
{sending
? 'Wysyłanie...'
: callbackOnly
? 'Poproś o kontakt'
: 'Wyślij zapytanie'}
</Button>
{!consent && (callbackOnly || content.trim()) && (
<p className="text-xs text-warn-600">
Bez zgody na kontakt handlowy nie możemy przyjąć zapytania.
</p>
)}
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { BrandMark } from '../../components/BrandMark'
import { Icon } from '../../components/Icon'
import type { IconName } from '../../components/Icon'
import { useInstanceFavicon } from '../../lib/usePublicConfig'
import { useLegal } from '../../lib/useLegal'
/**
* Rama strony publicznej.
*
* Domyslnie WASKA (`max-w-2xl`): wiekszosc stron publicznych to jeden formularz
* albo krotki komunikat, wygodniejszy na telefonie w waskiej kolumnie.
*
* `wide` przelacza na szerokosc panelu (`max-w-container`, 1200px) - dla stron,
* ktore maja miescic wiecej tresci obok siebie i wygladac jak reszta serwisu:
* strona zapisu na webinar oraz dokumenty prawne (regulamin, polityka).
* Szerokosc ustawia layout, a nie poszczegolna strona - zgodnie z regula, ze
* strona nie ustawia wlasnej szerokosci.
*/
export function PublicLayout({ children, wide = false }: { children: ReactNode; wide?: boolean }) {
useInstanceFavicon()
const legal = useLegal()
const company = legal.data?.company
// regulamin i polityka to gotowe dokumenty - sa zawsze dostepne,
// wiec odnosniki pokazujemy bezwarunkowo (o ile dane w ogole sie zaladowaly)
const ready = Boolean(legal.data)
const widthClass = wide ? 'max-w-container' : 'max-w-2xl'
return (
<div className="flex min-h-screen flex-col bg-slate-50">
<header className="border-b border-slate-100 bg-white">
<div className={`mx-auto flex w-full ${widthClass} items-center px-4 py-4`}>
<BrandMark />
</div>
</header>
<main className={`mx-auto w-full ${widthClass} flex-1 px-4 py-6 sm:py-10`}>{children}</main>
{/*
Stopka prawna: uczestnik webinaru MUSI mieć dostęp do regulaminu,
polityki prywatności i tożsamości administratora danych (RODO).
Puste dokumenty nie pokazują odnośnika - odnośnik donikąd jest gorszy
niż jego brak.
*/}
<footer className={`mx-auto w-full ${widthClass} space-y-2 px-4 pb-8 pt-4 pb-safe`}>
{ready && (
<p className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-slate-500">
<Link to="/regulamin" className="hover:text-slate-700 hover:underline">
Regulamin
</Link>
<Link to="/polityka-prywatnosci" className="hover:text-slate-700 hover:underline">
Polityka prywatności
</Link>
</p>
)}
{company?.name && (
<p className="text-center text-xs text-slate-400">
{company.name}
{company.address ? `, ${company.address}` : ''}
</p>
)}
</footer>
</div>
)
}
/** Komunikat zajmujacy cala strone, na przyklad brak miejsc albo webinar odwolany. */
export function PublicNotice({
icon,
title,
text,
tone = 'neutral',
children,
}: {
icon: IconName
title: string
text: string
tone?: 'neutral' | 'success' | 'warning'
children?: ReactNode
}) {
const tones = {
neutral: 'border-slate-100 bg-white',
success: 'border-ok-500/20 bg-ok-50',
warning: 'border-warn-500/25 bg-warn-50',
}
const iconTones = {
neutral: 'text-slate-300',
success: 'text-ok-500',
warning: 'text-warn-500',
}
const textTones = {
neutral: 'text-slate-600',
success: 'text-ok-600',
warning: 'text-warn-600',
}
return (
<section className={`rounded-lg border p-6 text-center shadow-sm ${tones[tone]}`}>
<span className="mx-auto flex justify-center">
<Icon name={icon} size={36} className={iconTones[tone]} />
</span>
<h2 className="mt-3 text-lg font-semibold text-slate-900">{title}</h2>
<p className={`mx-auto mt-1.5 max-w-md text-sm leading-relaxed ${textTones[tone]}`}>{text}</p>
{children && <div className="mt-5">{children}</div>}
</section>
)
}
@@ -0,0 +1,183 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { isNotFound, publicClient, publicErrorMessage } from '../../api/publicClient'
import type { PublicWebinar } from '../../api/types'
import { formatDateOnly, formatDuration, formatTimeOnly } from '../../lib/dates'
import { PUBLIC_CLOSED_MESSAGE } from '../../lib/labels'
import { useDocumentMeta } from '../../lib/useDocumentMeta'
import { PublicLayout, PublicNotice } from './PublicLayout'
import { RegistrationForm } from './RegistrationForm'
import { Icon } from '../../components/Icon'
export function PublicWebinarPage() {
const { slug } = useParams<{ slug: string }>()
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null)
const query = useQuery({
queryKey: ['public-webinar', slug],
retry: false,
queryFn: async () => {
const response = await publicClient.get<PublicWebinar>(`/public/webinars/${slug}`)
return response.data
},
})
const webinar = query.data
useDocumentMeta({
title: webinar ? `${webinar.title} - zapisy` : 'Zapisy na webinar',
description: webinar?.description ?? undefined,
})
if (query.isPending) {
return (
<PublicLayout wide>
<p className="flex items-center justify-center gap-2 py-16 text-sm text-slate-500">
<Icon name="loader" className="animate-spin" />
Wczytywanie...
</p>
</PublicLayout>
)
}
// slug nie istnieje albo webinar jest wersja robocza
if (query.isError && isNotFound(query.error)) {
return (
<PublicLayout wide>
<PublicNotice
icon="search"
title="Nie znaleziono webinaru"
text="Ten adres jest nieprawidłowy albo wydarzenie nie jest już dostępne. Sprawdź link z wiadomości."
/>
</PublicLayout>
)
}
if (query.isError || !webinar) {
return (
<PublicLayout wide>
<PublicNotice
icon="circle-alert"
title="Nie udało się wczytać strony"
text={publicErrorMessage(query.error, 'Spróbuj odświeżyć stronę za chwilę.')}
/>
</PublicLayout>
)
}
// ekran potwierdzenia. Nie ujawnia, czy adres byl juz zapisany.
if (registeredEmail) {
return (
<PublicLayout wide>
<PublicNotice
tone="success"
icon="mail-check"
title="Dziękujemy za zgłoszenie"
text={`Na adres ${registeredEmail} wysłaliśmy wiadomość z potwierdzeniem, linkiem do spotkania i zaproszeniem do kalendarza.`}
>
<div className="rounded-lg bg-white/70 p-4 text-left text-sm text-slate-600">
<p className="font-medium text-slate-900">{webinar.title}</p>
<p className="mt-1">
{formatDateOnly(webinar.startsAt, webinar.timezone)}, godz.{' '}
{formatTimeOnly(webinar.startsAt, webinar.timezone)} ({webinar.timezone})
</p>
<p className="mt-3 text-xs text-slate-500">
Jeśli wiadomość nie dotarła w ciągu kilku minut, sprawdź folder ze spamem.
</p>
</div>
</PublicNotice>
</PublicLayout>
)
}
const closed = !webinar.registrationOpen
const notice = webinar.closedReason ? PUBLIC_CLOSED_MESSAGE[webinar.closedReason] : null
return (
<PublicLayout wide>
<div className="space-y-5">
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl">
{webinar.title}
</h1>
<dl className="mt-4 space-y-2.5 text-sm">
<div className="flex items-start gap-2.5">
<Icon name="clock" size={20} className="text-slate-400" />
<div>
<dt className="sr-only">Termin</dt>
<dd className="font-medium text-slate-900">
{formatDateOnly(webinar.startsAt, webinar.timezone)}
</dd>
<dd className="text-slate-600">
godz. {formatTimeOnly(webinar.startsAt, webinar.timezone)}
{' - '}
{formatTimeOnly(webinar.endsAt, webinar.timezone)}{' '}
<span className="text-slate-400">(strefa {webinar.timezone})</span>
</dd>
</div>
</div>
<div className="flex items-start gap-2.5">
<Icon name="clock" size={20} className="text-slate-400" />
<div>
<dt className="sr-only">Czas trwania</dt>
<dd className="text-slate-600">
Czas trwania: {formatDuration(webinar.startsAt, webinar.endsAt)}
</dd>
</div>
</div>
{webinar.seatsLeft !== null && webinar.seatsLeft > 0 && (
<div className="flex items-start gap-2.5">
<Icon name="users" size={20} className="text-slate-400" />
<div>
<dt className="sr-only">Wolne miejsca</dt>
<dd className="text-slate-600">Wolnych miejsc: {webinar.seatsLeft}</dd>
</div>
</div>
)}
</dl>
{webinar.description && (
<p className="mt-4 whitespace-pre-line border-t border-slate-100 pt-4 text-sm leading-relaxed text-slate-700">
{webinar.description}
</p>
)}
</section>
{/*
Tresc przygotowana w edytorze wizualnym.
HTML jest **oczyszczony przy zapisie** na backendzie (biala lista jsoup),
wiec to, co tu trafia, przeszlo juz przez filtr. Nie czyscimy go drugi raz
w przegladarce, bo to tylko udawaloby dodatkowe zabezpieczenie: strona
publiczna dostaje dokladnie to, co jest w bazie.
Klasa `prose` daje naglowkom i listom sensowny wyglad bez wlasnego CSS.
`prose-img:mx-auto` wysrodkowuje obrazki, a `max-w-full` w po laczeniu
z `overflow-x-auto` na kontenerze pilnuje, zeby obrazek szerszy niz
ekran nie rozpychal strony na telefonie.
*/}
{webinar.contentHtml && (
<section className="overflow-x-auto rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<div
className="prose prose-slate max-w-none prose-headings:font-semibold prose-img:mx-auto prose-img:h-auto prose-img:max-w-full prose-img:rounded-md"
dangerouslySetInnerHTML={{ __html: webinar.contentHtml }}
/>
</section>
)}
{/* formularz jest ukryty w kazdym stanie zamknietym, nie tylko zablokowany */}
{closed && notice ? (
<PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} />
) : (
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<RegistrationForm webinar={webinar} onSuccess={setRegisteredEmail} />
</section>
)}
</div>
</PublicLayout>
)
}
@@ -0,0 +1,97 @@
import '@livekit/components-styles'
import { GridLayout, LiveKitRoom, ParticipantTile, RoomAudioRenderer, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
/**
* Strona, ktora nagrywa Egress.
*
* DLACZEGO WLASNY SZABLON, A NIE DOMYSLNY
* ------------------------------------------------------------------------
* Egress nagrywa **to, co widzi przegladarka**, wiec musi otworzyc jakas
* strone. Domyslnie pobiera ja z `https://template.livekit.io`, czyli
* z cudzego serwera w internecie. To oznacza dwie rzeczy, ktore w tym
* projekcie sa nie do przyjecia:
*
* 1. **Nagrywanie przestaje dzialac bez dostepu do internetu.** Instancja
* klienta stoi czesto w sieci zamknietej; Egress nie pobiera wtedy
* szablonu i nagranie konczy sie bledem "Start signal not received".
* 2. Wyglad nagrania zalezy od strony trzeciej, ktora moze sie zmienic.
*
* Wlasny szablon lezy w naszym froncie i jest osiagalny po sieci Dockera,
* wiec nagrywanie dziala tak samo w sieci zamknietej.
*
* SYGNAL STARTU I KONCA
* ------------------------------------------------------------------------
* Egress czeka na `window.START_RECORDING()`, zanim zacznie pisac plik, i na
* `window.END_RECORDING()`, zeby go domknac. **Bez wywolania pierwszej funkcji
* nagranie jest przerywane po kilkunastu sekundach** z bledem 412. Wolamy ja,
* gdy pokoj jest polaczony i sa w nim jakies sciezki: nagranie ma zaczynac sie
* od obrazu, a nie od pustej planszy.
*
* Ta strona nie jest dla ludzi: nie ma tu panelu interakcji, banerow ani
* paska akcji. Ma pokazywac obraz spotkania i nic wiecej.
*/
declare global {
interface Window {
START_RECORDING?: () => void
END_RECORDING?: () => void
}
}
export function RecordingTemplate() {
const [params] = useSearchParams()
const url = params.get('url') ?? ''
const token = params.get('token') ?? ''
if (!url || !token) {
return <div style={{ color: '#fff', padding: 24 }}>Brak parametrów nagrywania.</div>
}
return (
<div style={{ width: '100vw', height: '100vh', background: '#111' }} data-lk-theme="default">
<LiveKitRoom serverUrl={url} token={token} connect audio={false} video={false}>
<RecordingStage />
<RoomAudioRenderer />
</LiveKitRoom>
</div>
)
}
/**
* Uklad kafelkow plus sygnalizacja do Egress.
*
* Musi byc osobnym komponentem **wewnatrz** `LiveKitRoom`: `useTracks` czyta
* kontekst pokoju, ktorego poza nim nie ma.
*/
function RecordingStage() {
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: false },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: true, updateOnlyOn: [RoomEvent.ActiveSpeakersChanged] },
)
/*
* Sygnal startu wysylamy raz, gdy pojawi sie pierwsza sciezka. Wyslanie go
* od razu po polaczeniu dawaloby kilka sekund czarnego obrazu na poczatku
* kazdego nagrania.
*/
useEffect(() => {
if (tracks.length > 0 && window.START_RECORDING) {
window.START_RECORDING()
// wolamy tylko raz: powtorne wywolanie Egress ignoruje, ale nie ma
// powodu go zasypywac przy kazdej zmianie liczby sciezek
window.START_RECORDING = undefined
}
}, [tracks.length])
return (
<GridLayout tracks={tracks} style={{ height: '100vh' }}>
<ParticipantTile />
</GridLayout>
)
}
@@ -0,0 +1,311 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { useMutation } from '@tanstack/react-query'
import { publicClient, publicErrorMessage, publicFieldErrors } from '../../api/publicClient'
import type { ConsentText, ConsentType, PublicWebinar, RegistrationResult } from '../../api/types'
import { Icon } from '../../components/Icon'
type FormState = {
firstName: string
lastName: string
email: string
phone: string
company: string
}
const EMPTY: FormState = { firstName: '', lastName: '', email: '', phone: '', company: '' }
/**
* Adres przeniesiony z karty webinaru na stronie glownej.
*
* Karta ma tam jedno pole na adres, ale zapis wymaga takze imienia, nazwiska
* i zgody na regulamin: `first_name` i `last_name` maja NOT NULL, a zgoda musi
* byc zapisana z wersja tresci. Dlatego karta nie zapisuje, tylko przenosi
* tutaj to, co uzytkownik zdazyl wpisac, zeby nie musial pisac adresu drugi raz.
*
* Wartosc pochodzi z adresu strony, wiec jest **danymi od uzytkownika**:
* traktujemy ja jak tresc pola formularza, nie jak zaufane wejscie. Nie ma
* tu ryzyka przekierowania, bo nie buduje sie z niej zadnej sciezki.
*/
function prefilledEmail(): string {
try {
const value = new URLSearchParams(window.location.search).get('email')
return value && value.length <= 255 ? value.trim() : ''
} catch {
return ''
}
}
/** Prosty format adresu. Wlasciwa walidacje robi backend. */
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
/**
* Pola sa wysokie i maja 16px tekstu, zeby iOS nie powiekszal widoku
* przy wejsciu w pole, a palec trafial bez celowania.
*/
const INPUT_CLASS =
'w-full min-h-[48px] rounded-md border border-slate-200 bg-white px-4 text-base text-slate-900 placeholder:text-slate-400 transition duration-fast ease-standard hover:border-slate-200 focus:border-blue-500 focus:outline-none focus:ring-[3px] focus:ring-blue-500/45'
export function RegistrationForm({
webinar,
onSuccess,
}: {
webinar: PublicWebinar
onSuccess: (email: string) => void
}) {
const [form, setForm] = useState<FormState>(() => ({ ...EMPTY, email: prefilledEmail() }))
// zgody zawsze startuja odznaczone, takze ta wymagana
const [consents, setConsents] = useState<Partial<Record<ConsentType, boolean>>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [generalError, setGeneralError] = useState<string | null>(null)
const submit = useMutation({
mutationFn: async () => {
const response = await publicClient.post<RegistrationResult>(
`/public/webinars/${webinar.slug}/register`,
{
email: form.email.trim(),
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
phone: form.phone.trim() || null,
company: form.company.trim() || null,
consents,
/*
* Zaproszenie, z ktorego przyszedl uczestnik. Backend sprawdza, czy
* faktycznie dotyczy tego webinaru: identyfikator jest w adresie,
* wiec kazdy moze go podmienic.
*/
invitationId: invitationIdFromUrl(),
},
)
return response.data
},
onSuccess: () => onSuccess(form.email.trim()),
onError: (error) => {
// komunikaty backendu pokazujemy wiernie, bez podmieniania na wlasne
setErrors(publicFieldErrors(error))
setGeneralError(publicErrorMessage(error, 'Nie udało się zapisać. Spróbuj ponownie.'))
},
})
function validate(): Record<string, string> {
const found: Record<string, string> = {}
if (!form.firstName.trim()) found.firstName = 'Podaj imię'
if (!form.lastName.trim()) found.lastName = 'Podaj nazwisko'
if (!form.email.trim()) found.email = 'Podaj adres e-mail'
else if (!EMAIL_PATTERN.test(form.email.trim())) found.email = 'Niepoprawny adres e-mail'
const requiredConsent = webinar.consents.find((c) => c.required)
if (requiredConsent && !consents[requiredConsent.type]) {
found.consents = 'Akceptacja regulaminu jest wymagana'
}
return found
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setGeneralError(null)
const found = validate()
setErrors(found)
if (Object.keys(found).length > 0) return
submit.mutate()
}
function update<K extends keyof FormState>(key: K, value: string) {
setForm((current) => ({ ...current, [key]: value }))
}
const fields = [
{ id: 'firstName', label: 'Imię', required: true, autoComplete: 'given-name' },
{ id: 'lastName', label: 'Nazwisko', required: true, autoComplete: 'family-name' },
{ id: 'email', label: 'Adres e-mail', required: true, autoComplete: 'email', type: 'email', inputMode: 'email' as const },
{ id: 'phone', label: 'Telefon', required: false, autoComplete: 'tel', type: 'tel', inputMode: 'tel' as const },
{ id: 'company', label: 'Firma', required: false, autoComplete: 'organization' },
] as const
return (
<form onSubmit={handleSubmit} noValidate className="space-y-6">
<h2 className="text-lg font-semibold text-slate-900">Formularz zapisu</h2>
{generalError && (
<div
role="alert"
className="flex items-start gap-2.5 rounded-md border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600"
>
<Icon name="circle-alert" size={18} className="mt-px" />
<p className="min-w-0 flex-1">{generalError}</p>
</div>
)}
<div className="space-y-4">
{fields.map((field) => (
<div key={field.id}>
<label htmlFor={field.id} className="mb-1.5 block text-sm font-medium text-slate-700">
{field.label}{' '}
{field.required ? (
<span className="text-alarm-500">*</span>
) : (
<span className="font-normal text-slate-400">(opcjonalnie)</span>
)}
</label>
<input
id={field.id}
type={'type' in field ? field.type : 'text'}
inputMode={'inputMode' in field ? field.inputMode : undefined}
className={INPUT_CLASS}
value={form[field.id]}
onChange={(e) => update(field.id, e.target.value)}
autoComplete={field.autoComplete}
/>
{errors[field.id] && (
<p className="mt-1.5 text-sm text-alarm-600">{errors[field.id]}</p>
)}
{field.id === 'email' && !errors.email && (
<p className="mt-1.5 text-xs text-slate-500">
Na ten adres wyślemy potwierdzenie z linkiem do spotkania.
</p>
)}
</div>
))}
</div>
<fieldset className="space-y-3 border-t border-slate-100 pt-5">
<legend className="sr-only">Zgody</legend>
{/*
Przelacznik "master" nad lista zgod: jednym klikiem zaznacza albo
odznacza wszystkie ponizej. NIE jest zaznaczony domyslnie - zgoda
musi byc swiadomym dzialaniem uzytkownika, wiec to on go klika.
Stan liczy sie z dzieci: wszystkie zaznaczone -> zaznaczony,
czesc -> stan posredni (indeterminate), zadne -> pusty.
*/}
{(() => {
const total = webinar.consents.length
const selected = webinar.consents.filter((c) => consents[c.type] === true).length
const allChecked = total > 0 && selected === total
const someChecked = selected > 0 && selected < total
return (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-300 bg-slate-25 p-3.5 transition duration-fast ease-standard hover:bg-slate-50">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
// stan posredni: czesc zgod zaznaczona
if (el) el.indeterminate = someChecked
}}
onChange={(e) => {
const value = e.target.checked
setConsents(() => {
const next: Partial<Record<ConsentType, boolean>> = {}
for (const c of webinar.consents) next[c.type] = value
return next
})
}}
className="mt-0.5 h-5 w-5 shrink-0 rounded border-slate-300 text-blue-500 focus:ring-[3px] focus:ring-blue-500/45"
/>
<span className="min-w-0 text-sm font-semibold leading-relaxed text-slate-900">
Zaznacz wszystkie zgody
</span>
</label>
)
})()}
{/* Dzieci: wciete i polaczone pionowa linia, uklad drzewa */}
<div className="ml-3 space-y-3 border-l border-slate-200 pl-4">
{webinar.consents.map((consent) => (
<label
key={consent.type}
className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3.5 transition duration-fast ease-standard hover:border-slate-200 hover:bg-slate-25"
>
<input
type="checkbox"
// zgody nigdy nie sa zaznaczone domyslnie
checked={consents[consent.type] === true}
onChange={(e) =>
setConsents((current) => ({ ...current, [consent.type]: e.target.checked }))
}
className="mt-0.5 h-5 w-5 shrink-0 rounded border-slate-200 text-blue-500 focus:ring-[3px] focus:ring-blue-500/45"
/>
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
{consent.required && (
<span className="mr-1 font-semibold text-slate-900">Wymagane:</span>
)}
{renderConsentText(consent)}
{!consent.required && (
<span className="ml-1 text-xs text-slate-400">(opcjonalne)</span>
)}
</span>
</label>
))}
</div>
{errors.consents && <p className="text-sm text-alarm-600">{errors.consents}</p>}
</fieldset>
<button
type="submit"
disabled={submit.isPending}
className="flex min-h-[52px] w-full items-center justify-center gap-2 rounded-md bg-blue-500 px-4 text-base font-semibold text-white transition duration-fast ease-standard hover:bg-blue-600 active:translate-y-px focus:outline-none focus:ring-[3px] focus:ring-blue-500/45 disabled:bg-blue-200"
>
{submit.isPending && <Icon name="loader" size={18} className="animate-spin" />}
{submit.isPending ? 'Zapisywanie...' : 'Zapisz się na webinar'}
</button>
<p className="hx-data text-center text-2xs text-slate-400">
Wersja treści zgód: {webinar.consentVersion}
</p>
</form>
)
}
/**
* Identyfikator zaproszenia z adresu strony.
*
* Link w zaproszeniu ma postac /w/{slug}?zaproszenie={id}. Wartosc niepoprawna
* pomijamy po cichu: zapis ma sie udac niezaleznie od tego, czy da sie go
* powiazac ze zrodlem.
*/
/**
* Renderuje tresc zgody, zamieniajac wskazane slowo na klikalny odnosnik.
*
* Gdy zgoda ma `linkUrl` i `linkLabel` (np. slowo "regulamin" -> /regulamin),
* to jedno wystapienie tego slowa staje sie odnosnikiem otwieranym w nowej
* karcie. Reszta tresci zostaje zwyklym tekstem. Bez linku - caly tekst zwykly.
* `rel="noopener noreferrer"` odcina nowej karcie dostep do okna zapisu.
*/
function renderConsentText(consent: ConsentText): ReactNode {
const { text, linkUrl, linkLabel } = consent
if (!linkUrl || !linkLabel) {
return text
}
const index = text.toLowerCase().indexOf(linkLabel.toLowerCase())
if (index < 0) {
return text
}
const before = text.slice(0, index)
const match = text.slice(index, index + linkLabel.length)
const after = text.slice(index + linkLabel.length)
return (
<>
{before}
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
// klik w link nie moze przelaczac checkboxa zgody
onClick={(e) => e.stopPropagation()}
>
{match}
</a>
{after}
</>
)
}
function invitationIdFromUrl(): number | null {
const raw = new URLSearchParams(window.location.search).get('zaproszenie')
if (!raw) return null
const parsed = Number(raw)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from 'react'
import { Client, type IMessage } from '@stomp/stompjs'
import { publicClient, publicErrorMessage } from '../../api/publicClient'
import { Icon } from '../../components/Icon'
import { Button, Spinner } from '../../components/ui'
/**
* Zadania powstale podczas webinaru.
*
* Zadanie ogloszone przez prowadzacego pojawia sie tu bez odswiezania strony,
* a liczba wolnych miejsc zmienia sie u wszystkich, gdy ktos sie zapisze
* albo wycofa.
*/
export type TaskView = {
id: number
title: string
description: string | null
capacity: number | null
taken: number
open: boolean
full: boolean
signedUp: boolean
deadline: string | null
createdAt: string
}
export function TasksTab({ token, webinarId }: { token: string; webinarId: number }) {
const [tasks, setTasks] = useState<TaskView[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<number | null>(null)
const load = useCallback(async () => {
try {
const response = await publicClient.get<{ tasks: TaskView[] }>(
`/public/participation/${token}/tasks`)
setTasks(response.data.tasks)
} catch (e) {
setError(publicErrorMessage(e, 'Nie udało się wczytać zadań.'))
} finally {
setLoading(false)
}
}, [token])
useEffect(() => {
void load()
}, [load])
// zmiany od prowadzacego i od innych uczestnikow
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const stomp = new Client({
brokerURL: `${protocol}//${window.location.host}/ws`,
connectHeaders: { 'X-Join-Token': token },
reconnectDelay: 4000,
onConnect: () => {
stomp.subscribe(`/topic/webinars/${webinarId}/tasks`, (message: IMessage) => {
const event = JSON.parse(message.body) as { kind: string; payload: TaskView }
setTasks((current) => applyTaskEvent(current, event))
})
},
})
stomp.activate()
return () => {
void stomp.deactivate()
}
}, [token, webinarId])
async function toggle(task: TaskView) {
setBusy(task.id)
setError(null)
try {
const path = `/public/participation/${token}/tasks/${task.id}/signup`
const response = task.signedUp
? await publicClient.delete<TaskView>(path)
: await publicClient.post<TaskView>(path)
setTasks((current) =>
current.map((t) => (t.id === task.id ? response.data : t)))
} catch (e) {
setError(publicErrorMessage(e, 'Nie udało się zmienić zgłoszenia.'))
} finally {
setBusy(null)
}
}
if (loading) return <Spinner />
return (
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{error && (
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-2 text-xs text-alarm-600">
{error}
</p>
)}
{tasks.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
Prowadzący nie ogłosił jeszcze żadnych zadań.
</p>
)}
{tasks.map((task) => {
const left = task.capacity === null ? null : task.capacity - task.taken
const blocked = !task.open || (task.full && !task.signedUp)
return (
<div
key={task.id}
className={`rounded-md border p-3 ${
task.signedUp ? 'border-ok-500/30 bg-ok-50' : 'border-slate-200'
}`}
>
<p className="text-sm font-medium text-slate-900">{task.title}</p>
{task.description && (
<p className="mt-0.5 whitespace-pre-wrap text-sm text-slate-600">
{task.description}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="hx-data text-xs text-slate-500">
{task.capacity === null
? `${task.taken} zapisanych, bez limitu`
: `${task.taken} / ${task.capacity} miejsc`}
</span>
{left !== null && left > 0 && !task.signedUp && (
<span className="text-xs text-slate-400">wolne: {left}</span>
)}
{!task.open && <span className="text-xs text-warn-600">zapisy zamknięte</span>}
<Button
type="button"
size="sm"
variant={task.signedUp ? 'secondary' : 'primary'}
className="ml-auto"
disabled={busy === task.id || (blocked && !task.signedUp)}
onClick={() => toggle(task)}
>
{task.signedUp ? 'Wycofaj się' : task.full ? 'Brak miejsc' : 'Zgłaszam się'}
</Button>
</div>
{task.signedUp && (
<p className="mt-2 flex items-center gap-1.5 text-xs text-ok-600">
<Icon name="check-circle" size={14} />
Jesteś zapisany. Po webinarze dostaniesz mail z listą swoich zadań.
</p>
)}
</div>
)
})}
</div>
)
}
/** Nakladanie zdarzenia na liste. `signedUp` zostaje z poprzedniego stanu. */
function applyTaskEvent(
current: TaskView[],
event: { kind: string; payload: TaskView },
): TaskView[] {
if (event.kind === 'TASK_REMOVED') {
return current.filter((t) => t.id !== event.payload.id)
}
const existing = current.find((t) => t.id === event.payload.id)
// rozglaszana wersja jest wspolna dla wszystkich, wiec nie wie,
// czy TA przegladarka jest zapisana
const merged = { ...event.payload, signedUp: existing?.signedUp ?? false }
if (!existing) return [...current, merged]
return current.map((t) => (t.id === merged.id ? merged : t))
}
@@ -0,0 +1,136 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { publicClient, publicErrorMessage } from '../../api/publicClient'
import type { UnsubscribeResult } from '../../api/types'
import { Icon } from '../../components/Icon'
import { PublicLayout } from './PublicLayout'
/**
* Wypisanie z wiadomosci marketingowych.
*
* DWA KROKI, NIE JEDEN
* ------------------------------------------------------------------------
* Samo otwarcie linku niczego nie zmienia: skanery poczty i podglad linkow
* w kliencie odwiedzaja adresy z wiadomosci automatycznie, wiec wypisanie
* przy wejsciu na strone wypisywaloby ludzi, ktorzy niczego nie kliknęli.
* Zmiana nastepuje dopiero po nacisnieciu przycisku.
*
* Strona mowi wprost, czego wypisanie **nie** obejmuje: maile o webinarze,
* na ktory ktos sam sie zapisal, ida dalej, bo to obsluga zamowionej uslugi,
* a nie marketing.
*/
export function UnsubscribePage() {
const { token } = useParams<{ token: string }>()
const [done, setDone] = useState<UnsubscribeResult | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const query = useQuery({
queryKey: ['unsubscribe', token],
queryFn: async () => {
const response = await publicClient.get<UnsubscribeResult>(`/public/unsubscribe/${token}`)
return response.data
},
})
async function confirm() {
setPending(true)
setError(null)
try {
const response = await publicClient.post<UnsubscribeResult>(`/public/unsubscribe/${token}`)
setDone(response.data)
} catch (e) {
setError(publicErrorMessage(e, 'Nie udało się zapisać rezygnacji. Spróbuj ponownie.'))
} finally {
setPending(false)
}
}
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>
)
}
if (query.isError) {
return (
<PublicLayout>
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
<Icon name="circle-alert" size={32} className="mx-auto text-warn-600" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
Nie znaleźliśmy takiego zapisu
</h1>
<p className="mt-1 text-sm text-slate-600">
Link mógł wygasnąć albo został już wykorzystany. Jeśli nadal dostajesz wiadomości,
odpowiedz na dowolną z nich.
</p>
</section>
</PublicLayout>
)
}
const state = done ?? query.data
const finished = done !== null || query.data.alreadyDone
return (
<PublicLayout>
<section className="rounded-xl border border-slate-200 bg-white p-6">
{finished ? (
<div className="text-center">
<Icon name="check-circle" size={32} className="mx-auto text-ok-500" />
<h1 className="mt-3 text-lg font-semibold text-slate-900">
{done?.alreadyDone || query.data.alreadyDone
? 'Już nie wysyłamy Ci wiadomości'
: 'Rezygnacja została zapisana'}
</h1>
<p className="mt-1 text-sm text-slate-600">
Adres <span className="hx-data font-medium">{state.email}</span> nie będzie już
dostawał zaproszeń ani informacji handlowych.
</p>
</div>
) : (
<div>
<h1 className="text-lg font-semibold text-slate-900">
Rezygnacja z wiadomości handlowych
</h1>
<p className="mt-2 text-sm text-slate-600">
Po potwierdzeniu adres{' '}
<span className="hx-data font-medium">{state.email}</span> przestanie dostawać
zaproszenia na webinary i inne informacje handlowe.
</p>
{error && (
<p className="mt-3 rounded-md bg-alarm-50 p-3 text-sm text-alarm-600">{error}</p>
)}
<button
type="button"
onClick={confirm}
disabled={pending}
className="mt-5 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 px-4 py-3.5 text-base font-semibold text-white transition hover:bg-blue-600 disabled:opacity-60"
>
<Icon name="user-off" size={20} />
{pending ? 'Zapisywanie...' : 'Potwierdzam rezygnację'}
</button>
</div>
)}
{/*
Wazne rozroznienie: wypisanie dotyczy marketingu, a nie obslugi
webinaru, na ktory ktos sam sie zapisal. Bez tego zdania uczestnik
moglby sadzic, ze straci potwierdzenie i przypomnienie.
*/}
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
Nadal będziesz dostawać wiadomości dotyczące webinarów, na które sam się zapisałeś:
potwierdzenie zapisu, przypomnienie o terminie i podsumowanie po spotkaniu. To obsługa
zamówionej usługi, a nie informacje handlowe.
</p>
</section>
</PublicLayout>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useRef } from 'react'
import type ZoomMtgEmbedded from '@zoom/meetingsdk/embedded'
/** Typ klienta osadzonego, zwracany przez createClient(). */
type EmbeddedZoomClient = ReturnType<typeof ZoomMtgEmbedded.createClient>
/**
* Pokoj Zoom osadzony w oknie webinaru (Meeting SDK Web, component view).
*
* GOTOWY KOMPONENT, NIE WLASNY INTERFEJS
* ------------------------------------------------------------------------
* Tak jak przy LiveKit i Jitsi, obraz i sterowanie daje gotowy komponent
* dostawcy. Nasze zostaje to, czego Zoom nie ma: czat, pytania, zadania,
* zapytania ofertowe, banery i pasek akcji.
*
* BEZ SharedArrayBuffer
* ------------------------------------------------------------------------
* Osadzamy BEZ naglowkow izolacji COOP/COEP. Meeting SDK dziala wtedy
* poprawnie, traci tylko widok galerii i wirtualne tlo, ktore na webinarze
* z widocznym glownie prelegentem nie sa potrzebne. Wlaczenie ich w przyszlosci
* to dodanie naglowkow wylacznie na trasie spotkania.
*
* UPRAWNIENIA
* ------------------------------------------------------------------------
* Zoom NIE egzekwuje audio/wideo per uczestnik z podpisu: rola to tylko host
* albo uczestnik. Wyciszenie i wylaczenie obrazu ustawia backend na poziomie
* calego spotkania przy jego zakladaniu. To slabsza gwarancja niz LiveKit,
* blizsza Jitsi. Dlatego props `canPublish*` sluza tu wylacznie do wygody
* poczatkowego stanu, nie sa bariera.
*/
export function ZoomRoom({
meetingNumber,
signature,
sdkKey,
displayName,
isHost,
}: {
meetingNumber: string
signature: string
sdkKey: string
displayName: string
isHost: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
// klient Zoom jest tworzony raz i sprzatany przy odmontowaniu.
// Typ pochodzi z SDK, a nie z wlasnej atrapy.
const clientRef = useRef<EmbeddedZoomClient | null>(null)
useEffect(() => {
let cancelled = false
const container = containerRef.current
if (!container) {
return
}
async function start() {
// Meeting SDK laduje sie dynamicznie, zeby nie wchodzil do glownego
// pakietu: potrzebny jest wylacznie na sali.
const mod = await import('@zoom/meetingsdk/embedded')
const ZoomMtgEmbedded = mod.default
if (cancelled || !container) {
return
}
const client = ZoomMtgEmbedded.createClient()
clientRef.current = client
await client.init({
zoomAppRoot: container,
language: 'pl-PL',
patchJsMedia: true,
})
await client.join({
sdkKey,
signature,
meetingNumber,
userName: displayName,
// rola egzekwowana przez podpis po stronie serwera; tutaj tylko
// przekazujemy to, co jest juz w podpisie
password: '',
})
}
void start().catch((error) => {
// blad wejscia nie moze wywrocic calego ekranu: panel interakcji
// (czat, pytania) ma dzialac nawet gdy obraz sie nie zestawil
console.error('Nie udalo sie dolaczyc do spotkania Zoom', error)
})
return () => {
cancelled = true
const client = clientRef.current
if (client) {
try {
void client.leaveMeeting()
} catch {
// klient mogl nie dojsc do stanu polaczonego; sprzatanie i tak konczymy
}
clientRef.current = null
}
}
}, [meetingNumber, signature, sdkKey, displayName, isHost])
return <div ref={containerRef} className="h-full w-full" />
}
@@ -0,0 +1,21 @@
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
const KEYS = ['APP_BRAND_NAME', 'APP_PUBLIC_URL', 'APP_TIMEZONE']
export function AppSettingsPage() {
return (
<SettingsPane>
{({ settings, onSave, saving }) => (
<SettingsSection
title="Aplikacja"
description="Nazwa marki i adres publiczny używane w mailach oraz linkach"
settings={settings}
keys={KEYS}
onSave={onSave}
saving={saving}
/>
)}
</SettingsPane>
)
}
@@ -0,0 +1,164 @@
import { useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { Icon } from '../../components/Icon'
import { ImageEditor } from '../../components/ImageEditor'
import { useToasts } from '../../components/Toasts'
import { Button, Card } from '../../components/ui'
import { PUBLIC_CONFIG_KEY, usePublicConfig } from '../../lib/usePublicConfig'
type Kind = 'LOGO' | 'FAVICON'
const KINDS: Record<
Kind,
{ title: string; hint: string; aspect: number; output: number; preview: string; transparent: boolean }
> = {
LOGO: {
title: 'Logo instancji',
hint:
'Pokazywane w nagłówku panelu i na stronie zapisu. Ramkę kadru ustawiasz dowolnie, '
+ 'także w pionie, a wynik zachowuje jej kształt. Puste miejsca zostają przezroczyste.',
// punkt wyjscia: 3:1 miesci znak kwadratowy i typowy poziomy lockup z nazwa,
// ale uzytkownik moze zmienic proporcje ramki
aspect: 3,
output: 480,
preview: 'h-10 w-24',
transparent: true,
},
FAVICON: {
title: 'Ikona karty przeglądarki',
hint:
'Mała ikona widoczna na karcie przeglądarki. Kadr startuje jako kwadrat, bo tak ikona '
+ 'wygląda najlepiej, ale można go zmienić. Drobne detale bywają nieczytelne.',
aspect: 1,
output: 64,
preview: 'h-8 w-8',
transparent: true,
},
}
export function BrandingSection() {
const config = usePublicConfig()
const queryClient = useQueryClient()
const toasts = useToasts()
const [editing, setEditing] = useState<{ kind: Kind; file: File } | null>(null)
const inputs = {
LOGO: useRef<HTMLInputElement>(null),
FAVICON: useRef<HTMLInputElement>(null),
}
const upload = useMutation({
mutationFn: async ({ kind, dataUrl }: { kind: Kind; dataUrl: string }) => {
await client.post(`/admin/branding/${kind}`, { dataUrl })
},
onSuccess: () => {
toasts.success('Grafika została zapisana')
queryClient.invalidateQueries({ queryKey: PUBLIC_CONFIG_KEY })
queryClient.invalidateQueries({ queryKey: ['settings'] })
setEditing(null)
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać grafiki')),
})
const remove = useMutation({
mutationFn: (kind: Kind) => client.delete(`/admin/branding/${kind}`),
onSuccess: () => {
toasts.success('Przywrócono grafikę domyślną')
queryClient.invalidateQueries({ queryKey: PUBLIC_CONFIG_KEY })
queryClient.invalidateQueries({ queryKey: ['settings'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć grafiki')),
})
function currentUrl(kind: Kind): string {
return kind === 'LOGO' ? config.logoUrl : config.faviconUrl
}
return (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Grafika instancji</h2>
<p className="text-sm text-slate-500">
Logo i ikona są widoczne dla uczestników, więc nie są traktowane jako dane poufne.
</p>
</div>
{(Object.keys(KINDS) as Kind[]).map((kind) => {
const spec = KINDS[kind]
const url = currentUrl(kind)
const isEditing = editing?.kind === kind
return (
<Card key={kind} title={spec.title} description={spec.hint}>
{isEditing ? (
<ImageEditor
file={editing.file}
aspect={spec.aspect}
outputWidth={spec.output}
transparent={spec.transparent}
onCancel={() => setEditing(null)}
onConfirm={(dataUrl) => upload.mutate({ kind, dataUrl })}
/>
) : (
<div className="flex flex-wrap items-center gap-4">
<div
className={`${spec.preview} flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-slate-200 bg-slate-25`}
>
{url ? (
<img src={url} alt={spec.title} className="h-full w-full object-contain" />
) : (
<Icon name="droplet" size={18} className="text-slate-300" />
)}
</div>
<p className="min-w-0 flex-1 text-sm text-slate-500">
{url ? 'Ustawiona grafika własna.' : 'Używany jest znak domyślny.'}
</p>
<input
ref={inputs[kind]}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) setEditing({ kind, file })
// czyscimy, zeby wybor tego samego pliku ponownie wyzwolil zdarzenie
e.target.value = ''
}}
/>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
icon="upload"
size="sm"
onClick={() => inputs[kind].current?.click()}
>
{url ? 'Zmień' : 'Wgraj'}
</Button>
{url && (
<Button
type="button"
variant="ghost"
icon="trash"
size="sm"
onClick={() => {
if (window.confirm('Przywrócić grafikę domyślną?')) remove.mutate(kind)
}}
className="text-alarm-600 hover:bg-alarm-50"
>
Usuń
</Button>
)}
</div>
</div>
)}
</Card>
)
})}
</section>
)
}
@@ -0,0 +1,5 @@
import { BrandingSection } from './BrandingSection'
export function BrandingSettingsPage() {
return <BrandingSection />
}
@@ -0,0 +1,46 @@
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
/**
* Dane firmy / administratora danych osobowych (RODO).
*
* RODO (art. 13) wymaga, zeby osoba zapisujaca sie na webinar dostala tozsamosc
* administratora danych: pelna nazwe, adres i kontakt. Te dane trafiaja do
* stopki, na strone zapisu i do obowiazku informacyjnego w polityce prywatnosci.
* Kazde pole osobno, zamiast jednego wolnego akapitu, zeby latwiej bylo je
* zlozyc w klauzule informacyjna i zeby nie zostawic wymaganego elementu.
*/
const KEYS = [
'COMPANY_NAME',
'COMPANY_ADDRESS',
'COMPANY_TAX_ID',
'COMPANY_REGISTRY',
'COMPANY_EMAIL',
'COMPANY_PHONE',
'DPO_CONTACT',
]
export function CompanySettingsPage() {
return (
<SettingsPane>
{({ settings, onSave, saving }) => (
<SettingsSection
title="Dane firmy / RODO"
description="Dane administratora danych osobowych. Prawo (RODO) wymaga podania ich osobie, która zapisuje się na webinar. Pojawiają się w stopce, na stronie zapisu i w polityce prywatności."
settings={settings}
keys={KEYS}
onSave={onSave}
saving={saving}
intro={
<div className="rounded-md border border-info-500/30 bg-info-50 p-3 text-sm text-info-600">
Uzupełnij te dane przed opublikowaniem pierwszego webinaru. Bez nich strona zapisu
nie spełnia obowiązku informacyjnego RODO. Pole inspektora ochrony danych wypełnij
tylko, jeśli firma go powołała.
</div>
}
/>
)}
</SettingsPane>
)
}
@@ -0,0 +1,33 @@
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
const KEYS = [
'CONSENT_VERSION',
'CONSENT_TERMS_TEXT',
'CONSENT_MARKETING_EMAIL_TEXT',
'CONSENT_MARKETING_SMS_TEXT',
]
const MULTILINE = [
'CONSENT_TERMS_TEXT',
'CONSENT_MARKETING_EMAIL_TEXT',
'CONSENT_MARKETING_SMS_TEXT',
]
export function ConsentSettingsPage() {
return (
<SettingsPane>
{({ settings, onSave, saving }) => (
<SettingsSection
title="Treści zgód"
description="Po każdej zmianie treści podnieś wersję. Wersja jest utrwalana przy każdej zgodzie, więc stare zapisy zachowują treść, na którą uczestnik faktycznie się zgodził."
settings={settings}
keys={KEYS}
multiline={MULTILINE}
onSave={onSave}
saving={saving}
/>
)}
</SettingsPane>
)
}
@@ -0,0 +1,361 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { PageSection, PageSectionType } from '../../api/types'
import { Icon } from '../../components/Icon'
import { MediaField } from '../../components/MediaField'
import { Modal } from '../../components/Modal'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
IconButton,
Input,
Spinner,
Textarea,
} from '../../components/ui'
import { useToasts } from '../../components/Toasts'
/**
* Tresc sekcji statycznych strony glownej Akademii.
*
* Prowadzacy, opinie i pytania FAQ sa POWTARZALNYMI REKORDAMI, wiec nie leza
* w app_settings, tylko w tabeli `page_sections`. Dzieki temu administrator
* dodaje i usuwa pozycje przyciskiem, zamiast wpisywac wlasny format
* z separatorami, a liczba pozycji nie jest z gory ograniczona.
*
* Naglowki tych sekcji sa pojedynczymi napisami, wiec zostaja w ustawieniach
* aplikacji, razem z reszta tresci strony glownej.
*/
const SECTIONS: {
id: PageSectionType
label: string
description: string
titleLabel: string
subtitleLabel: string | null
bodyLabel: string
withMedia: boolean
}[] = [
{
id: 'EXPERT',
label: 'Prowadzący',
description: 'Osoby prowadzące szkolenia, pokazywane na stronie głównej Akademii.',
titleLabel: 'Imię i nazwisko',
subtitleLabel: 'Specjalność',
bodyLabel: 'Krótki opis',
withMedia: true,
},
{
id: 'REVIEW',
label: 'Opinie',
description: 'Opinie kursantów. Publikuj wyłącznie te, na które masz zgodę autora.',
titleLabel: 'Autor opinii',
subtitleLabel: 'Stanowisko i firma',
bodyLabel: 'Treść opinii',
withMedia: false,
},
{
id: 'FAQ',
label: 'Najczęstsze pytania',
description: 'Pytania i odpowiedzi w rozwijanej liście na dole strony głównej.',
titleLabel: 'Pytanie',
subtitleLabel: null,
bodyLabel: 'Odpowiedź',
withMedia: false,
},
]
export function HomePageSettingsPage() {
const [active, setActive] = useState<PageSectionType>('EXPERT')
const section = SECTIONS.find((s) => s.id === active)!
return (
<div className="space-y-4">
<Card
title="Strona główna Akademii"
description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia."
>
{/* Pasek przewija sie w poziomie, tak samo jak w reszcie ustawien */}
<nav className="-mx-1 mb-5 flex gap-1 overflow-x-auto px-1 pb-1">
{SECTIONS.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setActive(s.id)}
aria-current={active === s.id}
className={`min-h-11 shrink-0 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
active === s.id
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
{s.label}
</button>
))}
</nav>
<p className="mb-4 text-sm text-slate-600">{section.description}</p>
<SectionEditor section={section} />
</Card>
</div>
)
}
function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [editing, setEditing] = useState<PageSection | 'new' | null>(null)
const key = ['page-sections', section.id]
const list = useQuery({
queryKey: key,
queryFn: async () =>
(await client.get<PageSection[]>(`/admin/page-sections/${section.id}`)).data,
})
const remove = useMutation({
mutationFn: async (id: number) => {
await client.delete(`/admin/page-sections/${section.id}/${id}`)
},
onSuccess: () => {
toasts.success('Pozycja usunięta')
queryClient.invalidateQueries({ queryKey: key })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć pozycji')),
})
/**
* Zmiana kolejnosci przez przekazanie CALEJ listy identyfikatorow, tak samo
* jak przy modulach kursu. Przyciski gora i dol, a nie przeciaganie: lista
* jest krotka, a przyciski dzialaja takze z klawiatury i na ekranie
* dotykowym.
*/
const reorder = useMutation({
mutationFn: async (ids: number[]) => {
await client.put(`/admin/page-sections/${section.id}/order`, { ids })
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: key }),
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić kolejności')),
})
const move = (index: number, direction: -1 | 1) => {
const items = list.data ?? []
const target = index + direction
if (target < 0 || target >= items.length) return
const ids = items.map((i) => i.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
reorder.mutate(ids)
}
if (list.isPending) return <Spinner />
if (list.isError) {
return <ErrorState message={errorMessage(list.error, 'Nie udało się wczytać pozycji')} />
}
const items = list.data ?? []
return (
<>
<div className="mb-4 flex justify-end">
<Button icon="plus" onClick={() => setEditing('new')}>
Dodaj pozycję
</Button>
</div>
{items.length === 0 ? (
<EmptyState
icon="file-text"
title="Brak pozycji"
description="Ta sekcja nie pokaże się na stronie głównej, dopóki nie dodasz pierwszej pozycji."
/>
) : (
<ul className="space-y-2">
{items.map((item, index) => (
<li
key={item.id}
className="flex flex-col gap-3 rounded-md border border-slate-100 p-3 sm:flex-row sm:items-center"
>
<div className="flex shrink-0 gap-1">
<IconButton
icon="chevron-up"
label={`Przesuń "${item.title}" w górę`}
onClick={() => move(index, -1)}
disabled={index === 0 || reorder.isPending}
/>
<IconButton
icon="chevron-down"
label={`Przesuń "${item.title}" w dół`}
onClick={() => move(index, 1)}
disabled={index === items.length - 1 || reorder.isPending}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-slate-900">{item.title}</p>
{!item.enabled && <Badge tone="neutral">Ukryta</Badge>}
</div>
{item.subtitle && <p className="text-xs text-slate-500">{item.subtitle}</p>}
{item.body && (
<p className="mt-1 line-clamp-2 text-xs text-slate-600">{item.body}</p>
)}
</div>
<div className="flex shrink-0 gap-1">
<IconButton
icon="pencil"
label={`Edytuj "${item.title}"`}
onClick={() => setEditing(item)}
/>
<IconButton
icon="trash"
label={`Usuń "${item.title}"`}
variant="danger"
onClick={() => {
// pytanie nazywa konkretny obiekt, a nie "czy na pewno?"
if (window.confirm(`Usunąć pozycję "${item.title}"? Tej operacji nie można cofnąć.`)) {
remove.mutate(item.id)
}
}}
/>
</div>
</li>
))}
</ul>
)}
{editing && (
<SectionForm
section={section}
item={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
queryClient.invalidateQueries({ queryKey: key })
}}
/>
)}
</>
)
}
function SectionForm({
section,
item,
onClose,
onSaved,
}: {
section: (typeof SECTIONS)[number]
item: PageSection | null
onClose: () => void
onSaved: () => void
}) {
const toasts = useToasts()
const [form, setForm] = useState({
title: item?.title ?? '',
subtitle: item?.subtitle ?? '',
body: item?.body ?? '',
mediaId: item?.mediaId ?? null,
enabled: item?.enabled ?? true,
})
const save = useMutation({
mutationFn: async () => {
const payload = {
title: form.title,
subtitle: form.subtitle || null,
body: form.body || null,
mediaId: form.mediaId,
enabled: form.enabled,
}
if (item) {
await client.put(`/admin/page-sections/${section.id}/${item.id}`, payload)
} else {
await client.post(`/admin/page-sections/${section.id}`, payload)
}
},
onSuccess: () => {
toasts.success(item ? 'Pozycja zapisana' : 'Pozycja dodana')
onSaved()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać pozycji')),
})
return (
<Modal
title={item ? 'Edycja pozycji' : `Nowa pozycja: ${section.label}`}
onClose={onClose}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Anuluj
</Button>
<Button
icon="save"
onClick={() => save.mutate()}
disabled={save.isPending || !form.title.trim()}
>
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</>
}
>
<div className="space-y-4">
<Field label={section.titleLabel}>
<Input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
autoFocus
/>
</Field>
{section.subtitleLabel && (
<Field label={section.subtitleLabel}>
<Input
value={form.subtitle}
onChange={(e) => setForm({ ...form, subtitle: e.target.value })}
/>
</Field>
)}
<Field label={section.bodyLabel} wide>
<Textarea
rows={4}
value={form.body}
onChange={(e) => setForm({ ...form, body: e.target.value })}
/>
</Field>
{section.withMedia && (
<Field label="Zdjęcie" hint="Wybierz plik z biblioteki. Bez zdjęcia pokażemy inicjały.">
{/* MediaField operuje tekstem: pusty ciag oznacza brak wyboru,
a do API idzie liczba albo null. */}
<MediaField
value={form.mediaId == null ? '' : String(form.mediaId)}
onChange={(value) =>
setForm({ ...form, mediaId: value.trim() ? Number(value) : null })
}
/>
</Field>
)}
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm text-slate-700">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-3 focus:ring-blue-500/45"
/>
<span className="flex items-center gap-1.5">
<Icon name="eye" size={16} className="text-slate-400" />
Pokaż na stronie głównej
</span>
</label>
</div>
</Modal>
)
}
@@ -0,0 +1,49 @@
import { SafeModeNotice } from './SafeModeNotice'
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
import { TestBlock } from './TestBlock'
const KEYS = [
'MAIL_CONTACT_RECIPIENT',
'MAIL_RATE_LIMIT_PER_HOUR',
'MAIL_SAFE_MODE',
'MAIL_SAFE_RECIPIENT',
'MAIL_FROM_NAME',
'MAIL_HOST',
'MAIL_PASSWORD',
'MAIL_PORT',
'MAIL_SSL_ENABLED',
'MAIL_USERNAME',
]
export function MailSettingsPage() {
return (
<SettingsPane>
{({ settings, onSave, saving }) => (
<SettingsSection
title="Konto e-mail (SMTP)"
description="Konto, z którego wychodzą potwierdzenia zapisu i przypomnienia"
settings={settings}
keys={KEYS}
onSave={onSave}
saving={saving}
intro={
<SafeModeNotice
settings={settings}
modeKey="MAIL_SAFE_MODE"
recipientKey="MAIL_SAFE_RECIPIENT"
channel="poczty"
/>
}
>
<TestBlock
title="Test konfiguracji"
label="Testowy e-mail na adres"
placeholder="adres@example.com"
endpoint="/admin/settings/test-email"
/>
</SettingsSection>
)}
</SettingsPane>
)
}
@@ -0,0 +1,287 @@
import { useEffect, useState } from 'react'
import type { Setting } from '../../api/types'
import type { NotificationChannel } from '../../api/types'
import { Icon } from '../../components/Icon'
import { Badge, Button, Card, IconButton, Input, Select } from '../../components/ui'
import {
describeChannel,
describeOffset,
formatDefaultReminders,
parseDefaultReminders,
sortReminders,
REMINDER_PRESETS,
type DefaultReminder,
} from '../../lib/reminders'
import { SettingsPane } from './SettingsPane'
/**
* Domyslny harmonogram przypomnien.
*
* Wczesniej byl to jedno pole tekstowe w formacie EMAIL:1440:reminder, wiec
* dodanie przypomnienia wymagalo znajomosci skladni i przeliczenia dob na
* minuty. Teraz kazde przypomnienie jest osobnym wierszem, a odstep wybiera
* sie z listy albo podaje w wybranej jednostce. Zapis nadal idzie w starym
* formacie, bo to kontrakt backendu.
*/
const TOLERANCE_KEY = 'REMINDER_TOLERANCE_MINUTES'
const REMINDERS_KEY = 'DEFAULT_REMINDERS'
export function ReminderSettingsPage() {
return (
<SettingsPane>
{({ settings, onSave, saving }) => (
<ReminderForm settings={settings} onSave={onSave} saving={saving} />
)}
</SettingsPane>
)
}
function ReminderForm({
settings,
onSave,
saving,
}: {
settings: Setting[]
onSave: (values: Record<string, string>) => void
saving: boolean
}) {
const savedReminders = settings.find((s) => s.key === REMINDERS_KEY)?.value ?? ''
const savedTolerance = settings.find((s) => s.key === TOLERANCE_KEY)?.value ?? '30'
const [reminders, setReminders] = useState<DefaultReminder[]>([])
const [tolerance, setTolerance] = useState('30')
// po wczytaniu ustawien odtwarzamy stan z zapisanej wartosci
useEffect(() => {
setReminders(sortReminders(parseDefaultReminders(savedReminders)))
setTolerance(savedTolerance)
}, [savedReminders, savedTolerance])
// formularz dodawania
const [newMinutes, setNewMinutes] = useState('1440')
const [newChannel, setNewChannel] = useState<NotificationChannel>('EMAIL')
const [customValue, setCustomValue] = useState('')
const [customUnit, setCustomUnit] = useState<'minutes' | 'hours' | 'days'>('hours')
const usingCustom = newMinutes === 'custom'
const customMinutes = toMinutes(customValue, customUnit)
const minutesToAdd = usingCustom ? customMinutes : Number(newMinutes)
const duplicate = reminders.some(
(r) => r.minutes === minutesToAdd && r.channel === newChannel,
)
const canAdd = minutesToAdd > 0 && !duplicate
function addReminder() {
if (!canAdd) return
setReminders((current) =>
sortReminders([
...current,
// szablon jest dzisiaj jeden, wiec nie zawracamy nim glowy uzytkownikowi
{ channel: newChannel, minutes: minutesToAdd, template: 'reminder' },
]),
)
setCustomValue('')
}
function removeReminder(index: number) {
setReminders((current) => current.filter((_, i) => i !== index))
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
onSave({
[REMINDERS_KEY]: formatDefaultReminders(reminders),
[TOLERANCE_KEY]: tolerance,
})
}
const hasSms = reminders.some((r) => r.channel === 'SMS')
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Przypomnienia</h2>
<p className="text-sm text-slate-500">
Ten zestaw jest zakładany każdemu nowemu webinarowi. Harmonogram już istniejącego
zmienia się w jego formularzu, więc zmiana tutaj nie rusza zaplanowanych wydarzeń.
</p>
</div>
<Card
title="Domyślny zestaw przypomnień"
description="Uczestnik dostanie każde z poniższych przypomnień przed startem webinaru."
>
{reminders.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 p-6 text-center">
<p className="text-sm text-slate-500">
Brak przypomnień. Nowe webinary nie będą ich wysyłać,
dopóki nie dodasz ich ręcznie w formularzu webinaru.
</p>
</div>
) : (
<ul className="space-y-2">
{reminders.map((reminder, index) => (
<li
key={`${reminder.channel}-${reminder.minutes}`}
className="flex items-center gap-3 rounded-md border border-slate-200 bg-white p-3"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-blue-50">
<Icon
name={reminder.channel === 'SMS' ? 'send' : 'mail'}
size={16}
className="text-blue-500"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-slate-900">
{describeOffset(reminder.minutes)}
</span>
<span className="block text-xs text-slate-500">
Wysyłka przez {describeChannel(reminder.channel)}
</span>
</span>
<Badge tone={reminder.channel === 'SMS' ? 'info' : 'brand'}>
{describeChannel(reminder.channel)}
</Badge>
<IconButton
icon="trash"
label={`Usuń przypomnienie ${describeOffset(reminder.minutes)}`}
variant="ghost"
type="button"
onClick={() => removeReminder(index)}
className="text-alarm-600 hover:bg-alarm-50"
/>
</li>
))}
</ul>
)}
<div className="mt-4 space-y-3 rounded-md border border-slate-200 bg-slate-25 p-4">
<p className="text-sm font-medium text-slate-900">Dodaj przypomnienie</p>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">Kiedy wysłać</span>
<Select value={newMinutes} onChange={(e) => setNewMinutes(e.target.value)}>
{REMINDER_PRESETS.map((preset) => (
<option key={preset.minutes} value={preset.minutes}>
{preset.label}
</option>
))}
<option value="custom">Inny odstęp...</option>
</Select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">Kanał</span>
<Select
value={newChannel}
onChange={(e) => setNewChannel(e.target.value as NotificationChannel)}
>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</Select>
</label>
</div>
{usingCustom && (
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">
Ile przed startem
</span>
<Input
type="number"
min={1}
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder="np. 2"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">Jednostka</span>
<Select
value={customUnit}
onChange={(e) => setCustomUnit(e.target.value as typeof customUnit)}
>
<option value="minutes">minut</option>
<option value="hours">godzin</option>
<option value="days">dni</option>
</Select>
</label>
</div>
)}
{/* podpowiedz skutku, zeby nie trzeba bylo zgadywac, co doda przycisk */}
<p className="text-xs text-slate-500">
{minutesToAdd > 0
? `Zostanie dodane przypomnienie ${describeOffset(minutesToAdd)}, wysyłane przez ${describeChannel(newChannel)}.`
: 'Podaj, ile czasu przed startem ma pójść przypomnienie.'}
</p>
{duplicate && (
<p className="text-xs text-warn-600">
Takie przypomnienie jest już na liście.
</p>
)}
<Button type="button" icon="plus" variant="secondary" onClick={addReminder} disabled={!canAdd}>
Dodaj do zestawu
</Button>
</div>
{hasSms && (
<p className="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 text-xs leading-relaxed text-blue-900">
SMS trafi wyłącznie do uczestników, którzy podali numer telefonu i wyrazili zgodę
marketingową na SMS. Pozostali dostaną tylko przypomnienia e-mail.
</p>
)}
</Card>
<Card
title="Spóźnione przypomnienia"
description="Zabezpieczenie na wypadek przerwy w działaniu aplikacji."
>
<label className="block max-w-xs">
<span className="mb-1 block text-xs font-semibold text-slate-700">
Nie wysyłaj, jeśli spóźnienie przekracza
</span>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={tolerance}
onChange={(e) => setTolerance(e.target.value)}
/>
<span className="shrink-0 text-sm text-slate-600">minut</span>
</div>
</label>
<p className="mt-3 text-xs leading-relaxed text-slate-500">
Jeśli aplikacja była wyłączona w momencie, na który zaplanowano przypomnienie,
po uruchomieniu nadrobi zaległości. Przypomnienie spóźnione bardziej niż podana
liczba minut nie idzie w ogóle, żeby uczestnik nie dostał wiadomości o wydarzeniu,
które już się zaczęło.
</p>
</Card>
<Button type="submit" icon="save" disabled={saving}>
{saving ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</Button>
</form>
)
}
/** Przelicza wartosc z wybranej jednostki na minuty. */
function toMinutes(value: string, unit: 'minutes' | 'hours' | 'days'): number {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) return 0
if (unit === 'days') return Math.round(number * 1440)
if (unit === 'hours') return Math.round(number * 60)
return Math.round(number)
}
@@ -0,0 +1,65 @@
import type { Setting } from '../../api/types'
import { Alert } from '../../components/ui'
/**
* Znacznik trybu bezpiecznego.
*
* Pokazywany **nad** polami, a nie pod nimi: administrator ma wiedziec, dokad
* naprawde ida wiadomosci, zanim zacznie cokolwiek ustawiac albo wysylac test.
*
* Trzy stany, bo kazdy znaczy co innego dla osoby patrzacej na ekran:
* - tryb wlaczony i adres ustawiony: wszystko idzie na jeden adres, nic nie
* dociera do uczestnikow,
* - tryb wlaczony, ale adresu brak: wysylka jest **wstrzymana**, bo backend
* woli nie wyslac nic niz wyslac do prawdziwych ludzi wbrew ustawieniu,
* - tryb wylaczony: wiadomosci ida do prawdziwych odbiorcow, i to tez trzeba
* powiedziec wprost, bo to stan, w ktorym pomylka kosztuje najwiecej.
*/
export function SafeModeNotice({
settings,
modeKey,
recipientKey,
channel,
}: {
settings: Setting[]
modeKey: string
recipientKey: string
channel: 'poczty' | 'SMS'
}) {
const mode = settings.find((s) => s.key === modeKey)
const recipient = settings.find((s) => s.key === recipientKey)
// dopoki ustawienia sie wczytuja, nie zgadujemy stanu
if (!mode) return null
const enabled = mode.value === 'true'
const target = recipient?.value?.trim() ?? ''
if (!enabled) {
return (
<Alert tone="warn" icon="alert-triangle">
<span className="font-semibold">Tryb bezpieczny {channel} jest wyłączony.</span>{' '}
Wiadomości idą do prawdziwych odbiorców z bazy. Na instancji testowej włącz tryb
bezpieczny, żeby nic nie trafiło do uczestników.
</Alert>
)
}
if (!target) {
return (
<Alert tone="alarm" icon="circle-alert">
<span className="font-semibold">Tryb bezpieczny {channel} jest włączony, ale brakuje adresu zastępczego.</span>{' '}
Wysyłka jest wstrzymana, żeby nic nie trafiło do uczestników. Uzupełnij pole poniżej.
</Alert>
)
}
return (
<Alert tone="ok" icon="check-circle">
<span className="font-semibold">Tryb bezpieczny {channel} jest włączony.</span>{' '}
Wszystkie wiadomości, niezależnie od odbiorcy, trafiają na{' '}
<span className="hx-data font-semibold">{target}</span>. Prawdziwy odbiorca jest widoczny
w temacie, a w dzienniku wysyłek zapisywany bez zmian.
</Alert>
)
}
@@ -0,0 +1,78 @@
import { NavLink, Outlet } from 'react-router-dom'
import { Icon, type IconName } from '../../components/Icon'
import { panelPath } from '../../lib/routes'
/**
* Uklad sekcji ustawien z wlasna nawigacja.
*
* Wszystkie obszary byly wczesniej na jednej stronie, przez co trzeba bylo
* dlugo przewijac, zeby dojsc do tresci zgod. Kazdy obszar ma teraz wlasny
* adres, wiec da sie do niego wrocic zakladka i podeslac link.
*
* Nawigacja jest pozioma i przewijalna w poziomie **wewnatrz wlasnego
* kontenera**, wiec na telefonie nie rozpycha strony.
*/
type SettingsTab = {
to: string
label: string
icon: IconName
/** `end` tylko dla trasy indeksowej, inaczej podswietlalaby sie zawsze. */
end?: boolean
}
export const SETTINGS_TABS: SettingsTab[] = [
// Dane firmy / RODO jako pierwsza pozycja: to od nich zalezy zgodnosc prawna
// (stopka, obowiazek informacyjny, tresc regulaminu skladana z tych danych),
// wiec administrator powinien wypelnic je najpierw.
{ to: panelPath('/ustawienia/dane-firmy'), label: 'Dane firmy / RODO', icon: 'users' },
{ to: panelPath('/ustawienia'), label: 'Aplikacja', icon: 'settings', end: true },
{ to: panelPath('/ustawienia/grafika'), label: 'Grafika', icon: 'droplet' },
{ to: panelPath('/ustawienia/email'), label: 'E-mail', icon: 'mail' },
{ to: panelPath('/ustawienia/sms'), label: 'SMS', icon: 'send' },
{ to: panelPath('/ustawienia/wideo'), label: 'Wideo', icon: 'video' },
{ to: panelPath('/ustawienia/przypomnienia'), label: 'Przypomnienia', icon: 'clock' },
{ to: panelPath('/ustawienia/zgody'), label: 'Zgody', icon: 'file-text' },
{ to: panelPath('/ustawienia/strona-glowna'), label: 'Strona główna', icon: 'book-open' },
]
function tabClass({ isActive }: { isActive: boolean }): string {
const base =
'flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm '
+ 'transition-colors duration-fast ease-standard'
return isActive
? `${base} bg-blue-50 font-medium text-blue-700`
: `${base} text-slate-600 hover:bg-slate-50 hover:text-slate-900`
}
export function SettingsLayout() {
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-slate-900">Ustawienia</h1>
<p className="text-sm text-slate-500">
Wartości zapisane tutaj mają pierwszeństwo przed zmiennymi środowiskowymi. Zmiana
działa od razu, bez restartu aplikacji.
</p>
</div>
{/*
overflow-x-auto na samej nawigacji, a nie na stronie: pasek zakladek
przewija sie sam, a dokument zostaje bez przewijania w poziomie.
*/}
<nav
aria-label="Sekcje ustawień"
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
>
{SETTINGS_TABS.map((tab) => (
<NavLink key={tab.to} to={tab.to} end={tab.end} className={tabClass}>
<Icon name={tab.icon} size={16} />
{tab.label}
</NavLink>
))}
</nav>
<Outlet />
</div>
)
}
@@ -0,0 +1,28 @@
import type { Setting } from '../../api/types'
import { ErrorState, Spinner } from '../../components/ui'
import { errorMessage, useSettings } from './useSettings'
/**
* Opakowanie podstrony ustawien: wczytanie, blad, a potem tresc.
*
* Dzieki temu kazda podstrona zajmuje sie wylacznie swoimi polami,
* a stany posrednie wygladaja wszedzie tak samo.
*/
export function SettingsPane({
children,
}: {
children: (props: {
settings: Setting[]
onSave: (values: Record<string, string>) => void
saving: boolean
}) => React.ReactNode
}) {
const { query, save } = useSettings()
if (query.isPending) return <Spinner />
if (query.isError) {
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać ustawień')} />
}
return <>{children({ settings: query.data, onSave: save.mutate, saving: save.isPending })}</>
}
@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import type { Setting } from '../../api/types'
import { MediaField } from '../../components/MediaField'
import { Badge, Button, Field, Input, PasswordInput, Select, Textarea } from '../../components/ui'
/**
* Sekcja ustawien z wlasnym przyciskiem zapisu.
*
* Przy kluczach typu PASSWORD backend nigdy nie zwraca wartosci, tylko flage
* valueSet. Puste pole oznacza brak zmiany, wiec do zapisu trafiaja wylacznie
* te sekrety, ktore administrator faktycznie wpisal.
*/
export function SettingsSection({
title,
description,
settings,
keys,
multiline = [],
onSave,
saving,
intro,
children,
}: {
title: string
description?: string
settings: Setting[]
keys: string[]
/** Klucze prezentowane jako pole wielolinijkowe, np. tresci zgod. */
multiline?: string[]
onSave: (values: Record<string, string>) => void
saving: boolean
/** Objasnienie nad polami, np. opis formatu wartosci. */
intro?: React.ReactNode
/** Tresc pod przyciskiem zapisu, np. blok testu wysylki. */
children?: React.ReactNode
}) {
const visible = keys
.map((key) => settings.find((setting) => setting.key === key))
.filter((setting): setting is Setting => Boolean(setting))
const [values, setValues] = useState<Record<string, string>>({})
// po wczytaniu ustawien wypelniamy pola, sekrety zostaja puste
useEffect(() => {
const initial: Record<string, string> = {}
visible.forEach((setting) => {
initial[setting.key] = setting.secret ? '' : (setting.value ?? '')
})
setValues(initial)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const payload: Record<string, string> = {}
visible.forEach((setting) => {
const value = values[setting.key] ?? ''
// pusty sekret oznacza brak zmiany, nie wysylamy go w ogole
if (setting.secret && value.trim() === '') return
payload[setting.key] = value
})
// Sekrety czyscimy od razu po wyslaniu. Backend nigdy nie zwraca ich wartosci,
// wiec pozostawione w polu haslo sugerowaloby, ze pokazuje stan zapisany,
// a przy kolejnym zapisie wyslaloby je ponownie.
setValues((current) => {
const cleared = { ...current }
visible.forEach((setting) => {
if (setting.secret) cleared[setting.key] = ''
})
return cleared
})
onSave(payload)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
{description && <p className="text-sm text-slate-500">{description}</p>}
</div>
{intro}
<div className="space-y-4 rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
{visible.map((setting) => (
<Field
key={setting.key}
label={setting.description}
envName={setting.key}
badge={
setting.secret && setting.valueSet ? (
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">ustawione</Badge>
) : undefined
}
hint={setting.secret ? 'Puste pole oznacza brak zmiany zapisanej wartości' : undefined}
>
{setting.type === 'BOOLEAN' ? (
<Select
value={values[setting.key] ?? 'false'}
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
>
<option value="true">Włączone (true)</option>
<option value="false">Wyłączone (false)</option>
</Select>
) : setting.type === 'SELECT' ? (
/*
* Zbior wartosci jest zamkniety, wiec pokazujemy liste wyboru.
* Wpisywanie recznie oznaczalo, ze literowka w nazwie dostawcy
* wideo zamieniala instancje w tryb NONE bez zadnego sygnalu.
* Backend odrzuca wartosc spoza listy, a panel jej nie oferuje.
*/
<Select
value={values[setting.key] ?? ''}
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
>
{setting.allowedValues.map((allowed) => (
<option key={allowed} value={allowed}>
{allowed}
</option>
))}
</Select>
) : setting.type === 'MEDIA' ? (
<MediaField
value={values[setting.key] ?? ''}
onChange={(value) => setValues((c) => ({ ...c, [setting.key]: value }))}
/>
) : multiline.includes(setting.key) ? (
<Textarea
rows={3}
value={values[setting.key] ?? ''}
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
/>
) : setting.secret ? (
// podglad ulatwia sprawdzenie wklejonego hasla albo klucza API
<PasswordInput
value={values[setting.key] ?? ''}
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
placeholder={setting.valueSet ? '(wpisz, aby zmienić)' : undefined}
autoComplete="new-password"
/>
) : (
<Input
type={setting.type === 'INT' ? 'number' : 'text'}
value={values[setting.key] ?? ''}
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
/>
)}
</Field>
))}
</div>
<Button type="submit" icon="save" disabled={saving}>
{saving ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</Button>
{children}
</form>
)
}
@@ -0,0 +1,47 @@
import { SafeModeNotice } from './SafeModeNotice'
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
import { TestBlock } from './TestBlock'
const KEYS = [
'SMS_ENABLED',
'SMS_GATEWAY_URL',
'SMS_API_KEY',
'SMS_CREATOR',
'SMS_TIMEOUT_SECONDS',
'SMS_RATE_LIMIT_PER_HOUR',
'SMS_SAFE_MODE',
'SMS_SAFE_RECIPIENT',
]
export function SmsSettingsPage() {
return (
<SettingsPane>
{({ settings, onSave, saving }) => (
<SettingsSection
title="Bramka SMS"
description="Przypomnienia SMS idą tylko do osób, które wyraziły zgodę i podały numer"
settings={settings}
keys={KEYS}
onSave={onSave}
saving={saving}
intro={
<SafeModeNotice
settings={settings}
modeKey="SMS_SAFE_MODE"
recipientKey="SMS_SAFE_RECIPIENT"
channel="SMS"
/>
}
>
<TestBlock
title="Test konfiguracji"
label="Testowy SMS na numer"
placeholder="np. 600100200"
endpoint="/admin/settings/test-sms"
/>
</SettingsSection>
)}
</SettingsPane>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { TestResult } from '../../api/types'
import { Button, Input } from '../../components/ui'
import { Icon } from '../../components/Icon'
/**
* Blok testu konfiguracji: pole adresu i przycisk wysylki.
*
* Backend zwraca 200 takze przy niepowodzeniu, z flaga success i pelna trescia
* bledu, wiec wynik pokazujemy na miejscu zamiast tylko w powiadomieniu.
*/
export function TestBlock({
title,
label,
placeholder,
endpoint,
buttonLabel = 'Wyślij',
}: {
title: string
label: string
placeholder: string
endpoint: string
buttonLabel?: string
}) {
const [recipient, setRecipient] = useState('')
const [result, setResult] = useState<TestResult | null>(null)
const send = useMutation({
mutationFn: async () => {
const response = await client.post<TestResult>(endpoint, { recipient })
return response.data
},
onSuccess: (data) => setResult(data),
onError: (error) =>
setResult({ success: false, message: errorMessage(error, 'Nie udało się wykonać testu') }),
})
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-5">
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
<div className="mt-3 flex flex-wrap items-end gap-2">
<div className="min-w-[240px] flex-1">
<label className="mb-1 block text-sm font-medium text-slate-700">{label}</label>
<Input
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder={placeholder}
/>
</div>
<Button
type="button"
onClick={() => send.mutate()}
disabled={send.isPending || !recipient.trim()}
icon={send.isPending ? undefined : 'send'}
>
{send.isPending ? 'Wysyłanie...' : buttonLabel}
</Button>
</div>
{result && (
<div
className={`mt-3 flex items-start gap-2 rounded-md border p-3 text-sm ${
result.success
? 'border-ok-500/20 bg-ok-50 text-ok-600'
: 'border-alarm-500/20 bg-alarm-50 text-alarm-600'
}`}
>
<Icon name={result.success ? 'check-circle' : 'circle-alert'} size={18} className="mt-px" />
{/* pelna tresc bledu z serwera, czesto wielolinijkowa */}
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
{result.message}
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,125 @@
import { SettingsPane } from './SettingsPane'
import { SettingsSection } from './SettingsSection'
/**
* Ustawienia wideo instancji.
*
* TO JEST MIEJSCE DLA TECHNIKA, NIE DLA OSOBY ZAKLADAJACEJ WEBINAR.
* ---------------------------------------------------------------------------
* Klucze dostawcow (LiveKit, Zoom, Jitsi) i sekret webhooka ustawia sie tutaj
* RAZ, przy wdrozeniu instancji u klienta. Osoba tworzaca webinar nigdy tego
* nie dotyka: w formularzu webinaru wybiera tylko gotowego dostawce z listy
* i klika "Utworz pokoj". Dlatego instrukcja techniczna jest tutaj, a nie
* przy webinarze.
*
* Wczesniej tych kluczy NIE DALO SIE ustawic przez panel (byly wylacznie
* w bazie/zmiennych srodowiskowych), a instrukcja "?" przy webinarze mylila,
* bo odsylala do nieistniejacej sekcji Wideo. Ta strona domyka te luke.
*/
const KEYS = [
'VIDEO_PROVIDER',
'VIDEO_WEBHOOK_SECRET',
'VIDEO_DELETE_AFTER_DOWNLOAD',
// LiveKit
'LIVEKIT_URL',
'LIVEKIT_API_KEY',
'LIVEKIT_API_SECRET',
// Jitsi
'JITSI_DOMAIN',
'JITSI_JWT_ENABLED',
'JITSI_APP_ID',
'JITSI_APP_SECRET',
// Zoom
'ZOOM_ACCOUNT_ID',
'ZOOM_CLIENT_ID',
'ZOOM_CLIENT_SECRET',
'ZOOM_SDK_KEY',
'ZOOM_SDK_SECRET',
]
export function VideoSettingsPage() {
return (
<SettingsPane>
{({ settings, onSave, saving }) => (
<SettingsSection
title="Wideo (konfiguracja instancji)"
description="Dostawca wideo i jego klucze. Ustawia się raz, przy wdrożeniu. Osoba zakładająca webinar tego nie dotyka - wybiera tylko gotowego dostawcę z listy."
settings={settings}
keys={KEYS}
onSave={onSave}
saving={saving}
intro={<VideoInstructions />}
/>
)}
</SettingsPane>
)
}
/**
* Instrukcja techniczna, ktora WCZESNIEJ byla w oknie "?" przy webinarze.
* Jej miejsce jest tu: to jednorazowa konfiguracja infrastruktury.
*/
function VideoInstructions() {
return (
<details className="rounded-md border border-slate-200 bg-slate-25 p-4 text-sm">
<summary className="cursor-pointer font-semibold text-slate-800">
Jak skonfigurować dostawcę wideo (instrukcja dla administratora)
</summary>
<div className="mt-3 space-y-4 text-slate-600">
<p>
Wybierz jednego dostawcę w polu <strong>VIDEO_PROVIDER</strong> i uzupełnij jego klucze.
Pozostali dostawcy mogą zostać puści. Po zapisaniu konfiguracji osoba zakładająca
webinar wybiera dostawcę z gotowej listy i klika „Utwórz pokój" - bez żadnych kroków
technicznych.
</p>
<div>
<p className="font-semibold text-slate-800">LiveKit (zalecany)</p>
<p>
Serwer wideo uruchamiany na własnej infrastrukturze. Uprawnienia egzekwuje serwer na
podstawie podpisanego tokenu, nagrywanie działa automatycznie.
</p>
<ol className="mt-1 list-decimal space-y-1 pl-5">
<li>Uruchom serwer: profil Docker Compose „livekit" (<code>docker compose --profile livekit up -d</code>). Nagrywanie wymaga usług Redis i Egress z tego profilu.</li>
<li>Wpisz parę klucz-sekret w <strong>LIVEKIT_API_KEY</strong> i <strong>LIVEKIT_API_SECRET</strong>.</li>
<li>Podaj adres WebSocket serwera w <strong>LIVEKIT_URL</strong> (np. <code>wss://twoj-serwer</code>; lokalnie <code>ws://localhost:7880</code>).</li>
</ol>
</div>
<div>
<p className="font-semibold text-slate-800">Zoom</p>
<p>Obraz osadzony w oknie webinaru. Wymaga płatnego konta Zoom (min. Pro) i dwóch aplikacji na koncie dewelopera.</p>
<ol className="mt-1 list-decimal space-y-1 pl-5">
<li>Na marketplace.zoom.us utwórz aplikację <strong>Server-to-Server OAuth</strong> - da Account ID, Client ID, Client Secret. Dodaj zakresy: meeting:write, meeting:read, recording:read, recording:write.</li>
<li>Wpisz je w <strong>ZOOM_ACCOUNT_ID</strong>, <strong>ZOOM_CLIENT_ID</strong>, <strong>ZOOM_CLIENT_SECRET</strong>.</li>
<li>Utwórz drugą aplikację <strong>Meeting SDK</strong> - da SDK Key i SDK Secret. Wpisz w <strong>ZOOM_SDK_KEY</strong> i <strong>ZOOM_SDK_SECRET</strong>.</li>
<li>W Event Subscriptions dodaj adres <code>/api/webhooks/video/zoom</code> i zdarzenia: Recording Completed, Participant joined/left. Zoom poda Secret Token - wpisz go w <strong>VIDEO_WEBHOOK_SECRET</strong>.</li>
</ol>
<p className="mt-1">
Uwaga: Zoom nie egzekwuje uprawnień do obrazu i dźwięku per uczestnik - steruje nimi na
poziomie całego spotkania (jak Jitsi, nie jak LiveKit).
</p>
</div>
<div>
<p className="font-semibold text-slate-800">Jitsi Meet</p>
<p>
Publiczny <code>meet.jit.si</code> działa bez konta (<strong>JITSI_DOMAIN</strong>), ale pokój jest wtedy
otwarty dla każdego, kto zna adres. Własny serwer z modułem JWT pozwala włączyć
<strong> JITSI_JWT_ENABLED</strong> i wpisać <strong>JITSI_APP_ID</strong> oraz <strong>JITSI_APP_SECRET</strong>.
</p>
</div>
<div>
<p className="font-semibold text-slate-800">Bez wbudowanego wideo (NONE)</p>
<p>
Ustaw <strong>VIDEO_PROVIDER</strong> na NONE. Obraz idzie zewnętrznym narzędziem - link
wkleja się w formularzu każdego webinaru. Czat, pytania i zapytania ofertowe zostają
na tej platformie.
</p>
</div>
</div>
</details>
)
}
@@ -0,0 +1,41 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Setting } from '../../api/types'
import { useToasts } from '../../components/Toasts'
import { PUBLIC_CONFIG_KEY } from '../../lib/usePublicConfig'
/**
* Odczyt i zapis ustawien, wspolny dla wszystkich podstron.
*
* Kazda podstrona pobiera pelna liste ustawien i pokazuje wlasny wycinek.
* TanStack Query trzyma jedno zapytanie pod kluczem `settings`, wiec
* przechodzenie miedzy zakladkami nie odpytuje serwera od nowa.
*/
export function useSettings() {
const queryClient = useQueryClient()
const toasts = useToasts()
const query = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await client.get<Setting[]>('/admin/settings')
return response.data
},
})
const save = useMutation({
mutationFn: (values: Record<string, string>) => client.put('/admin/settings', values),
onSuccess: () => {
toasts.success('Ustawienia zostały zapisane')
queryClient.invalidateQueries({ queryKey: ['settings'] })
// jawna konfiguracja jest buforowana na czas sesji, wiec po zapisie
// trzeba ja uniewaznic recznie, inaczej naglowek pokazywalby stara marke
queryClient.invalidateQueries({ queryKey: PUBLIC_CONFIG_KEY })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
})
return { query, save }
}
export { errorMessage }
+401
View File
@@ -0,0 +1,401 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Page, User, UserRole } from '../../api/types'
import { useAuth } from '../../auth/AuthContext'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Field, IconButton, Input, PasswordInput, Select, Spinner } from '../../components/ui'
import { formatLocal } from '../../lib/dates'
import { ROLE_LABEL } from '../../lib/labels'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
type FormState = {
email: string
fullName: string
role: UserRole
active: boolean
password: string
}
const EMPTY: FormState = { email: '', fullName: '', role: 'MODERATOR', active: true, password: '' }
export function UserListPage() {
const queryClient = useQueryClient()
const toasts = useToasts()
const { user: currentUser } = useAuth()
const [editing, setEditing] = useState<User | null>(null)
const [creating, setCreating] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Record<string, string>>({})
/** Haslo po resecie, pokazywane jeden raz. */
const [resetPassword, setResetPassword] = useState<{ email: string; password: string } | null>(null)
const [page, setPage] = useState(0)
const query = useQuery({
queryKey: ['users', page],
queryFn: async () => {
const response = await client.get<Page<User>>('/admin/users', {
params: { page, size: PAGE_SIZE },
})
return response.data
},
})
function closeForm() {
setCreating(false)
setEditing(null)
setForm(EMPTY)
setErrors({})
}
const save = useMutation({
mutationFn: async (payload: FormState) => {
const body = {
email: payload.email.trim(),
fullName: payload.fullName.trim(),
role: payload.role,
active: payload.active,
// przy edycji puste haslo oznacza brak zmiany
password: payload.password.trim() || undefined,
}
if (editing) {
return client.put(`/admin/users/${editing.id}`, body)
}
return client.post('/admin/users', body)
},
onSuccess: () => {
toasts.success(editing ? 'Konto zostało zaktualizowane' : 'Konto zostało utworzone')
queryClient.invalidateQueries({ queryKey: ['users'] })
closeForm()
},
onError: (error) => {
setErrors(fieldErrors(error))
toasts.error(errorMessage(error, 'Nie udało się zapisać konta'))
},
})
const deactivate = useMutation({
mutationFn: (id: number) => client.post(`/admin/users/${id}/deactivate`),
onSuccess: () => {
toasts.success('Konto zostało dezaktywowane')
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dezaktywować konta')),
})
const reset = useMutation({
mutationFn: async (user: User) => {
const response = await client.post<{ password: string; message: string }>(
`/admin/users/${user.id}/reset-password`,
)
return { email: user.email, password: response.data.password }
},
onSuccess: (data) => {
setResetPassword(data)
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zresetować hasła')),
})
function startEdit(user: User) {
setEditing(user)
setCreating(false)
setErrors({})
setForm({ email: user.email, fullName: user.fullName, role: user.role, active: user.active, password: '' })
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const found: Record<string, string> = {}
if (!form.email.trim()) found.email = 'Adres e-mail jest wymagany'
if (!form.fullName.trim()) found.fullName = 'Imię i nazwisko jest wymagane'
if (!editing && form.password.trim().length < 10) {
found.password = 'Hasło musi mieć co najmniej 10 znaków'
}
if (editing && form.password.trim() && form.password.trim().length < 10) {
found.password = 'Hasło musi mieć co najmniej 10 znaków'
}
setErrors(found)
if (Object.keys(found).length > 0) return
save.mutate(form)
}
if (query.isPending) return <Spinner />
if (query.isError) {
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać listy kont')} />
}
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">Użytkownicy</h1>
<p className="text-sm text-slate-500">Konta administracyjne panelu</p>
</div>
<Button
icon="user-plus"
onClick={() => {
setCreating(true)
setEditing(null)
setForm(EMPTY)
setErrors({})
}}
>
Nowe konto
</Button>
</div>
{resetPassword && (
<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="text-warn-600" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-warn-600">
Nowe hasło dla {resetPassword.email}
</p>
<p className="mt-1 break-all rounded bg-white px-2 py-1 font-mono text-sm text-slate-900">
{resetPassword.password}
</p>
<p className="mt-1 text-xs text-warn-600">
Hasło jest pokazane tylko raz. Przekaż je użytkownikowi, będzie musiał je zmienić.
</p>
</div>
<button
type="button"
onClick={() => setResetPassword(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 || editing) && (
<Card title={editing ? `Edycja konta: ${editing.email}` : 'Nowe konto'}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Adres e-mail" error={errors.email}>
<Input
type="email"
value={form.email}
onChange={(e) => setForm((c) => ({ ...c, email: e.target.value }))}
required
/>
</Field>
<Field label="Imię i nazwisko" error={errors.fullName}>
<Input
value={form.fullName}
onChange={(e) => setForm((c) => ({ ...c, fullName: e.target.value }))}
required
/>
</Field>
<Field label="Rola" error={errors.role}>
<Select
value={form.role}
onChange={(e) => setForm((c) => ({ ...c, role: e.target.value as UserRole }))}
>
{(Object.keys(ROLE_LABEL) as UserRole[]).map((role) => (
<option key={role} value={role}>
{ROLE_LABEL[role]}
</option>
))}
</Select>
</Field>
<Field
label={editing ? 'Nowe hasło' : 'Hasło'}
error={errors.password}
hint={editing ? 'Puste oznacza brak zmiany' : 'Minimum 10 znaków'}
>
<PasswordInput
value={form.password}
onChange={(e) => setForm((c) => ({ ...c, password: e.target.value }))}
autoComplete="new-password"
/>
</Field>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm((c) => ({ ...c, active: e.target.checked }))}
className="h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
/>
<span className="font-medium text-slate-700">Konto aktywne</span>
</label>
<div className="flex justify-end gap-2">
<Button variant="secondary" type="button" onClick={closeForm}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
</form>
</Card>
)}
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
<ul className="divide-y divide-slate-100 lg:hidden">
{query.data.content.map((user) => {
const isSelf = user.id === currentUser?.id
return (
<li key={user.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">
{user.fullName}
{isSelf && <span className="ml-1.5 text-xs text-slate-400">(to Ty)</span>}
</p>
<p className="truncate text-sm text-slate-500">{user.email}</p>
</div>
<Badge tone={user.role === 'ADMIN' ? 'brand' : 'neutral'}>
{ROLE_LABEL[user.role]}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
{user.active ? (
<Badge tone="ok" dot>Aktywne</Badge>
) : (
<Badge tone="alarm">Nieaktywne</Badge>
)}
{user.mustChangePassword && <Badge tone="warn">wymaga zmiany hasła</Badge>}
<div className="ml-auto flex gap-1">
<IconButton icon="pencil" label="Edytuj" onClick={() => startEdit(user)} />
<IconButton
icon="key"
label="Resetuj hasło"
onClick={() => {
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) reset.mutate(user)
}}
disabled={reset.isPending}
/>
{user.active && !isSelf && (
<IconButton
icon="user-off"
label="Dezaktywuj"
onClick={() => {
if (window.confirm(`Dezaktywować konto ${user.email}?`)) deactivate.mutate(user.id)
}}
disabled={deactivate.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
)}
</div>
</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">Konto</th>
<th className="px-4 py-3 font-semibold">Rola</th>
<th className="px-4 py-3 font-semibold">Stan</th>
<th className="px-4 py-3 font-semibold">Ostatnie logowanie</th>
<th className="px-4 py-3 text-right font-semibold">Akcje</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{query.data.content.map((user) => {
const isSelf = user.id === currentUser?.id
return (
<tr key={user.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900">
{user.fullName}
{isSelf && <span className="ml-1.5 text-xs text-slate-400">(to Ty)</span>}
</p>
<p className="text-xs text-slate-500">{user.email}</p>
</td>
<td className="px-4 py-3">
<Badge
className={
user.role === 'ADMIN'
? 'bg-blue-50 text-blue-700 ring-blue-200'
: 'bg-slate-100 text-slate-700 ring-slate-200'
}
>
{ROLE_LABEL[user.role]}
</Badge>
</td>
<td className="px-4 py-3">
{user.active ? (
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">Aktywne</Badge>
) : (
<Badge className="bg-alarm-50 text-alarm-600 ring-alarm-500/20">Nieaktywne</Badge>
)}
{user.mustChangePassword && (
<p className="mt-1 text-xs text-warn-600">wymaga zmiany hasła</p>
)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
{formatLocal(user.lastLoginAt)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<Button variant="ghost" icon="pencil" onClick={() => startEdit(user)} title="Edytuj">
<span className="sr-only">Edytuj</span>
</Button>
<Button
variant="ghost"
icon="key"
onClick={() => {
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) {
reset.mutate(user)
}
}}
disabled={reset.isPending}
title="Resetuj hasło"
>
<span className="sr-only">Resetuj hasło</span>
</Button>
{user.active && !isSelf && (
<Button
variant="ghost"
icon="user-off"
onClick={() => {
if (window.confirm(`Dezaktywować konto ${user.email}?`)) {
deactivate.mutate(user.id)
}
}}
disabled={deactivate.isPending}
title="Dezaktywuj"
className="text-alarm-600 hover:bg-alarm-50"
>
<span className="sr-only">Dezaktywuj</span>
</Button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{query.data && (
<Pagination
page={query.data.number}
totalPages={query.data.totalPages}
totalElements={query.data.totalElements}
onChange={setPage}
itemLabel="kont"
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,344 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Certificate, CertificateBulkResult, Page, Registration, Webinar } from '../../api/types'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Field, Input, Spinner } from '../../components/ui'
import { usePublicUrl } from '../../lib/usePublicConfig'
import { formatLocal } from '../../lib/dates'
/**
* Certyfikaty uczestnictwa.
*
* Wydanie jest **nieodwracalne w praktyce**: dokument moze juz trafic do
* teczki uczestnika, wiec uniewaznienie zostawia slad zamiast kasowac wpis.
* Panel mowi to wprost przy kazdej akcji.
*/
export function CertificateSection({ webinar }: { webinar: Webinar }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const publicUrl = usePublicUrl()
const [enabled, setEnabled] = useState(webinar.certificatesEnabled)
const [hours, setHours] = useState(
webinar.certificateHours === null ? '' : String(webinar.certificateHours),
)
const certificates = useQuery({
queryKey: ['certificates', webinar.id],
queryFn: async () => {
const response = await client.get<Certificate[]>(`/admin/webinars/${webinar.id}/certificates`)
return response.data
},
})
/* uczestnicy: potrzebni do wydania pojedynczego i do pokazania, kto go nie ma */
const registrations = useQuery({
queryKey: ['registrations', String(webinar.id), '', 0],
queryFn: async () => {
const response = await client.get<Page<Registration>>(
`/admin/webinars/${webinar.id}/registrations`,
{ params: { page: 0, size: 100 } },
)
return response.data
},
})
const saveSettings = useMutation({
mutationFn: () =>
client.put(`/admin/webinars/${webinar.id}`, {
title: webinar.title,
slug: webinar.slug,
description: webinar.description,
contentHtml: webinar.contentHtml,
startsAt: webinar.startsAt,
endsAt: webinar.endsAt,
timezone: webinar.timezone,
registrationOpen: webinar.registrationOpen,
registrationDeadline: webinar.registrationDeadline,
capacity: webinar.capacity,
joinUrl: webinar.joinUrl,
recordingUrl: webinar.recordingUrl,
materialsUrl: webinar.materialsUrl,
certificatesEnabled: enabled,
certificateHours: hours.trim() === '' ? null : Number(hours.replace(',', '.')),
certificateTemplateHtml: webinar.certificateTemplateHtml,
}),
onSuccess: () => {
toasts.success('Ustawienia certyfikatów zostały zapisane')
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinar.id)] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
})
const issueAll = useMutation({
mutationFn: (force: boolean) =>
client.post<CertificateBulkResult>(
`/admin/webinars/${webinar.id}/certificates/all`,
null,
{ params: { force } },
),
onSuccess: (response) => {
const { issued, skipped, problems } = response.data
const extra = skipped > 0 ? `, pominięto ${skipped}` : ''
toasts.success(`Wydano ${issued} certyfikatów${extra}`)
problems.forEach((problem) => toasts.error(problem))
queryClient.invalidateQueries({ queryKey: ['certificates', webinar.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatów')),
})
const issueOne = useMutation({
mutationFn: (payload: { registrationId: number; force: boolean }) =>
client.post<Certificate>(`/admin/webinars/${webinar.id}/certificates`, payload),
onSuccess: () => {
toasts.success('Certyfikat został wydany')
queryClient.invalidateQueries({ queryKey: ['certificates', webinar.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatu')),
})
const revoke = useMutation({
mutationFn: (certificateId: number) =>
client.post(`/admin/certificates/${certificateId}/revoke`),
onSuccess: () => {
toasts.success('Certyfikat został unieważniony')
queryClient.invalidateQueries({ queryKey: ['certificates', webinar.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się unieważnić certyfikatu')),
})
const issued = certificates.data ?? []
const withCertificate = new Set(issued.map((c) => c.registrationId))
const pending = (registrations.data?.content ?? []).filter(
(r) => r.status === 'CONFIRMED' && !withCertificate.has(r.id),
)
return (
<div className="space-y-4">
<Card title="Ustawienia certyfikatów">
<div className="space-y-4">
<label className="flex items-start gap-2.5">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-slate-300"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<span className="text-sm text-slate-700">
Wydawaj certyfikaty uczestnictwa
<span className="mt-0.5 block text-xs text-slate-500">
Po zakończeniu webinaru certyfikaty powstaną automatycznie dla uczestników
z odnotowaną obecnością, a link trafi do maila podsumowującego.
</span>
</span>
</label>
<Field
label="Liczba godzin szkoleniowych"
hint="Puste oznacza, że certyfikat nie podaje liczby godzin"
>
<Input
type="number"
min={0}
step="0.5"
className="max-w-[12rem]"
value={hours}
placeholder="np. 3"
onChange={(e) => setHours(e.target.value)}
/>
</Field>
<div className="flex justify-end">
<Button icon="save" disabled={saveSettings.isPending} onClick={() => saveSettings.mutate()}>
{saveSettings.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</Button>
</div>
</div>
</Card>
<Card
title="Wydawanie"
description="Certyfikat powstaje raz i nie zmienia się po wydaniu"
>
<div className="space-y-3">
{pending.length === 0 ? (
<p className="text-sm text-slate-500">
Wszyscy potwierdzeni uczestnicy mają już certyfikat.
</p>
) : (
<>
<p className="text-sm text-slate-700">
Bez certyfikatu:{' '}
<span className="hx-data font-semibold">{pending.length}</span>{' '}
{pending.length === 1 ? 'uczestnik' : 'uczestników'}, w tym{' '}
<span className="hx-data font-semibold">
{pending.filter((r) => !r.attended).length}
</span>{' '}
bez odnotowanej obecności.
</p>
<div className="flex flex-wrap gap-2">
<Button
icon="check-circle"
disabled={issueAll.isPending}
onClick={() => {
if (confirm('Wydać certyfikaty wszystkim uczestnikom z odnotowaną obecnością?')) {
issueAll.mutate(false)
}
}}
>
Wydaj obecnym
</Button>
<Button
variant="secondary"
icon="alert-triangle"
disabled={issueAll.isPending}
onClick={() => {
if (
confirm(
'Wydać certyfikaty WSZYSTKIM potwierdzonym uczestnikom, także tym bez ' +
'odnotowanej obecności? Tej operacji nie da się cofnąć, można jedynie ' +
'unieważnić pojedyncze certyfikaty.',
)
) {
issueAll.mutate(true)
}
}}
>
Wydaj wszystkim, także nieobecnym
</Button>
</div>
<ul className="divide-y divide-slate-100">
{pending.map((registration) => (
<li
key={registration.id}
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
>
<div className="min-w-0">
<p className="font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</p>
<p className="hx-data break-all text-xs text-slate-500">
{registration.email}
</p>
</div>
<div className="flex items-center gap-2">
{!registration.attended && (
<Badge tone="warn">Brak obecności</Badge>
)}
<Button
size="sm"
variant="secondary"
icon="file-text"
disabled={issueOne.isPending}
onClick={() => {
if (
registration.attended ||
confirm(
`${registration.firstName} ${registration.lastName} nie ma odnotowanej ` +
'obecności. Wydać certyfikat mimo to?',
)
) {
issueOne.mutate({
registrationId: registration.id,
force: !registration.attended,
})
}
}}
>
Wydaj
</Button>
</div>
</li>
))}
</ul>
</>
)}
</div>
</Card>
<Card title="Wydane certyfikaty">
{certificates.isPending && <Spinner />}
{certificates.isError && (
<ErrorState
message={errorMessage(certificates.error, 'Nie udało się wczytać certyfikatów')}
/>
)}
{issued.length === 0 && !certificates.isPending && (
<EmptyState
icon="file-text"
title="Brak certyfikatów"
description="Nikt jeszcze nie dostał certyfikatu z tego webinaru."
/>
)}
{issued.length > 0 && (
<ul className="divide-y divide-slate-100">
{issued.map((certificate) => (
<li
key={certificate.id}
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
>
<div className="min-w-0">
<p className="font-medium text-slate-900">{certificate.participantName}</p>
<p className="hx-data text-xs text-slate-500">
{certificate.number} · {formatLocal(certificate.issuedAt)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{certificate.valid ? (
<Badge tone="ok">Ważny</Badge>
) : (
<Badge tone="alarm">Unieważniony</Badge>
)}
<a
href={publicUrl + certificate.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-[36px] items-center gap-1.5 rounded-md border border-slate-200 px-3 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
>
<Icon name="download" size={16} />
Pobierz
</a>
{certificate.valid && (
<Button
size="sm"
variant="ghost"
icon="x-circle"
disabled={revoke.isPending}
onClick={() => {
if (
confirm(
`Unieważnić certyfikat ${certificate.number}? Plik zostanie, ale ` +
'weryfikacja publiczna pokaże go jako nieważny.',
)
) {
revoke.mutate(certificate.id)
}
}}
>
Unieważnij
</Button>
)}
</div>
</li>
))}
</ul>
)}
</Card>
<Alert tone="info">
Certyfikat można sprawdzić bez logowania pod adresem{' '}
<span className="hx-data">{publicUrl}/certyfikat/NUMER</span>. Weryfikacja pokazuje tylko
numer, tytuł, datę, imię z inicjałem i informację o ważności.
</Alert>
</div>
)
}
@@ -0,0 +1,462 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Material, Recording, Visibility } from '../../api/types'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, EmptyState, ErrorState, Select, Spinner } from '../../components/ui'
import { usePublicUrl } from '../../lib/usePublicConfig'
import { formatLocal } from '../../lib/dates'
/**
* Nagrania i materialy webinaru w panelu.
*
* Pliki lezą we wlasnym skladzie (MinIO) i sa serwowane przez backend, wiec
* kazde pobranie przechodzi przez kontrole widocznosci. Adres kopiowany
* przyciskiem jest pelny, zeby dalo sie go wkleic w maila albo na strone.
*/
const VISIBILITY_LABEL: Record<Visibility, string> = {
PRIVATE: 'Tylko panel',
REGISTERED: 'Uczestnicy webinaru',
PUBLIC: 'Każdy z linkiem',
}
const VISIBILITY_HINT: Record<Visibility, string> = {
PRIVATE: 'Widoczne wyłącznie dla zalogowanych do panelu.',
REGISTERED: 'Widoczne dla uczestników z potwierdzonym zapisem na ten webinar.',
PUBLIC: 'Widoczne dla każdego, kto zna adres. Nagranie zawiera wypowiedzi uczestników.',
}
const STATUS_LABEL: Record<string, string> = {
PENDING: 'Oczekuje na pobranie',
DOWNLOADING: 'Pobieranie w toku',
READY: 'Gotowe',
FAILED: 'Błąd pobierania',
}
export function FilesSection({ webinarId }: { webinarId: number }) {
return (
<div className="space-y-4">
<RecordingsCard webinarId={webinarId} />
<MaterialsCard webinarId={webinarId} />
</div>
)
}
// --- nagrania ---
function RecordingsCard({ webinarId }: { webinarId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const fileInput = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const query = useQuery({
queryKey: ['recordings', webinarId],
queryFn: async () => {
const response = await client.get<Recording[]>(`/admin/webinars/${webinarId}/recordings`)
return response.data
},
})
async function handleUpload(file: File) {
const form = new FormData()
form.append('file', file)
setUploading(true)
setProgress(0)
try {
await client.post(`/admin/webinars/${webinarId}/recordings`, form, {
// nagranie idzie dlugo, wiec pokazujemy postep zamiast samego kręcioła
onUploadProgress: (event) => {
if (event.total) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
},
timeout: 0,
})
toasts.success('Nagranie zostało wgrane')
queryClient.invalidateQueries({ queryKey: ['recordings', webinarId] })
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się wgrać nagrania'))
} finally {
setUploading(false)
setProgress(0)
if (fileInput.current) fileInput.current.value = ''
}
}
return (
<Card
title="Nagrania"
description="Pliki w składzie instancji. Odtwarzane przez naszą stronę, bez linku do dostawcy."
>
<div className="space-y-3">
<div>
<input
ref={fileInput}
type="file"
accept="video/mp4,video/webm,.mp4,.webm"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
}}
/>
<Button
type="button"
variant="secondary"
icon="upload"
disabled={uploading}
onClick={() => fileInput.current?.click()}
>
{uploading ? `Wysyłanie... ${progress}%` : 'Wgraj nagranie'}
</Button>
<p className="mt-1.5 text-xs text-slate-500">
Format MP4 albo WEBM. Typ pliku jest sprawdzany po zawartości, nie po nazwie.
</p>
</div>
{uploading && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full bg-blue-500 transition-all duration-fast ease-standard"
style={{ width: `${progress}%` }}
/>
</div>
)}
{query.isPending && <Spinner />}
{query.isError && (
<ErrorState message={errorMessage(query.error, 'Nie udało się wczytać nagrań')} />
)}
{query.data && query.data.length === 0 && (
<EmptyState
icon="video"
title="Brak nagrań"
description="Wgraj plik albo poczekaj, aż dostawca zgłosi gotowe nagranie."
/>
)}
{query.data && query.data.length > 0 && (
<ul className="space-y-2">
{query.data.map((recording) => (
<RecordingRow key={recording.id} recording={recording} webinarId={webinarId} />
))}
</ul>
)}
</div>
</Card>
)
}
function RecordingRow({ recording, webinarId }: { recording: Recording; webinarId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const publicUrl = usePublicUrl()
const changeVisibility = useMutation({
mutationFn: (visibility: Visibility) =>
client.put(`/admin/recordings/${recording.id}/visibility`, { visibility }),
onSuccess: () => {
toasts.success('Widoczność została zmieniona')
queryClient.invalidateQueries({ queryKey: ['recordings', webinarId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić widoczności')),
})
const remove = useMutation({
mutationFn: () => client.delete(`/admin/recordings/${recording.id}`),
onSuccess: () => {
toasts.success('Nagranie zostało usunięte')
queryClient.invalidateQueries({ queryKey: ['recordings', webinarId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć nagrania')),
})
const ready = recording.status === 'READY'
return (
<li className="rounded-md border border-slate-200 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-slate-900">{recording.title ?? 'Nagranie webinaru'}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="hx-data">{formatLocal(recording.createdAt)}</span>
{recording.sizeBytes !== null && (
<span className="hx-data">{formatSize(recording.sizeBytes)}</span>
)}
{recording.durationSeconds !== null && (
<span className="hx-data">{formatDuration(recording.durationSeconds)}</span>
)}
</div>
</div>
<Badge tone={ready ? 'ok' : recording.status === 'FAILED' ? 'alarm' : 'warn'}>
{STATUS_LABEL[recording.status] ?? recording.status}
</Badge>
</div>
{recording.error && (
<p className="mt-2 rounded-md bg-alarm-50 p-2 text-xs text-alarm-600">{recording.error}</p>
)}
<div className="mt-3 flex flex-wrap items-end gap-2">
<div className="w-full sm:w-56">
<label className="mb-1 block text-xs font-semibold text-slate-700">Widoczność</label>
<Select
value={recording.visibility}
disabled={changeVisibility.isPending}
onChange={(e) => changeVisibility.mutate(e.target.value as Visibility)}
>
{(Object.keys(VISIBILITY_LABEL) as Visibility[]).map((value) => (
<option key={value} value={value}>
{VISIBILITY_LABEL[value]}
</option>
))}
</Select>
</div>
{ready && recording.url && (
<Button
type="button"
size="sm"
variant="secondary"
icon="copy"
onClick={() => copyLink(publicUrl + recording.url, toasts)}
>
Kopiuj link
</Button>
)}
<Button
type="button"
size="sm"
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
const name = recording.title ?? 'Nagranie webinaru'
if (confirm(`Usunąć nagranie "${name}" wraz z plikiem?
Tej operacji nie da się cofnąć.`)) {
remove.mutate()
}
}}
>
Usuń
</Button>
</div>
<p className="mt-1.5 text-xs text-slate-500">{VISIBILITY_HINT[recording.visibility]}</p>
{ready && recording.url && (
<video
className="mt-3 w-full rounded-md bg-slate-900"
controls
preload="metadata"
src={recording.url}
/>
)}
</li>
)
}
// --- materialy ---
function MaterialsCard({ webinarId }: { webinarId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const fileInput = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const query = useQuery({
queryKey: ['materials', webinarId],
queryFn: async () => {
const response = await client.get<Material[]>(`/admin/webinars/${webinarId}/materials`)
return response.data
},
})
async function handleUpload(file: File) {
const form = new FormData()
form.append('file', file)
setUploading(true)
try {
await client.post(`/admin/webinars/${webinarId}/materials`, form, { timeout: 0 })
toasts.success('Materiał został wgrany')
queryClient.invalidateQueries({ queryKey: ['materials', webinarId] })
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się wgrać materiału'))
} finally {
setUploading(false)
if (fileInput.current) fileInput.current.value = ''
}
}
return (
<Card title="Materiały" description="Prezentacja, arkusze i inne pliki do pobrania">
<div className="space-y-3">
<div>
<input
ref={fileInput}
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
}}
/>
<Button
type="button"
variant="secondary"
icon="upload"
disabled={uploading}
onClick={() => fileInput.current?.click()}
>
{uploading ? 'Wysyłanie...' : 'Wgraj materiał'}
</Button>
<p className="mt-1.5 text-xs text-slate-500">
Dowolny format poza plikami wykonywalnymi. Lista zablokowanych rozszerzeń jest
w ustawieniach instancji.
</p>
</div>
{query.isPending && <Spinner />}
{query.isError && (
<ErrorState message={errorMessage(query.error, 'Nie udało się wczytać materiałów')} />
)}
{query.data && query.data.length === 0 && (
<EmptyState
icon="file-text"
title="Brak materiałów"
description="Wgraj prezentację albo inne pliki dla uczestników."
/>
)}
{query.data && query.data.length > 0 && (
<ul className="space-y-2">
{query.data.map((material) => (
<MaterialRow key={material.id} material={material} webinarId={webinarId} />
))}
</ul>
)}
</div>
</Card>
)
}
function MaterialRow({ material, webinarId }: { material: Material; webinarId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const publicUrl = usePublicUrl()
const changeVisibility = useMutation({
mutationFn: (visibility: Visibility) =>
client.put(`/admin/materials/${material.id}/visibility`, { visibility }),
onSuccess: () => {
toasts.success('Widoczność została zmieniona')
queryClient.invalidateQueries({ queryKey: ['materials', webinarId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić widoczności')),
})
const remove = useMutation({
mutationFn: () => client.delete(`/admin/materials/${material.id}`),
onSuccess: () => {
toasts.success('Materiał został usunięty')
queryClient.invalidateQueries({ queryKey: ['materials', webinarId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć materiału')),
})
return (
<li className="rounded-md border border-slate-200 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-slate-900">{material.title}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="hx-data break-all">{material.originalName}</span>
<span className="hx-data">{formatSize(material.sizeBytes)}</span>
</div>
</div>
<Badge tone={material.visibility === 'PRIVATE' ? 'neutral' : 'ok'}>
{VISIBILITY_LABEL[material.visibility]}
</Badge>
</div>
<div className="mt-3 flex flex-wrap items-end gap-2">
<div className="w-full sm:w-56">
<label className="mb-1 block text-xs font-semibold text-slate-700">Widoczność</label>
<Select
value={material.visibility}
disabled={changeVisibility.isPending}
onChange={(e) => changeVisibility.mutate(e.target.value as Visibility)}
>
{(Object.keys(VISIBILITY_LABEL) as Visibility[]).map((value) => (
<option key={value} value={value}>
{VISIBILITY_LABEL[value]}
</option>
))}
</Select>
</div>
<Button
type="button"
size="sm"
variant="secondary"
icon="copy"
onClick={() => copyLink(publicUrl + material.url, toasts)}
>
Kopiuj link
</Button>
<Button
type="button"
size="sm"
variant="ghost"
icon="trash"
disabled={remove.isPending}
onClick={() => {
if (confirm(`Usunąć materiał "${material.title}" wraz z plikiem?
Tej operacji nie da się cofnąć.`)) {
remove.mutate()
}
}}
>
Usuń
</Button>
</div>
</li>
)
}
// --- pomocnicze ---
async function copyLink(url: string, toasts: ReturnType<typeof useToasts>) {
try {
await navigator.clipboard.writeText(url)
toasts.success('Link został skopiowany')
} catch {
// przegladarka moze odmowic dostepu do schowka bez HTTPS
toasts.error('Nie udało się skopiować. Zaznacz adres i skopiuj ręcznie.')
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB`
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) return `${hours} godz. ${minutes} min`
return `${minutes} min`
}
@@ -0,0 +1,734 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Client, type IMessage } from '@stomp/stompjs'
import { client, errorMessage, getToken } from '../../api/client'
import type { Webinar } from '../../api/types'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
import type { InteractionView, ModeratorInteractionView } from '../../lib/useInteractions'
import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
import { panelPath } from '../../lib/routes'
/**
* Panel prowadzacego: czat na zywo i kolejka pytan.
*
* Aktualizacja idzie tym samym WebSocketem co u uczestnikow, **bez odpytywania
* serwera co kilka sekund**: prowadzacy patrzy na ten ekran przez cala godzine,
* wiec odpytywanie oznaczaloby setki zbednych zadan.
*
* Prowadzacy slucha osobnego tematu moderacji, na ktory ida takze pytania
* jeszcze niezatwierdzone. Uczestnicy tego tematu nie widza.
*/
type ModerationData = {
chat: ModeratorInteractionView[]
questions: ModeratorInteractionView[]
}
/**
* Sygnal dzwiekowy przy nowym zapytaniu ofertowym.
*
* Skladany w WebAudio, a nie wczytywany z pliku: chodzi o jeden krotki ton,
* a plik oznaczalby dodatkowy zasob do wgrania i podania sciezki. Dzwiek jest
* krotki i cichy: ma zwrocic uwage prowadzacego w trakcie mowienia, a nie
* przestraszyc jego i sale.
*
* Przegladarki blokuja dzwiek do pierwszej interakcji uzytkownika, wiec blad
* jest tu spodziewany i pomijany. Prowadzacy i tak zdazy kliknac w panelu.
*/
function playChime() {
try {
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
const ctx = new Ctx()
const oscillator = ctx.createOscillator()
const gain = ctx.createGain()
oscillator.type = 'sine'
oscillator.frequency.value = 880
gain.gain.setValueAtTime(0.0001, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35)
oscillator.connect(gain)
gain.connect(ctx.destination)
oscillator.start()
oscillator.stop(ctx.currentTime + 0.36)
oscillator.onended = () => void ctx.close()
} catch {
// dzwiek jest udogodnieniem, nie warunkiem dzialania panelu
}
}
/** Klucz zapamietanego wyboru: prowadzacy ustawia to raz, nie na kazdym webinarze. */
const SOUND_KEY = 'host-console-sound'
export function HostConsolePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const toasts = useToasts()
const [chat, setChat] = useState<ModeratorInteractionView[]>([])
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
const [connected, setConnected] = useState(false)
/** Ile pozycji przyszlo od ostatniego spojrzenia. Zerowane klikiem. */
const [newChat, setNewChat] = useState(0)
const [newQuestions, setNewQuestions] = useState(0)
/** Zapytania ofertowe licza sie osobno: to kontakt handlowy, nie pytanie. */
const [newOffers, setNewOffers] = useState(0)
/*
* Sygnal dzwiekowy przy zapytaniu ofertowym, wylaczalny.
*
* Domyslnie wlaczony, bo zapytanie ofertowe jest tym zgloszeniem, ktorego
* prowadzacy najbardziej nie chce przegapic, a w trakcie mowienia nie patrzy
* na panel. Wybor zapamietujemy w przegladarce: ustawia sie go raz, a nie
* przy kazdym webinarze.
*/
const [sound, setSound] = useState(() => {
try {
return window.localStorage.getItem(SOUND_KEY) !== 'off'
} catch {
return true
}
})
const soundRef = useRef(sound)
soundRef.current = sound
function toggleSound() {
setSound((on) => {
const next = !on
try {
window.localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
} catch {
// brak pamieci przegladarki nie moze zepsuc panelu
}
return next
})
}
/** Kolejka zgloszen do glosu. */
const [speakRequests, setSpeakRequests] = useState<SpeakRequestView[]>([])
/** Tryb otwartego mikrofonu, wczytywany razem z danymi webinaru. */
const [openMic, setOpenMic] = useState(false)
/** Tryb "wideo dla wszystkich", wczytywany razem z danymi webinaru. */
const [openVideo, setOpenVideo] = useState(false)
const loadSpeakQueue = useCallback(async () => {
if (!id) return
try {
const response = await client.get<SpeakRequestView[]>(
`/admin/webinars/${id}/speak-requests`,
)
setSpeakRequests(response.data)
} catch {
// kolejka jest dodatkiem do panelu: jej brak nie moze zatrzymac moderacji
}
}, [id])
useEffect(() => {
void loadSpeakQueue()
}, [loadSpeakQueue])
const webinar = useQuery({
queryKey: ['webinar', id],
queryFn: async () => {
const response = await client.get<Webinar>(`/admin/webinars/${id}`)
return response.data
},
})
const initial = useQuery({
queryKey: ['moderation', id],
queryFn: async () => {
const response = await client.get<ModerationData>(`/admin/webinars/${id}/interactions`)
return response.data
},
})
useEffect(() => {
if (!initial.data) return
setChat(initial.data.chat)
setQuestions(initial.data.questions)
}, [initial.data])
useEffect(() => {
if (webinar.data) {
setOpenMic(webinar.data.openMic ?? false)
setOpenVideo(webinar.data.openVideo ?? false)
}
}, [webinar.data])
useEffect(() => {
if (!id) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const stomp = new Client({
brokerURL: `${protocol}//${window.location.host}/ws`,
// prowadzacy nie ma tokenu zapisu, bo nie jest uczestnikiem: przedstawia
// sie tym samym JWT co przy zwyklych zadaniach panelu
connectHeaders: { 'X-Panel-Token': getToken() ?? '' },
reconnectDelay: 4000,
onConnect: () => {
setConnected(true)
stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => {
const event = JSON.parse(message.body) as { kind: string; payload: InteractionView }
apply(event)
})
/*
* Kolejka glosu odswieza sie ze zdarzenia, a nie z odpytywania serwera:
* prowadzacy ma zobaczyc podniesiona reke od razu, a nie po minucie.
*/
stomp.subscribe(`/topic/webinars/${id}/speak`, () => {
void loadSpeakQueue()
})
},
onDisconnect: () => setConnected(false),
onWebSocketClose: () => setConnected(false),
})
function apply(event: { kind: string; payload: InteractionView }) {
// zdarzenie WS niesie publiczny widok bez danych moderacji autora.
// Uzupelniamy je do widoku moderatora wartosciami domyslnymi: przyciski
// moderacji uzyja pelnych danych po najblizszym odswiezeniu listy (robimy
// je po kazdej akcji mute/ban), a nowa wiadomosc i tak jest widoczna od razu.
const item: ModeratorInteractionView = {
...event.payload,
authorRegistrationId: null,
authorChatMuted: false,
authorRoomBanned: false,
}
// zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo
// w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym
if (item.type === 'OFFER_REQUEST') {
setNewOffers((n) => n + 1)
// referencja, a nie stan: efekt WebSocketu widzi wartosc z chwili
// podlaczenia, wiec przelacznik nie dzialalby bez zerwania polaczenia
if (soundRef.current) playChime()
return
}
if (item.type === 'CHAT') {
if (event.kind === 'REJECTED') {
setChat((current) => current.filter((c) => c.id !== item.id))
return
}
setChat((current) =>
current.some((c) => c.id === item.id) ? current : [...current, item])
setNewChat((n) => n + 1)
return
}
setQuestions((current) => {
const exists = current.some((q) => q.id === item.id)
if (!exists) {
setNewQuestions((n) => n + 1)
return [item, ...current]
}
return current.map((q) => (q.id === item.id ? item : q))
})
}
stomp.activate()
return () => {
void stomp.deactivate()
}
}, [id, loadSpeakQueue])
async function act(interactionId: number, action: string, body?: unknown) {
try {
await client.post(`/admin/webinars/${id}/interactions/${interactionId}/${action}`, body ?? {})
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się wykonać operacji'))
}
}
/**
* Wyciszenie/przywrocenie glosu albo blokada/odblokada uczestnika.
*
* Po akcji odswiezamy liste czatu, zeby przyciski przy KAZDEJ wiadomosci tego
* autora pokazaly aktualny stan (zdarzenie WS niesie tylko publiczny widok
* bez danych moderacji). To pojedyncze odswiezenie, nie odpytywanie w petli.
*/
async function moderateParticipant(
registrationId: number,
kind: 'mute' | 'ban',
value: boolean,
) {
try {
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
value,
})
const fresh = await initial.refetch()
if (fresh.data) {
setChat(fresh.data.chat)
setQuestions(fresh.data.questions)
}
toasts.success(
kind === 'mute'
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
)
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się wykonać operacji'))
}
}
if (webinar.isPending || initial.isPending) return <Spinner />
if (webinar.isError) {
return <ErrorState message={errorMessage(webinar.error, 'Nie udało się wczytać webinaru')} />
}
const pending = questions.filter((q) => q.status === 'NEW')
const published = questions
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
return b.votes - a.votes
})
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Button variant="ghost" icon="arrow-left" onClick={() => navigate(panelPath('/webinary'))}>
Wróć
</Button>
<div>
<h1 className="text-xl font-semibold text-slate-900">Prowadzenie spotkania</h1>
<p className="text-sm text-slate-500">{webinar.data.title}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleSound}
aria-pressed={sound}
title={sound ? 'Wyłącz sygnał dźwiękowy' : 'Włącz sygnał dźwiękowy'}
className={`flex h-11 w-11 items-center justify-center rounded-md transition-colors duration-fast ease-standard ${
sound
? 'bg-blue-50 text-blue-700 hover:bg-blue-100'
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
}`}
>
<Icon name={sound ? 'volume' : 'volume-off'} size={18} />
</button>
<Badge tone={connected ? 'ok' : 'warn'}>
{connected ? 'połączono' : 'łączenie...'}
</Badge>
</div>
</div>
{newOffers > 0 && (
<Link
to={panelPath('/zapytania')}
className="flex items-center gap-2 rounded-lg border border-blue-300 bg-blue-50 p-3 text-sm text-blue-900 hover:bg-blue-100"
>
<Icon name="send" size={16} />
<span className="font-medium">
{newOffers === 1
? 'Nowe zapytanie ofertowe'
: `Nowe zapytania ofertowe: ${newOffers}`}
</span>
<span className="ml-auto text-xs">Przejdź do listy</span>
</Link>
)}
{/* trzy kolumny od xl, dwie od lg, jedna pod spodem na wezszym ekranie */}
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<SpeakQueue
webinarId={id ?? ''}
requests={speakRequests}
openMic={openMic}
onOpenMicChange={setOpenMic}
openVideo={openVideo}
onOpenVideoChange={setOpenVideo}
onChanged={() => void loadSpeakQueue()}
/>
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
onMouseEnter={() => setNewChat(0)}
>
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
<span className="hx-data text-xs text-slate-400">
{chat.length}
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
</span>
</header>
<ChatColumn
messages={chat}
onReject={(itemId) => act(itemId, 'reject')}
onModerate={moderateParticipant}
/>
</section>
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
onMouseEnter={() => setNewQuestions(0)}
>
<h2 className="text-sm font-semibold text-slate-900">Kolejka pytań</h2>
<span className="hx-data text-xs text-slate-400">
{pending.length} oczekuje
{newQuestions > 0 && <span className="ml-2 text-blue-700">+{newQuestions}</span>}
</span>
</header>
<QuestionsColumn
pending={pending}
published={published}
onAct={act}
/>
</section>
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header className="border-b border-slate-200 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-900">Zadania</h2>
</header>
<TasksColumn webinarId={Number(id)} />
</section>
</div>
</div>
)
}
function ChatColumn({
messages,
onReject,
onModerate,
}: {
messages: ModeratorInteractionView[]
onReject: (id: number) => void
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean) => void
}) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [messages.length])
return (
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{messages.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
)}
{messages.map((message) => (
<div key={message.id} className="group flex items-start gap-2 text-sm">
<div className="min-w-0 flex-1">
<span
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
>
{message.authorName}
</span>
{message.authorChatMuted && (
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
wyciszony
</span>
)}
{message.authorRoomBanned && (
<span className="ml-1.5 rounded bg-alarm-50 px-1.5 py-0.5 text-2xs font-medium text-alarm-600">
zablokowany
</span>
)}
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
{message.authorRegistrationId !== null && (
<>
<button
type="button"
onClick={() =>
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
}
aria-label={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
title={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
className="rounded-md p-1 text-warn-600 hover:bg-warn-50"
>
<Icon name={message.authorChatMuted ? 'mic' : 'mic-off'} size={14} />
</button>
<button
type="button"
onClick={() =>
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned)
}
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
className="rounded-md p-1 text-alarm-600 hover:bg-alarm-50"
>
<Icon name={message.authorRoomBanned ? 'user-plus' : 'user-off'} size={14} />
</button>
</>
)}
<button
type="button"
onClick={() => onReject(message.id)}
aria-label="Usuń wiadomość"
title="Usuń wiadomość"
className="rounded-md p-1 text-alarm-600 hover:bg-alarm-50"
>
<Icon name="trash" size={14} />
</button>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
)
}
function QuestionsColumn({
pending,
published,
onAct,
}: {
pending: ModeratorInteractionView[]
published: ModeratorInteractionView[]
onAct: (id: number, action: string, body?: unknown) => void
}) {
return (
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{pending.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-warn-600">
Oczekuje na zatwierdzenie
</p>
{pending.map((question) => (
<div key={question.id} className="rounded-md border border-warn-500/30 bg-warn-50 p-2.5">
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.content}
</p>
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
<div className="mt-2 flex gap-2">
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
Zatwierdź
</Button>
<Button
size="sm"
variant="ghost"
icon="x"
onClick={() => onAct(question.id, 'reject')}
className="text-alarm-600 hover:bg-alarm-100"
>
Odrzuć
</Button>
</div>
</div>
))}
</div>
)}
{published.length === 0 && pending.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">Nie ma jeszcze pytań.</p>
)}
{published.map((question) => (
<QuestionCard key={question.id} question={question} onAct={onAct} />
))}
</div>
)
}
function QuestionCard({
question,
onAct,
}: {
question: ModeratorInteractionView
onAct: (id: number, action: string, body?: unknown) => void
}) {
const [answering, setAnswering] = useState(false)
const [answer, setAnswer] = useState(question.answer ?? '')
return (
<div
className={`rounded-md border p-2.5 ${
question.pinned ? 'border-blue-300 bg-blue-50' : 'border-slate-200'
}`}
>
<div className="flex items-start gap-2">
<span className="hx-data flex w-10 shrink-0 flex-col items-center rounded-md bg-slate-100 py-1 text-xs text-slate-600">
<Icon name="chevron-up" size={12} />
{question.votes}
</span>
<div className="min-w-0 flex-1">
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.content}
</p>
<p className="mt-0.5 text-xs text-slate-400">{question.authorName}</p>
{question.answer && !answering && (
<div className="mt-2 rounded-md bg-ok-50 p-2">
<p className="text-xs font-medium text-ok-600">Odpowiedziano</p>
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
{question.answer}
</p>
</div>
)}
{answering && (
<div className="mt-2 space-y-2">
<Textarea
rows={2}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Odpowiedź widoczna dla uczestników..."
/>
<div className="flex gap-2">
<Button
size="sm"
icon="check"
disabled={!answer.trim()}
onClick={() => {
onAct(question.id, 'answer', { answer })
setAnswering(false)
}}
>
Zapisz odpowiedź
</Button>
<Button size="sm" variant="ghost" onClick={() => setAnswering(false)}>
Anuluj
</Button>
</div>
</div>
)}
{!answering && (
<div className="mt-2 flex flex-wrap gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => onAct(question.id, 'pin', { pinned: !question.pinned })}
>
{question.pinned ? 'Odepnij' : 'Przypnij'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setAnswering(true)}>
{question.answer ? 'Zmień odpowiedź' : 'Odpowiedz'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onAct(question.id, 'reject')}
className="text-alarm-600 hover:bg-alarm-50"
>
Ukryj
</Button>
</div>
)}
</div>
</div>
</div>
)
}
/**
* Zadania ogloszone w trakcie spotkania.
*
* Formularz jest krotki celowo: prowadzacy oglasza zadanie na zywo, w trakcie
* mowienia, wiec ma miec tytul i ewentualnie limit, a nie pelen formularz.
*/
function TasksColumn({ webinarId }: { webinarId: number }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [title, setTitle] = useState('')
const [capacity, setCapacity] = useState('')
const tasks = useQuery({
queryKey: ['tasks', webinarId],
queryFn: async () => {
const response = await client.get<TaskRow[]>(`/admin/webinars/${webinarId}/tasks`)
return response.data
},
})
const create = useMutation({
mutationFn: () =>
client.post(`/admin/webinars/${webinarId}/tasks`, {
title,
capacity: capacity ? Number(capacity) : null,
}),
onSuccess: () => {
setTitle('')
setCapacity('')
queryClient.invalidateQueries({ queryKey: ['tasks', webinarId] })
},
onError: (error: unknown) => toasts.error(errorMessage(error, 'Nie udało się dodać zadania')),
})
const toggle = useMutation({
mutationFn: ({ id, open }: { id: number; open: boolean }) =>
client.post(`/admin/webinars/${webinarId}/tasks/${id}/open`, { open }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks', webinarId] }),
onError: (error: unknown) => toasts.error(errorMessage(error, 'Nie udało się zmienić zadania')),
})
return (
<>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{tasks.isPending && <Spinner />}
{tasks.data?.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
Nie ogłoszono jeszcze żadnych zadań.
</p>
)}
{tasks.data?.map((task) => (
<div key={task.id} className="rounded-md border border-slate-200 p-2.5">
<p className="text-sm font-medium text-slate-900">{task.title}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="hx-data text-xs text-slate-500">
{task.capacity === null
? `${task.taken} zapisanych`
: `${task.taken} / ${task.capacity}`}
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto"
onClick={() => toggle.mutate({ id: task.id, open: !task.open })}
>
{task.open ? 'Zamknij zapisy' : 'Otwórz zapisy'}
</Button>
</div>
</div>
))}
</div>
<div className="space-y-2 border-t border-slate-200 p-3">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Tytuł zadania"
/>
<div className="flex gap-2">
<Input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
placeholder="Limit miejsc (opcjonalnie)"
/>
<Button
icon="plus"
size="sm"
onClick={() => create.mutate()}
disabled={!title.trim() || create.isPending}
>
Ogłoś
</Button>
</div>
</div>
</>
)
}
type TaskRow = {
id: number
title: string
capacity: number | null
taken: number
open: boolean
}
@@ -0,0 +1,265 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type {
ContactGroup,
Invitation,
Page,
RecipientPreview,
Webinar,
} from '../../api/types'
import { ContentEditor } from '../../components/ContentEditor'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Spinner } from '../../components/ui'
import { formatLocal } from '../../lib/dates'
/**
* Zaproszenia na webinar.
*
* Pierwszy etap sciezki uczestnika: informacja z linkiem do zapisu, dopiero
* potem rejestracja. Odbiorcami sa kontakty z bazy, nie uczestnicy: uczestnik
* powstaje dopiero po kliknieciu w link.
*/
const STATUS_LABEL: Record<string, string> = {
QUEUED: 'W kolejce',
SENT: 'Wysłane',
FAILED: 'Błąd wysyłki',
}
export function InvitationSection({ webinar }: { webinar: Webinar }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [groupIds, setGroupIds] = useState<number[]>([])
const [content, setContent] = useState(webinar.invitationContentHtml ?? '')
const [page, setPage] = useState(0)
const groups = useQuery({
queryKey: ['contact-groups'],
queryFn: async () => {
const response = await client.get<ContactGroup[]>('/admin/contacts/groups')
return response.data
},
})
const invitations = useQuery({
queryKey: ['invitations', webinar.id, page],
queryFn: async () => {
const response = await client.get<Page<Invitation>>(
`/admin/webinars/${webinar.id}/invitations`,
{ params: { page, size: PAGE_SIZE } },
)
return response.data
},
})
/*
* Podglad liczby odbiorcow po odfiltrowaniu wypisanych, bez zgody i juz
* zaproszonych. Tylko taka liczba cos znaczy przed klinieciem "wyslij".
*/
const [recipients, setRecipients] = useState<RecipientPreview | null>(null)
useEffect(() => {
let cancelled = false
client
.post<RecipientPreview>(`/admin/webinars/${webinar.id}/invitations/preview`, {
groupIds,
contactIds: [],
})
.then((response) => {
if (!cancelled) setRecipients(response.data)
})
.catch(() => {
if (!cancelled) setRecipients(null)
})
return () => {
cancelled = true
}
}, [webinar.id, groupIds, invitations.data])
const send = useMutation({
mutationFn: () =>
client.post<{ queued: number; skipped: number }>(
`/admin/webinars/${webinar.id}/invitations`,
{ groupIds, contactIds: [], contentHtml: content },
),
onSuccess: (response) => {
const { queued, skipped } = response.data
const extra = skipped > 0 ? `, pominięto ${skipped} (już zaproszeni)` : ''
toasts.success(`Zakolejkowano ${queued} zaproszeń${extra}`)
queryClient.invalidateQueries({ queryKey: ['invitations', webinar.id] })
queryClient.invalidateQueries({ queryKey: ['stats', webinar.id] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wysłać zaproszeń')),
})
function toggleGroup(groupId: number) {
setGroupIds((current) =>
current.includes(groupId) ? current.filter((x) => x !== groupId) : [...current, groupId])
}
const published = webinar.status === 'PUBLISHED'
return (
<div className="space-y-4">
<Card
title="Wyślij zaproszenia"
description="Odbiorcami są kontakty z bazy, ze zgodą marketingową"
>
<div className="space-y-4">
{!published && (
<Alert tone="warn">
Zaproszenia można wysłać dopiero po opublikowaniu webinaru: odbiorca musi móc
otworzyć stronę zapisu.
</Alert>
)}
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Odbiorcy</label>
{groups.data && groups.data.length > 0 ? (
<div className="flex flex-wrap gap-2">
{groups.data.map((group) => (
<button
key={group.id}
type="button"
onClick={() => toggleGroup(group.id)}
className={`min-h-[36px] rounded-full px-3 text-sm transition-colors duration-fast ease-standard ${
groupIds.includes(group.id)
? 'bg-blue-50 font-medium text-blue-700 ring-1 ring-inset ring-blue-200'
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
}`}
>
{group.name}
<span className="hx-data ml-1.5 text-xs text-slate-400">
{group.contactCount}
</span>
</button>
))}
</div>
) : (
<p className="text-sm text-slate-500">
Nie ma jeszcze żadnej grupy. Zaproszenie pójdzie do wszystkich kontaktów ze zgodą.
</p>
)}
<p className="mt-1.5 text-xs text-slate-500">
{groupIds.length === 0
? 'Nie wybrano grupy: zaproszenie pójdzie do wszystkich kontaktów ze zgodą.'
: `Wybrano ${groupIds.length} ${groupIds.length === 1 ? 'grupę' : 'grup'}.`}
</p>
</div>
{recipients && (
<div className="rounded-md bg-slate-25 p-3 text-sm">
<p className="text-slate-700">
Zaproszenie trafi do{' '}
<span className="hx-data font-semibold text-slate-900">{recipients.toSend}</span>{' '}
{recipients.toSend === 1 ? 'osoby' : 'osób'}.
</p>
<p className="mt-0.5 text-xs text-slate-500">
{recipients.alreadyInvited > 0 && (
<>Pominiętych, bo już dostali zaproszenie: {recipients.alreadyInvited}. </>
)}
W całej bazie zgodę ma {recipients.totalWithConsent} kontaktów. Osoby wypisane
i bez zgody nie są liczone.
</p>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
Treść zaproszenia
</label>
{/* ten sam edytor co tresc strony zapisu */}
<ContentEditor value={content} onChange={setContent} webinarId={webinar.id} />
<p className="mt-1.5 text-xs text-slate-500">
Imię odbiorcy i link do zapisu są dodawane automatycznie. Wiadomość zawiera też
stopkę z odnośnikiem do wypisania, wymaganą przy wiadomościach marketingowych.
</p>
</div>
<div className="flex justify-end">
<Button
icon="send"
disabled={
send.isPending || !published || !content.trim() || (recipients?.toSend ?? 0) === 0
}
onClick={() => {
const count = recipients?.toSend ?? 0
if (confirm(`Wysłać zaproszenie do ${count} odbiorców?`)) {
send.mutate()
}
}}
>
{send.isPending ? 'Wysyłanie...' : 'Wyślij zaproszenia'}
</Button>
</div>
</div>
</Card>
<Card title="Wysłane zaproszenia" description="Kto dostał i kto się po nich zapisał">
{invitations.isPending && <Spinner />}
{invitations.isError && (
<ErrorState message={errorMessage(invitations.error, 'Nie udało się wczytać zaproszeń')} />
)}
{invitations.data && invitations.data.content.length === 0 && (
<EmptyState
icon="send"
title="Brak zaproszeń"
description="Nikt jeszcze nie dostał zaproszenia na ten webinar."
/>
)}
{invitations.data && invitations.data.content.length > 0 && (
<div className="space-y-3">
<ul className="divide-y divide-slate-100">
{invitations.data.content.map((invitation) => (
<li key={invitation.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
<div className="min-w-0">
<p className="font-medium text-slate-900">{invitation.fullName}</p>
<p className="hx-data break-all text-xs text-slate-500">{invitation.email}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{invitation.registeredAt && (
<Badge tone="ok">
<Icon name="check-circle" size={12} className="mr-1" />
Zapisał się
</Badge>
)}
<Badge
tone={
invitation.status === 'SENT'
? 'info'
: invitation.status === 'FAILED'
? 'alarm'
: 'neutral'
}
>
{STATUS_LABEL[invitation.status] ?? invitation.status}
</Badge>
{invitation.sentAt && (
<span className="hx-data text-xs text-slate-400">
{formatLocal(invitation.sentAt)}
</span>
)}
</div>
</li>
))}
</ul>
<Pagination
page={invitations.data.number}
totalPages={invitations.data.totalPages}
totalElements={invitations.data.totalElements}
onChange={setPage}
itemLabel="zaproszeń"
/>
</div>
)}
</Card>
</div>
)
}
@@ -0,0 +1,257 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Page } from '../../api/types'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, EmptyState, ErrorState, Select, Spinner, Textarea } from '../../components/ui'
import { formatLocal } from '../../lib/dates'
import { panelPath } from '../../lib/routes'
/**
* Lista zapytan ofertowych wraz z ich obsluga.
*
* Jeden komponent obsluguje dwa miejsca: zakladke konkretnego webinaru
* (`webinarId` podany) i ekran zbiorczy ze wszystkich webinarow (`webinarId`
* puste). Dzieki temu handlowiec widzi zapytanie tak samo niezaleznie od tego,
* ktora droga do niego trafil, a poprawka w obsludze nie wymaga zmiany
* w dwoch plikach.
*
* Gdy komponent jest osadzony przy webinarze, znika filtr po webinarze
* i nazwa webinaru przy kazdej pozycji: jedno i drugie powtarzaloby to,
* co uzytkownik ma w naglowku strony.
*/
export type OfferView = {
id: number
webinarId: number
webinarTitle: string
content: string
topic: string | null
status: 'NEW' | 'HANDLED' | 'REJECTED' | 'APPROVED' | 'ANSWERED'
handlerNote: string | null
firstName: string | null
lastName: string | null
email: string | null
phone: string | null
company: string | null
consentVersion: string | null
consentAt: string | null
createdAt: string
}
const STATUS_LABEL: Record<string, string> = {
NEW: 'Nowe',
HANDLED: 'Załatwione',
}
export function OfferSection({ webinarId }: { webinarId?: number }) {
const [status, setStatus] = useState('')
const [page, setPage] = useState(0)
const [exporting, setExporting] = useState(false)
const toasts = useToasts()
const offers = useQuery({
queryKey: ['offers', status, webinarId ?? '', page],
queryFn: async () => {
const response = await client.get<Page<OfferView>>('/admin/offers', {
params: {
status: status || undefined,
webinarId: webinarId ?? undefined,
page,
size: PAGE_SIZE,
},
})
return response.data
},
})
async function handleExport() {
setExporting(true)
try {
const response = await client.get('/admin/offers/export', {
params: { status: status || undefined, webinarId: webinarId ?? undefined },
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = 'zapytania.csv'
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
toasts.success('Plik CSV został pobrany')
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się pobrać pliku CSV'))
} finally {
setExporting(false)
}
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="w-full sm:w-48">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Status</label>
<Select
value={status}
onChange={(e) => {
setStatus(e.target.value)
setPage(0)
}}
>
<option value="">Wszystkie</option>
<option value="NEW">Nowe</option>
<option value="HANDLED">Załatwione</option>
</Select>
</div>
<Button icon="download" variant="secondary" onClick={handleExport} disabled={exporting}>
{exporting ? 'Pobieranie...' : 'Eksport CSV'}
</Button>
</div>
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
{offers.isPending && <Spinner />}
{offers.isError && (
<div className="p-5">
<ErrorState message={errorMessage(offers.error, 'Nie udało się wczytać zapytań')} />
</div>
)}
{offers.data && offers.data.content.length === 0 && (
<EmptyState
icon="send"
title="Brak zapytań"
description="Nikt jeszcze nie wysłał zapytania ofertowego."
/>
)}
{offers.data && offers.data.content.length > 0 && (
<ul className="divide-y divide-slate-100">
{offers.data.content.map((offer) => (
<OfferRow key={offer.id} offer={offer} showWebinar={webinarId === undefined} />
))}
</ul>
)}
{offers.data && (
<Pagination
page={offers.data.number}
totalPages={offers.data.totalPages}
totalElements={offers.data.totalElements}
onChange={setPage}
itemLabel="zapytań"
/>
)}
</div>
</div>
)
}
/**
* Jedno zapytanie wraz z obsluga.
*
* `showWebinar` odpowiada za nazwe webinaru: przy zakladce konkretnego
* webinaru jest zbedna, na ekranie zbiorczym niezbedna.
*/
function OfferRow({ offer, showWebinar }: { offer: OfferView; showWebinar: boolean }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [noting, setNoting] = useState(false)
const [note, setNote] = useState(offer.handlerNote ?? '')
const handle = useMutation({
mutationFn: () => client.post(`/admin/offers/${offer.id}/handled`, { note }),
onSuccess: () => {
toasts.success('Zapytanie oznaczone jako załatwione')
queryClient.invalidateQueries({ queryKey: ['offers'] })
// licznik zapytan przy webinarze pochodzi z listy webinarow
queryClient.invalidateQueries({ queryKey: ['webinars'] })
setNoting(false)
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać')),
})
return (
<li className="space-y-2 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-slate-900">
{offer.firstName} {offer.lastName}
{offer.company && <span className="text-slate-500"> - {offer.company}</span>}
</p>
<div className="flex flex-wrap gap-3 text-xs">
<a href={`mailto:${offer.email}`} className="hx-data text-blue-700 hover:underline">
{offer.email}
</a>
{offer.phone && (
<a href={`tel:${offer.phone}`} className="hx-data text-slate-600 hover:underline">
{offer.phone}
</a>
)}
</div>
</div>
<Badge tone={offer.status === 'HANDLED' ? 'ok' : 'warn'}>
{STATUS_LABEL[offer.status] ?? offer.status}
</Badge>
</div>
<p className="whitespace-pre-wrap rounded-md bg-slate-25 p-2.5 text-sm text-slate-700">
{offer.content}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
{showWebinar && (
<Link to={panelPath(`/webinary/${offer.webinarId}`)} className="text-blue-700 hover:underline">
{offer.webinarTitle}
</Link>
)}
{offer.topic && <span>temat: {offer.topic}</span>}
<span className="hx-data">{formatLocal(offer.createdAt)}</span>
{offer.consentVersion && (
<span className="inline-flex items-center gap-1">
<Icon name="check-circle" size={12} className="text-ok-500" />
zgoda {offer.consentVersion}
{offer.consentAt && <span className="hx-data"> z {formatLocal(offer.consentAt)}</span>}
</span>
)}
</div>
{offer.handlerNote && !noting && (
<p className="rounded-md bg-ok-50 p-2 text-xs text-slate-700">
<span className="font-medium text-ok-600">Notatka: </span>
{offer.handlerNote}
</p>
)}
{noting ? (
<div className="space-y-2">
<Textarea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Notatka handlowca, np. wysłano ofertę 12.09"
/>
<div className="flex gap-2">
<Button size="sm" icon="check" onClick={() => handle.mutate()} disabled={handle.isPending}>
Oznacz jako załatwione
</Button>
<Button size="sm" variant="ghost" onClick={() => setNoting(false)}>
Anuluj
</Button>
</div>
</div>
) : (
offer.status !== 'HANDLED' && (
<Button size="sm" variant="secondary" icon="check" onClick={() => setNoting(true)}>
Oznacz jako załatwione
</Button>
)
)}
</li>
)
}
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { client } from '../../api/client'
import type { Page, Webinar } from '../../api/types'
import { Card, Select } from '../../components/ui'
import { OfferSection } from './OfferSection'
/**
* Zapytania ofertowe ze wszystkich webinarow.
*
* Ekran zbiorczy dla handlowca, ktory obsluguje zapytania niezaleznie od tego,
* z ktorego wydarzenia przyszly. Zapytania **jednego** webinaru sa dostepne
* takze jako jego zakladka na /webinary/:id, i to tam prowadzi kazde
* powiazanie z konkretnym wydarzeniem.
*
* Sama lista i obsluga zapytania siedza w OfferSection, wspolnym dla obu
* miejsc. Tutaj dochodzi wylacznie wybor webinaru, ktorego na zakladce
* konkretnego wydarzenia z definicji nie ma.
*/
/** Ile webinarow wchodzi do listy wyboru. Wiecej nie miesci sie w rozwijanej liscie. */
const WEBINAR_CHOICES = 100
export function OffersPage() {
const [webinarId, setWebinarId] = useState('')
/*
* Osobny klucz zapytania, nie ['webinars', ...]. Ta lista ma inne parametry
* niz lista w panelu webinarow, wiec wspolny prefiks podstawialby jednemu
* ekranowi wynik pobrany dla drugiego.
*/
const webinars = useQuery({
queryKey: ['webinars-for-filter'],
queryFn: async () => {
const response = await client.get<Page<Webinar>>('/admin/webinars', {
params: { size: WEBINAR_CHOICES, sort: 'createdAt', direction: 'desc' },
})
return response.data
},
})
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Zapytania ofertowe</h1>
<p className="text-sm text-slate-500">Kontakty handlowe ze wszystkich webinarów</p>
</div>
<Card>
<div className="w-full sm:w-72">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Webinar</label>
<Select value={webinarId} onChange={(e) => setWebinarId(e.target.value)}>
<option value="">Wszystkie</option>
{webinars.data?.content.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</Select>
</div>
</Card>
<OfferSection webinarId={webinarId ? Number(webinarId) : undefined} />
</div>
)
}
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { Modal } from '../../components/Modal'
import { IconButton } from '../../components/ui'
/**
* Pomoc "?" przy wyborze dostawcy wideo W FORMULARZU WEBINARU.
*
* TO JEST POMOC DLA OSOBY ZAKLADAJACEJ WEBINAR, NIE DLA TECHNIKA.
* ---------------------------------------------------------------------------
* Okno wyjasnia, co dany dostawca oznacza w praktyce: czy obraz jest osadzony
* w oknie webinaru, czy uczestnik idzie do zewnetrznego narzedzia, i jaka jest
* sila gwarancji uprawnien. NIE zawiera krokow technicznych (zakladanie kont,
* klucze API, uruchamianie serwera) - te naleza do jednorazowej konfiguracji
* instancji i sa w Ustawienia -> Wideo, dla administratora.
*
* Wczesniej to okno pokazywalo instrukcje infrastrukturalna, przez co osoba
* tworzaca webinar odnosila wrazenie, ze ma sama uruchamiac serwer i wpisywac
* klucze. To bylo mylace: przy webinarze wybiera sie tylko gotowego dostawce.
*/
type Guide = {
label: string
what: string
note: { text: string; tone: 'ok' | 'warn' | 'info' }
}
const GUIDES: Record<string, Guide> = {
LIVEKIT: {
label: 'LiveKit',
what: 'Obraz i dźwięk są osadzone bezpośrednio w oknie webinaru, razem z czatem, pytaniami i banerami. Uczestnik nie wychodzi z platformy.',
note: {
text: 'Najmocniejsze gwarancje: serwer pilnuje, kto może włączyć obraz i mikrofon. Nagrywanie działa automatycznie. Zalecany.',
tone: 'ok',
},
},
ZOOM: {
label: 'Zoom',
what: 'Obraz jest osadzony w oknie webinaru przez Zoom, a czat, pytania i zapytania ofertowe zostają na tej platformie.',
note: {
text: 'Uprawnienia do obrazu i dźwięku steruje ustawieniami całego spotkania, nie osobno dla każdego uczestnika (jak Jitsi, nie jak LiveKit). Wideo uczestników jest wyłączone dla całego spotkania.',
tone: 'warn',
},
},
JITSI: {
label: 'Jitsi Meet',
what: 'Obraz jest osadzony w oknie webinaru przez Jitsi, a czat i pytania zostają na tej platformie.',
note: {
text: 'Na publicznym serwerze Jitsi pokój jest otwarty dla każdego, kto zna adres, a role są tylko umowne. Nagrywanie wymaga ręcznego wgrania pliku.',
tone: 'warn',
},
},
DAILY: {
label: 'Daily.co',
what: 'Obraz jest osadzony w oknie webinaru przez usługę Daily, a czat i pytania zostają na tej platformie.',
note: {
text: 'Uprawnienia egzekwuje serwer. Nagranie pobierane jest automatycznie po zakończeniu.',
tone: 'ok',
},
},
NONE: {
label: 'Bez wbudowanego wideo',
what: 'Obraz idzie zewnętrznym narzędziem (własny link, który wklejasz w formularzu webinaru). Uczestnik otwiera go przyciskiem, a czat, pytania i zapytania ofertowe zostają na tej platformie.',
note: {
text: 'Wybierz, gdy prowadzisz spotkanie we własnym narzędziu. Nagrywanie wymaga ręcznego wgrania pliku.',
tone: 'info',
},
},
}
const TONE_CLASS: Record<'ok' | 'warn' | 'info', string> = {
ok: 'bg-ok-50 text-ok-600 border-ok-500/30',
warn: 'bg-warn-50 text-warn-600 border-warn-500/30',
info: 'bg-info-50 text-info-600 border-info-500/30',
}
/**
* Ikona "?" otwierajaca wyjasnienie dostawcow.
*
* Pokazuje wyjasnienie dla wskazanego dostawcy, z zakladkami do przelaczania
* miedzy wszystkimi. Bez krokow technicznych - te sa w Ustawieniach.
*/
export function ProviderHelp({ current }: { current: string }) {
const [open, setOpen] = useState(false)
const start = GUIDES[current] ? current : 'LIVEKIT'
const [active, setActive] = useState(start)
const guide = GUIDES[active] ?? GUIDES.LIVEKIT
return (
<>
<IconButton
type="button"
icon="help-circle"
label="Co oznacza wybór dostawcy wideo"
onClick={() => {
setActive(GUIDES[current] ? current : 'LIVEKIT')
setOpen(true)
}}
/>
{open && (
<Modal title="Dostawcy wideo - co oznaczają" onClose={() => setOpen(false)}>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{Object.entries(GUIDES).map(([key, g]) => (
<button
key={key}
type="button"
onClick={() => setActive(key)}
className={
'rounded-md border px-3 py-1.5 text-sm transition-colors duration-fast ease-standard ' +
(key === active
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-slate-200 text-slate-600 hover:bg-slate-50')
}
>
{g.label}
</button>
))}
</div>
<p className="text-sm text-slate-600">{guide.what}</p>
<div className={'rounded-md border p-3 text-sm ' + TONE_CLASS[guide.note.tone]}>
{guide.note.text}
</div>
<p className="rounded-md border border-slate-200 bg-slate-25 p-3 text-xs leading-relaxed text-slate-500">
Nie musisz nic konfigurować przy webinarze. Wybierasz gotowego dostawcę z listy
i klikasz „Utwórz pokój". Klucze i uruchomienie dostawcy ustawia administrator raz,
w Ustawienia → Wideo. Jeśli wybrany dostawca nie działa, skontaktuj się z administratorem.
</p>
</div>
</Modal>
)}
</>
)
}
@@ -0,0 +1,248 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Button, Field, Select, Spinner } from '../../components/ui'
import { ProviderHelp } from './ProviderHelp'
/**
* Wybor dostawcy wideo dla pojedynczego webinaru.
*
* DLACZEGO WYBOR JEST TU, A NIE TYLKO W USTAWIENIACH
* ------------------------------------------------------------------------
* Instancja ma jednego dostawcy domyslnego, ale pojedyncze wydarzenie bywa
* inne: szkolenie wewnetrzne na dziesiec osob nie potrzebuje wlasnego serwera,
* a konferencja dla dwustu nie moze isc przez otwarty pokoj Jitsi. Wybor pusty
* oznacza "ten sam co instancja", wiec zmiana ustawienia pociaga za soba
* wszystkie webinary, ktore niczego nie wybraly.
*
* OSTRZEZENIE PRZY JITSI BEZ JWT JEST TU NAJWAZNIEJSZE
* ------------------------------------------------------------------------
* To jedyny wariant, w ktorym **role sa umowne**: pokoj jest otwarty dla
* kazdego, kto zna adres, a ukrycie przycisku kamery to jedyne, co dzieli
* uczestnika od publikacji obrazu. Administrator ma o tym wiedziec przy
* wyborze, a nie dowiedziec sie po webinarze.
*/
type ProviderState = {
/** Kto faktycznie obsluguje ten webinar. */
effective: string
/** Wybor administratora. Null oznacza "z instancji". */
chosen: string | null
/** Dostawca z ustawien instancji. */
instance: string
/** U kogo pokoj faktycznie stoi. Null, gdy pokoju nie ma. */
roomProvider: string | null
/** Czy zmiana wyboru zostawila pokoj u poprzedniego dostawcy. */
needsMigration: boolean
available: string[]
}
const PROVIDER_LABEL: Record<string, string> = {
NONE: 'Bez wbudowanego wideo (własny link)',
LIVEKIT: 'LiveKit',
JITSI: 'Jitsi Meet',
DAILY: 'Daily.co',
ZOOM: 'Zoom',
}
/** Co gwarantuje serwer, a co jest tylko interfejsem. */
const PROVIDER_NOTE: Record<string, { text: string; tone: 'ok' | 'warn' | 'info' }> = {
LIVEKIT: {
text: 'Uprawnienia egzekwuje serwer na podstawie podpisanego tokenu. Nagrywanie działa automatycznie.',
tone: 'ok',
},
DAILY: {
text: 'Uprawnienia egzekwuje serwer. Nagranie jest pobierane od dostawcy po zakończeniu.',
tone: 'ok',
},
JITSI: {
text: 'Serwer egzekwuje wyłącznie rolę moderatora. Nagrywanie wymaga ręcznego wgrania pliku.',
tone: 'warn',
},
ZOOM: {
text: 'Obraz osadzony w oknie webinaru. Uprawnienia do obrazu i dźwięku steruje ustawieniami całego spotkania, nie tokenem uczestnika (jak Jitsi, nie jak LiveKit). Kliknij "?" po instrukcję konfiguracji.',
tone: 'warn',
},
NONE: {
text: 'Obraz idzie zewnętrznym narzędziem. Czat, pytania i zapytania ofertowe zostają na tej platformie.',
tone: 'info',
},
}
export function ProviderPicker({
webinarId,
value,
onChange,
}: {
webinarId: number
/** Pusty ciag oznacza "dostawca z instancji". */
value: string
onChange: (value: string) => void
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const state = useQuery({
queryKey: ['video-provider', webinarId],
queryFn: async () => {
const response = await client.get<ProviderState>(
`/admin/webinars/${webinarId}/video-provider`,
)
return response.data
},
})
const migrate = useMutation({
mutationFn: () => client.post(`/admin/webinars/${webinarId}/video-provider/migrate`),
onSuccess: () => {
toasts.success('Poprzedni pokój został zamknięty')
queryClient.invalidateQueries({ queryKey: ['video-provider', webinarId] })
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinarId)] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zamknąć pokoju')),
})
if (state.isPending) return <Spinner />
const data = state.data
const effective = value || data?.instance || 'NONE'
const note = PROVIDER_NOTE[effective]
return (
<div className="space-y-3">
<Field
label="Dostawca wideo"
hint="Puste oznacza dostawcę z ustawień instancji. Zmiana dotyczy tylko tego webinaru."
badge={<ProviderHelp current={effective} />}
>
<Select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">
Domyślny z instancji ({PROVIDER_LABEL[data?.instance ?? 'NONE'] ?? data?.instance})
</option>
{(data?.available ?? []).map((name) => (
<option key={name} value={name}>
{PROVIDER_LABEL[name] ?? name}
</option>
))}
</Select>
</Field>
{note && (
<p
className={`rounded-md border p-3 text-xs leading-relaxed ${
note.tone === 'ok'
? 'border-ok-500/20 bg-ok-50 text-ok-600'
: note.tone === 'warn'
? 'border-warn-500/20 bg-warn-50 text-warn-600'
: 'border-slate-200 bg-slate-25 text-slate-600'
}`}
>
{note.text}
</p>
)}
{/*
Jitsi bez JWT: pokoj otwarty, role umowne. To nie jest szczegol
konfiguracji, tylko zupelnie inna sila gwarancji, wiec ostrzezenie
jest osobne i wyrazniejsze niz zwykla notka o dostawcy.
*/}
{effective === 'JITSI' && <JitsiWarning />}
{/*
Pokoj stoi u poprzedniego dostawcy. Zamkniecie jest nieodwracalne,
a uczestnicy z linkiem sprzed zmiany trafia w puste miejsce, wiec
pytamy wprost, zamiast robic to przy zapisie formularza.
*/}
{data?.needsMigration && (
<div className="rounded-md border border-warn-500/30 bg-warn-50 p-3">
<div className="flex items-start gap-2">
<Icon name="alert-triangle" size={16} className="mt-0.5 shrink-0 text-warn-600" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-warn-600">
Pokój stoi u poprzedniego dostawcy ({PROVIDER_LABEL[data.roomProvider ?? ''] ?? data.roomProvider})
</p>
<p className="mt-1 text-xs leading-relaxed text-slate-600">
Ten webinar ma teraz obsługiwać {PROVIDER_LABEL[data.effective] ?? data.effective}.
Zamknij stary pokój, żeby nie został otwarty i płatny u poprzedniego dostawcy.
Uczestnicy z linkiem sprzed zmiany trafią w puste miejsce.
</p>
<Button
type="button"
size="sm"
variant="secondary"
className="mt-2"
disabled={migrate.isPending}
onClick={() => {
if (
window.confirm(
`Zamknąć pokój u dostawcy ${data.roomProvider}?\n\n` +
'Tej operacji nie da się cofnąć. Nowy pokój założysz przyciskiem poniżej.',
)
) {
migrate.mutate()
}
}}
>
Zamknij stary pokój
</Button>
</div>
</div>
</div>
)}
</div>
)
}
/**
* Ostrzezenie o Jitsi.
*
* Osobny komponent, bo tresc zalezy od tego, czy serwer ma wlaczone JWT,
* a to jest ustawienie instancji, nie webinaru.
*/
function JitsiWarning() {
const settings = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await client.get<{ key: string; value: string | null }[]>('/admin/settings')
return response.data
},
})
const jwtEnabled =
settings.data?.find((s) => s.key === 'JITSI_JWT_ENABLED')?.value === 'true'
if (jwtEnabled) {
return (
<p className="rounded-md border border-slate-200 bg-slate-25 p-3 text-xs leading-relaxed text-slate-600">
Token JWT jest włączony, więc serwer sprawdza tożsamość uczestników i egzekwuje rolę
moderatora. Jitsi nie rozróżnia jednak uprawnienia „tylko dźwięk”: różnica między
uczestnikiem a uczestnikiem z głosem jest widoczna wyłącznie w interfejsie.
</p>
)
}
return (
<div className="rounded-md border border-alarm-500/30 bg-alarm-50 p-3">
<div className="flex items-start gap-2">
<Icon name="alert-triangle" size={16} className="mt-0.5 shrink-0 text-alarm-600" />
<div>
<p className="text-sm font-medium text-alarm-600">
Pokój będzie otwarty, a role tylko umowne
</p>
<p className="mt-1 text-xs leading-relaxed text-slate-700">
Token JWT jest wyłączony, więc <strong>każdy, kto zna adres pokoju, wejdzie do
środka</strong> — także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
jedyne zabezpieczenie, a wystarczy otworzyć ten sam pokój bezpośrednio na serwerze
Jitsi, żeby je obejść.
</p>
<p className="mt-2 text-xs leading-relaxed text-slate-700">
Jeśli potrzebujesz gwarancji po stronie serwera, włącz JWT w ustawieniach wideo
(wymaga własnego serwera Jitsi) albo wybierz <strong>LiveKit</strong>, gdzie
uprawnienia są w podpisanym tokenie.
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,395 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { ConsentType, Page, Registration, RegistrationStatus, Webinar } from '../../api/types'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, EmptyState, ErrorState, Input, Select, Spinner } from '../../components/ui'
import { formatWithZone } from '../../lib/dates'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { SortableHeader, type SortState } from '../../components/SortableHeader'
import {
CONSENT_LABEL,
REGISTRATION_STATUS_LABEL,
REGISTRATION_STATUS_TONE,
} from '../../lib/labels'
import { panelPath } from '../../lib/routes'
const CONSENT_ORDER: ConsentType[] = ['TERMS', 'MARKETING_EMAIL', 'MARKETING_SMS']
export function RegistrationListPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const toasts = useToasts()
const [status, setStatus] = useState<RegistrationStatus | ''>('')
const [search, setSearch] = useState('')
/** Domyslnie najnowsze zapisy na gorze. */
const [sort, setSort] = useState<SortState>({ field: 'createdAt', direction: 'desc' })
const [page, setPage] = useState(0)
const [exporting, setExporting] = useState(false)
const webinar = useQuery({
queryKey: ['webinar', id],
queryFn: async () => {
const response = await client.get<Webinar>(`/admin/webinars/${id}`)
return response.data
},
})
const registrations = useQuery({
queryKey: ['registrations', id, status, search, sort.field, sort.direction, page],
queryFn: async () => {
const response = await client.get<Page<Registration>>(
`/admin/webinars/${id}/registrations`,
{
params: {
status: status || undefined,
search: search || undefined,
sort: sort.field,
direction: sort.direction,
page,
size: PAGE_SIZE,
},
},
)
return response.data
},
})
/** Zmiana filtru cofa na pierwsza strone, inaczej wynik bywa pusty. */
function changeSort(next: SortState) {
setSort(next)
setPage(0)
}
function changeStatus(next: RegistrationStatus | '') {
setStatus(next)
setPage(0)
}
/**
* Eksport idzie przez axios, a nie zwyklym linkiem, bo endpoint wymaga
* naglowka Authorization. Plik pobieramy jako blob i zapisujemy po stronie przegladarki.
*/
async function handleExport() {
setExporting(true)
try {
const response = await client.get(`/admin/webinars/${id}/registrations/export`, {
responseType: 'blob',
})
const url = URL.createObjectURL(response.data as Blob)
const link = document.createElement('a')
link.href = url
link.download = `zapisy-${webinar.data?.slug ?? id}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
toasts.success('Plik CSV został pobrany')
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się pobrać pliku CSV'))
} finally {
setExporting(false)
}
}
/** Najnowsza zgoda danego typu. Ponowny zapis tworzy nowe rekordy, stare zostaja. */
function latestConsent(registration: Registration, type: ConsentType) {
return registration.consents
.filter((consent) => consent.type === type)
.sort((a, b) => b.grantedAt.localeCompare(a.grantedAt))[0]
}
const timezone = webinar.data?.timezone ?? 'Europe/Warsaw'
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Button variant="ghost" icon="arrow-left" onClick={() => navigate(panelPath('/webinary'))}>
Wróć
</Button>
<div>
<h1 className="text-xl font-semibold text-slate-900">
Zapisani {webinar.data ? `- ${webinar.data.title}` : ''}
</h1>
{webinar.data && (
<p className="text-sm text-slate-500">
{formatWithZone(webinar.data.startsAt, webinar.data.timezone)}
{' | '}
{webinar.data.registeredCount}
{webinar.data.capacity !== null
? ` z ${webinar.data.capacity} miejsc`
: ' zapisanych, bez limitu'}
</p>
)}
</div>
</div>
<Button icon="download" onClick={handleExport} disabled={exporting} variant="secondary">
{exporting ? 'Pobieranie...' : 'Eksport CSV'}
</Button>
</div>
<Card>
<div className="flex flex-wrap items-end gap-3">
<div className="w-full sm:w-72">
<label className="mb-1 block text-sm font-semibold text-slate-800">Szukaj</label>
<Input
value={search}
placeholder="Nazwisko, imię albo adres e-mail"
onChange={(e) => {
setSearch(e.target.value)
setPage(0)
}}
/>
</div>
<div className="w-full sm:w-56">
<label className="mb-1 block text-sm font-semibold text-slate-800">Status zapisu</label>
<Select value={status} onChange={(e) => changeStatus(e.target.value as RegistrationStatus | '')}>
<option value="">Wszystkie</option>
{(Object.keys(REGISTRATION_STATUS_LABEL) as RegistrationStatus[]).map((value) => (
<option key={value} value={value}>
{REGISTRATION_STATUS_LABEL[value]}
</option>
))}
</Select>
</div>
</div>
</Card>
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
{registrations.isPending && <Spinner />}
{registrations.isError && (
<div className="p-5">
<ErrorState message={errorMessage(registrations.error, 'Nie udało się wczytać listy zapisanych')} />
</div>
)}
{registrations.data && registrations.data.content.length === 0 && (
<EmptyState
icon="user-off"
title="Brak zapisanych"
description={status ? 'Zmień filtr statusu.' : 'Nikt jeszcze nie zapisał się na ten webinar.'}
/>
)}
{registrations.data && registrations.data.content.length > 0 && (
<>
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
<ul className="divide-y divide-slate-100 lg:hidden">
{registrations.data.content.map((registration) => (
<li key={registration.id} className="space-y-2.5 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</p>
<p className="truncate text-sm text-slate-600">{registration.email}</p>
{registration.company && (
<p className="truncate text-xs text-slate-500">{registration.company}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
{REGISTRATION_STATUS_LABEL[registration.status]}
</Badge>
{registration.attended && (
<Badge tone="ok" dot>
Obecny
</Badge>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<p className="hx-data text-xs text-slate-500">
{formatWithZone(registration.createdAt, timezone)}
</p>
<RoomRoleToggle webinarId={Number(id)} registration={registration} />
</div>
<div className="flex flex-wrap items-center gap-3 text-xs">
{CONSENT_ORDER.map((type) => {
const consent = latestConsent(registration, type)
return (
<span key={type} className="inline-flex items-center gap-1 text-slate-600">
<Icon
name={consent?.granted ? 'check-circle' : 'x-circle'}
size={14}
className={consent?.granted ? 'text-ok-500' : 'text-slate-300'}
/>
{CONSENT_LABEL[type]}
</span>
)
})}
{registration.remindersSent > 0 && (
<span className="inline-flex items-center gap-1 text-slate-600">
<Icon name="send" size={14} className="text-ok-500" />
<span className="hx-data">{registration.remindersSent}</span>
</span>
)}
</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>
<SortableHeader field="lastName" sort={sort} onSort={changeSort}>
Uczestnik
</SortableHeader>
<SortableHeader field="email" sort={sort} onSort={changeSort}>
Kontakt
</SortableHeader>
<th className="px-4 py-3 font-semibold">Status</th>
<SortableHeader field="attended" sort={sort} onSort={changeSort}>
Obecność
</SortableHeader>
<th className="px-4 py-3 font-semibold">Głos</th>
<SortableHeader field="createdAt" sort={sort} onSort={changeSort}>
Data zapisu
</SortableHeader>
<th className="px-4 py-3 text-center font-semibold">Przypomnienia</th>
{CONSENT_ORDER.map((type) => (
<th key={type} className="px-4 py-3 text-center font-semibold">
{CONSENT_LABEL[type]}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{registrations.data.content.map((registration) => (
<tr key={registration.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</p>
{registration.company && (
<p className="text-xs text-slate-500">{registration.company}</p>
)}
</td>
<td className="px-4 py-3 text-slate-600">
<p>{registration.email}</p>
{registration.phone && <p className="text-xs text-slate-500">{registration.phone}</p>}
</td>
<td className="px-4 py-3">
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
{REGISTRATION_STATUS_LABEL[registration.status]}
</Badge>
</td>
<td className="px-4 py-3">
{registration.attended ? (
<Badge tone="ok" dot>
Obecny
</Badge>
) : (
<span className="text-xs text-slate-400">brak</span>
)}
</td>
<td className="px-4 py-3">
<RoomRoleToggle webinarId={Number(id)} registration={registration} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
{formatWithZone(registration.createdAt, timezone)}
</td>
<td className="px-4 py-3 text-center">
{registration.remindersSent > 0 ? (
<span className="inline-flex items-center gap-1 text-sm text-slate-700">
<Icon name="send" size={16} className="text-ok-500" />
{registration.remindersSent}
</span>
) : (
<span className="text-slate-300">-</span>
)}
</td>
{CONSENT_ORDER.map((type) => {
const consent = latestConsent(registration, type)
return (
<td key={type} className="px-4 py-3 text-center">
{consent ? (
<div className="flex flex-col items-center">
<span title={consent.granted ? 'Zgoda udzielona' : 'Brak zgody'}>
<Icon
name={consent.granted ? 'check-circle' : 'x-circle'}
size={18}
className={consent.granted ? 'text-ok-500' : 'text-slate-300'}
/>
</span>
<span className="text-[10px] text-slate-400">w. {consent.contentVersion}</span>
</div>
) : (
<span className="text-slate-300">-</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{registrations.data && (
<Pagination
page={registrations.data.number}
totalPages={registrations.data.totalPages}
totalElements={registrations.data.totalElements}
onChange={setPage}
itemLabel="zapisanych"
/>
)}
</div>
</div>
)
}
/**
* Podniesienie i odebranie glosu uczestnikowi.
*
* SPEAKER publikuje **wylacznie dzwiek**: wideo uczestnika jest wylaczone
* uprawnieniami w tokenie i nie da sie go wlaczyc z panelu. Zmiana dziala
* przy nastepnym wejsciu do pokoju, bo token jest wydawany przy wejsciu.
*/
function RoomRoleToggle({
webinarId,
registration,
}: {
webinarId: number
registration: Registration
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const speaking = registration.roomRole === 'SPEAKER'
const change = useMutation({
mutationFn: (role: 'VIEWER' | 'SPEAKER') =>
client.put(`/admin/webinars/${webinarId}/registrations/${registration.id}/room-role`, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['registrations'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić roli')),
})
return (
<button
type="button"
onClick={() => change.mutate(speaking ? 'VIEWER' : 'SPEAKER')}
disabled={change.isPending}
title={speaking ? 'Odbierz głos' : 'Daj głos (tylko dźwięk)'}
className={`inline-flex min-h-[36px] items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors duration-fast ease-standard ${
speaking
? 'bg-ok-50 text-ok-600 hover:bg-ok-100'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
<Icon name={speaking ? 'check' : 'x'} size={14} />
{speaking ? 'ma głos' : 'słuchacz'}
</button>
)
}
@@ -0,0 +1,193 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { NotificationChannel, ReminderRule } from '../../api/types'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Input, Select, Spinner } from '../../components/ui'
import { formatWithZone } from '../../lib/dates'
import { describeOffset, REMINDER_PRESETS as PRESETS } from '../../lib/reminders'
export function ReminderSection({ webinarId, timezone }: { webinarId: number; timezone: string }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const [offset, setOffset] = useState('1440')
const [channel, setChannel] = useState<NotificationChannel>('EMAIL')
const rules = useQuery({
queryKey: ['reminders', webinarId],
queryFn: async () => {
const response = await client.get<ReminderRule[]>(`/admin/webinars/${webinarId}/reminders`)
return response.data
},
})
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['reminders', webinarId] })
}
const add = useMutation({
mutationFn: () =>
client.post(`/admin/webinars/${webinarId}/reminders`, {
offsetMinutes: Number(offset),
channel,
template: 'reminder',
}),
onSuccess: () => {
toasts.success('Reguła została dodana')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać reguły')),
})
const toggle = useMutation({
mutationFn: ({ id, value }: { id: number; value: boolean }) =>
client.post(`/admin/webinars/${webinarId}/reminders/${id}/enabled?value=${value}`),
onSuccess: () => invalidate(),
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić reguły')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/webinars/${webinarId}/reminders/${id}`),
onSuccess: () => {
toasts.success('Reguła została usunięta')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć reguły')),
})
return (
<Card
title="Przypomnienia"
description="Wiadomości wysyłane automatycznie przed startem webinaru"
>
{rules.isPending && <Spinner />}
{rules.isError && (
<ErrorState message={errorMessage(rules.error, 'Nie udało się wczytać reguł')} />
)}
{rules.data && (
<div className="space-y-4">
{rules.data.length === 0 ? (
<p className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-500">
Brak reguł. Ten webinar nie wyśle żadnych przypomnień.
</p>
) : (
<ul className="divide-y divide-slate-100 rounded-md border border-slate-200">
{rules.data.map((rule) => (
<li key={rule.id} className="flex flex-wrap items-center gap-3 p-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={rule.enabled}
onChange={(e) => toggle.mutate({ id: rule.id, value: e.target.checked })}
className="h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
aria-label={rule.enabled ? 'Wyłącz regułę' : 'Włącz regułę'}
/>
</label>
<div className="min-w-0 flex-1">
<p className={`text-sm font-medium ${rule.enabled ? 'text-slate-900' : 'text-slate-400'}`}>
{describeOffset(rule.offsetMinutes)}
<span className="ml-2 text-xs font-normal text-slate-500">
{rule.channel === 'SMS' ? 'SMS' : 'e-mail'}
</span>
</p>
<p className="text-xs text-slate-500">
{formatWithZone(rule.scheduledAt, timezone)}
</p>
</div>
{/* liczniki pokazuja, co juz poszlo z tej reguly */}
<div className="flex shrink-0 gap-1.5">
{rule.sentCount > 0 && (
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">
wysłano {rule.sentCount}
</Badge>
)}
{rule.queuedCount > 0 && (
<Badge className="bg-warn-50 text-warn-600 ring-warn-500/25">
w kolejce {rule.queuedCount}
</Badge>
)}
{rule.failedCount > 0 && (
<Badge className="bg-alarm-50 text-alarm-600 ring-alarm-500/20">
błędy {rule.failedCount}
</Badge>
)}
</div>
<Button
type="button"
variant="ghost"
icon="trash"
onClick={() => {
const extra = rule.alreadySent
? '\n\nZ tej reguły wysłano już wiadomości. Historia wysyłek zostanie usunięta.'
: ''
if (window.confirm(`Usunąć regułę "${describeOffset(rule.offsetMinutes)}"?${extra}`)) {
remove.mutate(rule.id)
}
}}
className="shrink-0 text-alarm-600 hover:bg-alarm-50"
title="Usuń regułę"
>
<span className="sr-only">Usuń</span>
</Button>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-end gap-2 border-t border-slate-100 pt-4">
<div className="w-44">
<label className="mb-1 block text-xs font-semibold text-slate-700">Kiedy wysłać</label>
<Select value={offset} onChange={(e) => setOffset(e.target.value)}>
{PRESETS.map((preset) => (
<option key={preset.minutes} value={preset.minutes}>
{preset.label}
</option>
))}
</Select>
</div>
<div className="w-28">
<label className="mb-1 block text-xs font-semibold text-slate-700">Kanał</label>
<Select
value={channel}
onChange={(e) => setChannel(e.target.value as NotificationChannel)}
>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</Select>
</div>
<div className="w-32">
<label className="mb-1 block text-xs font-semibold text-slate-700">Własny odstęp</label>
<Input
type="number"
min={1}
value={offset}
onChange={(e) => setOffset(e.target.value)}
placeholder="minuty"
/>
</div>
<Button
type="button"
icon="plus"
variant="secondary"
onClick={() => add.mutate()}
disabled={add.isPending || !offset || Number(offset) < 1}
>
Dodaj
</Button>
</div>
{rules.data.some((rule) => rule.channel === 'SMS') && (
<p className="rounded-md border border-blue-200 bg-blue-50 p-3 text-xs leading-relaxed text-blue-900">
SMS trafi wyłącznie do uczestników, którzy podali numer telefonu
i wyrazili zgodę marketingową na SMS.
</p>
)}
</div>
)}
</Card>
)
}
+353
View File
@@ -0,0 +1,353 @@
import { useEffect, useState } from 'react'
import { client, errorMessage } from '../../api/client'
import { Icon } from '../../components/Icon'
import { Modal } from '../../components/Modal'
import { useToasts } from '../../components/Toasts'
import { Button } from '../../components/ui'
/**
* Kolejka zgloszen do zabrania glosu, po stronie prowadzacego.
*
* KOLEJNOSC JEST REGULA, NIE ESTETYKA
* ------------------------------------------------------------------------
* Najstarsze zgloszenie na gorze: kto pierwszy podniosl reke, ten pierwszy
* dostaje glos. Sortowanie po nazwisku albo losowe sprawialoby, ze uczestnik
* czekajacy piec minut widzi, jak ktos zgloszony przed chwila mowi przed nim.
* Dlatego pokazujemy takze, **od kiedy** ktos czeka.
*
* MAJACY GLOS ZOSTAJE NA LISCIE
* ------------------------------------------------------------------------
* Osoba, ktorej przyznano glos, nie znika z kolejki, tylko zmienia stan.
* Prowadzacy musi miec jedno miejsce, w ktorym odbiera glos, a nie szukac
* po liscie zapisanych.
*/
export type SpeakRequestView = {
id: number
registrationId: number
participantName: string
status: 'PENDING' | 'GRANTED' | 'REVOKED' | 'WITHDRAWN' | 'DECLINED'
createdAt: string
resolvedAt: string | null
}
export function SpeakQueue({
webinarId,
requests,
openMic,
onOpenMicChange,
openVideo,
onOpenVideoChange,
onChanged,
}: {
webinarId: string
requests: SpeakRequestView[]
/** Czy kazdy uczestnik moze mowic bez proszenia o glos. */
openMic: boolean
onOpenMicChange: (enabled: boolean) => void
/** Czy kazdy uczestnik moze wlaczyc kamere (wideo dla wszystkich). */
openVideo: boolean
onOpenVideoChange: (enabled: boolean) => void
onChanged: () => void
}) {
const toasts = useToasts()
const [busy, setBusy] = useState<number | null>(null)
const [switching, setSwitching] = useState(false)
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
const [confirmOpen, setConfirmOpen] = useState(false)
/**
* Przelaczenie trybu otwartego mikrofonu.
*
* Wlaczenie pytamy o potwierdzenie, wylaczenie nie. Otwarcie mikrofonow
* calej sali jest slyszalne natychmiast dla wszystkich i trudno to cofnac
* po fakcie: ktos zdazy powiedziec cos przy wlaczonym mikrofonie, nie
* wiedzac, ze jest slyszany. Zamkniecie mikrofonow nikomu nie szkodzi.
*
* Potwierdzenie wlaczenia idzie oknem modalnym (spojnym z reszta panelu),
* a nie natywnym window.confirm: to operacja o skutku slyszalnym dla calej
* sali, wiec pytanie ma wygladac jak swiadoma decyzja, a nie systemowy alert.
*/
function toggleOpenMic() {
if (!openMic) {
setConfirmOpen(true)
return
}
void applyOpenMic(false)
}
async function applyOpenMic(enabled: boolean) {
setSwitching(true)
try {
await client.put(`/admin/webinars/${webinarId}/speak-requests/open-mic`, {
openMic: enabled,
})
onOpenMicChange(enabled)
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić trybu mikrofonu'))
} finally {
setSwitching(false)
}
}
/**
* Przelaczenie trybu "wideo dla wszystkich".
*
* Wlaczenie daje kazdemu uczestnikowi prawo do kamery. Nie pytamy o
* potwierdzenie: wlaczenie samo w sobie niczego nie otwiera - uczestnik
* musi swiadomie wlaczyc kamere. To inaczej niz otwarty mikrofon, gdzie
* ktos moze byc slyszany, nie wiedzac o tym.
*/
async function toggleOpenVideo() {
setSwitching(true)
try {
await client.put(`/admin/webinars/${webinarId}/speak-requests/open-video`, {
openVideo: !openVideo,
})
onOpenVideoChange(!openVideo)
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić trybu wideo'))
} finally {
setSwitching(false)
}
}
async function act(requestId: number, kind: 'grant' | 'revoke') {
setBusy(requestId)
try {
if (kind === 'grant') {
await client.post(`/admin/webinars/${webinarId}/speak-requests/${requestId}/grant`)
} else {
await client.delete(`/admin/webinars/${webinarId}/speak-requests/${requestId}`)
}
onChanged()
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień'))
} finally {
setBusy(null)
}
}
const waiting = requests.filter((r) => r.status === 'PENDING')
const speaking = requests.filter((r) => r.status === 'GRANTED')
return (
<>
{confirmOpen && (
<Modal title="Włączyć otwarty mikrofon?" onClose={() => setConfirmOpen(false)}>
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md border border-warn-500/30 bg-warn-50 p-3">
<Icon name="mic" size={20} className="mt-0.5 shrink-0 text-warn-600" />
<p className="text-sm leading-relaxed text-warn-600">
Każdy uczestnik będzie mógł mówić od razu, <strong>bez zgłaszania się</strong>.
Przy dużej sali oznacza to wiele otwartych mikrofonów naraz - słychać je
natychmiast i trudno to cofnąć po fakcie.
</p>
</div>
<p className="text-sm text-slate-600">
Ten tryb sprawdza się na warsztatach dla kilkunastu osób. Kolejkę zgłoszeń
możesz w każdej chwili przywrócić, wyłączając otwarty mikrofon.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setConfirmOpen(false)}>
Anuluj
</Button>
<Button
type="button"
icon="mic"
disabled={switching}
onClick={() => {
setConfirmOpen(false)
void applyOpenMic(true)
}}
>
Włącz otwarty mikrofon
</Button>
</div>
</div>
</Modal>
)}
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<header className="space-y-2 border-b border-slate-200 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold text-slate-900">Zgłoszenia do głosu</h2>
{waiting.length > 0 && !openMic && (
<span className="hx-data inline-flex min-w-[20px] items-center justify-center rounded-full bg-warn-500 px-1.5 text-xs font-semibold text-white">
{waiting.length}
</span>
)}
</div>
<button
type="button"
onClick={toggleOpenMic}
disabled={switching}
aria-pressed={openMic}
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
openMic
? 'border-ok-500/30 bg-ok-50 text-ok-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
<Icon name={openMic ? 'mic' : 'mic-off'} size={16} className="shrink-0" />
<span className="min-w-0 flex-1">
<span className="block font-medium">
{openMic ? 'Otwarty mikrofon włączony' : 'Otwarty mikrofon wyłączony'}
</span>
<span className="block text-xs opacity-80">
{openMic
? 'Każdy mówi bez zgłaszania się'
: 'Uczestnicy muszą prosić o głos'}
</span>
</span>
</button>
{/*
Wideo dla wszystkich: domyslnie widoczni sa tylko prelegenci, reszta
to ikony. Wlaczenie daje kazdemu prawo do kamery i kafelki rozkladaja
sie rownomiernie. Aktywny mowca i tak trafia na duze okno.
*/}
<button
type="button"
onClick={toggleOpenVideo}
disabled={switching}
aria-pressed={openVideo}
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
openVideo
? 'border-ok-500/30 bg-ok-50 text-ok-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
<Icon name={openVideo ? 'video' : 'video-off'} size={16} className="shrink-0" />
<span className="min-w-0 flex-1">
<span className="block font-medium">
{openVideo ? 'Wideo dla wszystkich włączone' : 'Widoczni tylko prelegenci'}
</span>
<span className="block text-xs opacity-80">
{openVideo
? 'Każdy uczestnik może włączyć kamerę'
: 'Uczestnicy widoczni jako ikony, tylko prelegenci na wideo'}
</span>
</span>
</button>
</header>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{/*
Kolejka zostaje widoczna takze przy otwartym mikrofonie: prowadzacy
moze wylaczyc tryb po dyskusji, a wtedy czekajacy wracaja do gry.
Kasowanie zgloszen przy wlaczeniu trybu odbieraloby miejsce osobom,
ktore czekaja od kwadransa.
*/}
{openMic && requests.length > 0 && (
<p className="rounded-md bg-slate-50 p-2 text-xs text-slate-500">
Mikrofon jest otwarty dla wszystkich, więc zgłoszenia poniżej czekają bezczynnie.
</p>
)}
{requests.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">
{openMic ? 'Mikrofon jest otwarty dla wszystkich.' : 'Nikt nie prosi o głos.'}
</p>
)}
{/* majacy glos na gorze: to nimi prowadzacy zarzadza w pierwszej kolejnosci */}
{speaking.map((request) => (
<div
key={request.id}
className="rounded-md border border-ok-500/30 bg-ok-50 p-2.5"
>
<div className="flex items-center gap-2">
<Icon name="mic" size={16} className="shrink-0 text-ok-600" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{request.participantName}
</p>
<p className="text-xs text-ok-600">
Mówi od {timeOf(request.resolvedAt ?? request.createdAt)}
</p>
</div>
<Button
type="button"
size="sm"
variant="secondary"
icon="mic-off"
disabled={busy === request.id}
onClick={() => act(request.id, 'revoke')}
>
Odbierz
</Button>
</div>
</div>
))}
{waiting.map((request) => (
<div key={request.id} className="rounded-md border border-slate-200 p-2.5">
<div className="flex items-center gap-2">
<Icon name="hand" size={16} className="shrink-0 text-warn-600" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{request.participantName}
</p>
<p className="hx-data text-xs text-slate-500">
czeka od {timeOf(request.createdAt)} (<Waiting since={request.createdAt} />)
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button
type="button"
size="sm"
icon="mic"
disabled={busy === request.id}
onClick={() => act(request.id, 'grant')}
>
Daj głos
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy === request.id}
onClick={() => act(request.id, 'revoke')}
>
Odrzuć
</Button>
</div>
</div>
</div>
))}
</div>
</section>
</>
)
}
/** Sama godzina: prowadzacy patrzy na to w trakcie spotkania, data nic nie wnosi. */
function timeOf(iso: string): string {
return new Date(iso).toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' })
}
/**
* Ile juz czeka, odswiezane co dziesiec sekund.
*
* "czeka od 14:32" nie mowi prowadzacemu nic, dopoki sam nie sprawdzi zegarka.
* "3 min" mowi wprost, ze ktos czeka za dlugo.
*/
function Waiting({ since }: { since: string }) {
const [label, setLabel] = useState(() => elapsed(since))
useEffect(() => {
const timer = window.setInterval(() => setLabel(elapsed(since)), 10_000)
return () => window.clearInterval(timer)
}, [since])
return <>{label}</>
}
function elapsed(iso: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
if (seconds < 60) return 'przed chwilą'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes} min`
return `${Math.floor(minutes / 60)} godz. ${minutes % 60} min`
}
@@ -0,0 +1,276 @@
import { useQuery } from '@tanstack/react-query'
import { BarChart } from '@mui/x-charts/BarChart'
import { LineChart } from '@mui/x-charts/LineChart'
import { client, errorMessage } from '../../api/client'
import type { WebinarStats } from '../../api/types'
import { Icon, type IconName } from '../../components/Icon'
import { Card, ErrorState, Spinner } from '../../components/ui'
/**
* Statystyki webinaru.
*
* Wszystkie liczby przychodza gotowe z backendu, policzone zapytaniami
* agregujacymi. Front niczego nie sumuje: przy kilkuset uczestnikach
* przeliczanie w przegladarce byloby wolniejsze od samego pobrania.
*
* Wykresy z @mui/x-charts, kolory z tokenow HydroNexis podane wprost, bo
* biblioteka nie czyta klas Tailwinda.
*/
/** Kolory wykresow. Odpowiadaja tokenom z tailwind.config.js. */
const HYDRO_AZURE = '#2C8ABD'
const OK = '#16935C'
const ALARM = '#D8392F'
export function StatsSection({ webinarId, timezone }: { webinarId: number; timezone: string }) {
const query = useQuery({
queryKey: ['stats', webinarId],
queryFn: async () => {
const response = await client.get<WebinarStats>(`/admin/webinars/${webinarId}/stats`)
return response.data
},
})
if (query.isPending) return <Spinner />
if (query.isError) {
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać statystyk')} />
}
const stats = query.data
const buckets = stats.durationBuckets
return (
<div className="space-y-4">
{/* Cztery najważniejsze liczby na wierzchu */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Metric icon="users" label="Zapisani" value={stats.registered} />
<Metric icon="check-circle" label="Obecni" value={stats.attended} tone="ok" />
<Metric
icon="presentation"
label="Frekwencja"
value={`${stats.attendanceRate}%`}
tone={stats.attendanceRate >= 50 ? 'ok' : 'warn'}
/>
<Metric icon="send" label="Zapytania ofertowe" value={stats.interactions.offers} />
</div>
<Card title="Obecni w czasie" description="Liczba osób w pokoju, co pięć minut">
{stats.presenceTimeline.length === 0 ? (
<EmptyChart text="Wykres pojawi się po rozpoczęciu webinaru." />
) : (
<div className="overflow-x-auto">
<LineChart
height={260}
series={[
{
data: stats.presenceTimeline.map((p) => p.present),
label: 'Obecni',
color: HYDRO_AZURE,
area: true,
showMark: false,
},
]}
xAxis={[
{
scaleType: 'point',
data: stats.presenceTimeline.map((p) => formatTime(p.moment, timezone)),
},
]}
/* liczba osob jest calkowita, wiec bez ulamkow na osi */
yAxis={[{ min: 0 }]}
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
/>
</div>
)}
</Card>
<Card
title="Jak długo zostawali"
description={`Podział według łącznego czasu obecności`}
>
{stats.attended === 0 ? (
<EmptyChart text="Nikt jeszcze nie był obecny." />
) : (
<div className="overflow-x-auto">
<BarChart
height={240}
series={[
{
data: [buckets.shortStay, buckets.mediumStay, buckets.longStay],
label: 'Uczestnicy',
color: HYDRO_AZURE,
},
]}
xAxis={[
{
scaleType: 'band',
data: [
`krócej niż ${buckets.shortThresholdMinutes} min`,
`${buckets.shortThresholdMinutes}-${buckets.longThresholdMinutes} min`,
`dłużej niż ${buckets.longThresholdMinutes} min`,
],
},
]}
yAxis={[{ min: 0 }]}
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
/>
</div>
)}
</Card>
{/* skutecznosc zaproszen: ilu z zaproszonych faktycznie sie zapisalo */}
{stats.invitations.sent > 0 && (
<Card title="Zaproszenia" description="Ilu z zaproszonych zapisało się na webinar">
<div className="grid grid-cols-3 gap-3">
<Metric icon="mail" label="Wysłane" value={stats.invitations.sent} />
<Metric icon="user-plus" label="Zapisy z zaproszeń" value={stats.invitations.registered} tone="ok" />
<Metric
icon="check-circle"
label="Skuteczność"
value={`${stats.invitations.rate}%`}
tone={stats.invitations.rate >= 20 ? 'ok' : 'warn'}
/>
</div>
</Card>
)}
{/*
Banery: wyswietlenia, klikalnosc i to, co interesuje najbardziej, czyli
ile kontaktow handlowych przyniosla reklama. Sekcja znika, gdy zadnego
banera nie bylo: pusta tabela niczego nie wnosi.
*/}
{stats.banners.length > 0 && (
<Card title="Banery" description="Skuteczność reklam pokazywanych w sali">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="py-2 pr-3 font-semibold">Baner</th>
<th className="px-3 py-2 text-right font-semibold">Wyświetlenia</th>
<th className="px-3 py-2 text-right font-semibold">Kliknięcia</th>
<th className="px-3 py-2 text-right font-semibold">Klikalność</th>
<th className="py-2 pl-3 text-right font-semibold">Zapytania</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{stats.banners.map((banner) => (
<tr key={banner.bannerId}>
<td className="py-2 pr-3 text-slate-700">{banner.title}</td>
<td className="hx-data px-3 py-2 text-right text-slate-600">
{banner.impressions}
</td>
<td className="hx-data px-3 py-2 text-right text-slate-600">{banner.clicks}</td>
<td className="hx-data px-3 py-2 text-right font-medium text-slate-900">
{banner.clickRate.toFixed(1)}%
</td>
<td className="hx-data py-2 pl-3 text-right font-medium text-ok-600">
{banner.offers}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Aktywność uczestników">
<dl className="divide-y divide-slate-100">
<StatRow label="Pytania" value={stats.interactions.questions} />
<StatRow label="Pytania zatwierdzone" value={stats.interactions.questionsApproved} />
<StatRow label="Pytania z odpowiedzią" value={stats.interactions.questionsAnswered} />
<StatRow label="Wiadomości na czacie" value={stats.interactions.chatMessages} />
<StatRow label="Zgłoszenia do zadań" value={stats.interactions.taskSignups} />
<StatRow label="Zapytania ofertowe" value={stats.interactions.offers} />
</dl>
</Card>
<Card title="Wysyłki" description="Maile i SMS-y związane z tym webinarem">
{stats.messages.emailSent + stats.messages.emailFailed +
stats.messages.smsSent + stats.messages.smsFailed === 0 ? (
<EmptyChart text="Nic jeszcze nie zostało wysłane." />
) : (
<div className="overflow-x-auto">
<BarChart
height={220}
series={[
{
data: [stats.messages.emailSent, stats.messages.smsSent],
label: 'Wysłane',
color: OK,
stack: 'total',
},
{
data: [stats.messages.emailFailed, stats.messages.smsFailed],
label: 'Nieudane',
color: ALARM,
stack: 'total',
},
]}
xAxis={[{ scaleType: 'band', data: ['E-mail', 'SMS'] }]}
yAxis={[{ min: 0 }]}
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
/>
</div>
)}
</Card>
</div>
</div>
)
}
function Metric({
icon,
label,
value,
tone = 'neutral',
}: {
icon: IconName
label: string
value: string | number
tone?: 'neutral' | 'ok' | 'warn'
}) {
const toneClass =
tone === 'ok' ? 'text-ok-600' : tone === 'warn' ? 'text-warn-600' : 'text-slate-900'
return (
<div className="rounded-lg border border-slate-200 bg-white p-4">
<div className="flex items-center gap-2 text-slate-500">
<Icon name={icon} size={16} />
<span className="hx-eyebrow">{label}</span>
</div>
<p className={`hx-data mt-1.5 text-2xl font-semibold ${toneClass}`}>{value}</p>
</div>
)
}
function StatRow({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center justify-between py-2">
<dt className="text-sm text-slate-600">{label}</dt>
<dd className="hx-data font-medium text-slate-900">{value}</dd>
</div>
)
}
function EmptyChart({ text }: { text: string }) {
return (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
{text}
</p>
)
}
/** Sama godzina, w strefie webinaru: wykres pokazuje przebieg spotkania. */
function formatTime(iso: string, timezone: string): string {
try {
return new Intl.DateTimeFormat('pl-PL', {
hour: '2-digit',
minute: '2-digit',
timeZone: timezone,
}).format(new Date(iso))
} catch {
return ''
}
}
@@ -0,0 +1,269 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import { Icon } from '../../components/Icon'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, IconButton, Input, Spinner } from '../../components/ui'
import { usePublicUrl } from '../../lib/usePublicConfig'
import { ProviderPicker } from './ProviderPicker'
/**
* Pokoj wideo i prelegenci.
*
* Sekcja wymaga zapisanego webinaru, bo pokoj i prelegenci wisza na jego id.
* W trybie NONE nie ma czego zakladac: link do spotkania wkleja organizator
* w zakladce "Zapisy", a ta sekcja tlumaczy, skad sie bierze.
*/
type Speaker = {
id: number
email: string
fullName: string
accessToken: string
joinPath: string
createdAt: string
}
export function VideoSection({
webinarId,
joinUrl,
videoProvider,
onProviderChange,
}: {
webinarId: number
joinUrl: string
/** Wybor dostawcy dla tego webinaru. Pusty ciag oznacza dostawce instancji. */
videoProvider: string
onProviderChange: (value: string) => void
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const publicUrl = usePublicUrl()
const [email, setEmail] = useState('')
const [fullName, setFullName] = useState('')
const speakers = useQuery({
queryKey: ['speakers', webinarId],
queryFn: async () => {
const response = await client.get<Speaker[]>(`/admin/webinars/${webinarId}/speakers`)
return response.data
},
})
/*
* Stan pokoju u dostawcy. To jest ZRODLO PRAWDY o tym, czy pokoj istnieje,
* a nie prop `joinUrl`: LiveKit i Jitsi NIE zapisuja join_url (pokoj powstaje
* z room_id i room_provider), wiec po ich utworzeniu joinUrl dalej jest pusty.
* `roomProvider` z tego zapytania mowi wprost, u kogo pokoj stoi (null = brak).
* Wspoldzieli klucz z ProviderPicker, wiec nie ma drugiego zapytania.
*/
const providerState = useQuery({
queryKey: ['video-provider', webinarId],
queryFn: async () => {
const response = await client.get<{ effective: string; roomProvider: string | null }>(
`/admin/webinars/${webinarId}/video-provider`,
)
return response.data
},
})
const roomProvider = providerState.data?.roomProvider ?? null
const hasRoom = Boolean(roomProvider) || Boolean(joinUrl)
const createRoom = useMutation({
mutationFn: () => client.post(`/admin/webinars/${webinarId}/room`),
onSuccess: () => {
toasts.success('Pokój został utworzony u dostawcy')
// odswiezamy STAN POKOJU, zeby komunikat i przycisk od razu pokazaly,
// ze pokoj juz jest - bez ponownego wchodzenia w edycje webinaru
queryClient.invalidateQueries({ queryKey: ['video-provider', webinarId] })
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinarId)] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się utworzyć pokoju')),
})
const addSpeaker = useMutation({
mutationFn: () =>
client.post(`/admin/webinars/${webinarId}/speakers`, { email, fullName }),
onSuccess: () => {
toasts.success('Prelegent został dodany')
setEmail('')
setFullName('')
queryClient.invalidateQueries({ queryKey: ['speakers', webinarId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać prelegenta')),
})
const removeSpeaker = useMutation({
mutationFn: (id: number) => client.delete(`/admin/webinars/${webinarId}/speakers/${id}`),
onSuccess: () => {
toasts.success('Prelegent został usunięty')
queryClient.invalidateQueries({ queryKey: ['speakers', webinarId] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć prelegenta')),
})
function copy(text: string) {
navigator.clipboard.writeText(text).then(
() => toasts.success('Link skopiowany'),
() => toasts.error('Nie udało się skopiować linku'),
)
}
return (
<div className="space-y-4">
<Card
title="Pokój wideo"
description="Ten webinar może użyć innego dostawcy niż reszta instancji."
>
<div className="space-y-3">
<ProviderPicker
webinarId={webinarId}
value={videoProvider}
onChange={onProviderChange}
/>
<p className="rounded-md border border-slate-200 bg-slate-25 p-3 text-sm leading-relaxed text-slate-600">
Jeśli instancja działa bez wbudowanego wideo, wystarczy wkleić link do spotkania
w zakładce <span className="font-medium">Zapisy</span>. Uczestnik zostanie tam
przekierowany po kliknięciu linku z maila.
</p>
{providerState.isPending ? (
<p className="flex items-center gap-2 text-sm text-slate-500">
<Spinner /> Sprawdzam stan pokoju...
</p>
) : hasRoom ? (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-ok-500/30 bg-ok-50 p-3 text-sm text-ok-600">
<Icon name="video" size={16} className="text-ok-500" />
{roomProvider
? `Pokój jest utworzony u dostawcy ${roomProvider}. Uczestnicy wejdą przez link z maila potwierdzającego.`
: 'Link do spotkania jest ustawiony. Uczestnicy wejdą przez link z maila potwierdzającego.'}
</div>
) : (
<p className="flex flex-wrap items-center gap-2 text-sm text-warn-600">
<Icon name="alert-triangle" size={16} />
Brak pokoju. Uczestnik nie będzie miał gdzie wejść. Utwórz pokój przyciskiem poniżej.
</p>
)}
<Button
type="button"
variant="secondary"
icon="video"
onClick={() => createRoom.mutate()}
disabled={createRoom.isPending}
>
{createRoom.isPending
? 'Tworzenie...'
: hasRoom
? 'Utwórz pokój ponownie'
: 'Utwórz pokój u dostawcy'}
</Button>
<p className="text-xs leading-relaxed text-slate-500">
Działa tylko, gdy w ustawieniach instancji wybrano dostawcę wideo i podano klucz API.
W trybie bez wideo przycisk wyjaśni, czego brakuje.
</p>
</div>
</Card>
<Card
title="Prelegenci"
description="Osoby prowadzące, które nie zapisują się przez formularz. Wchodzą własnym linkiem, z prawem publikacji obrazu i dźwięku."
>
{speakers.isPending && <Spinner />}
{speakers.isError && (
<ErrorState message={errorMessage(speakers.error, 'Nie udało się wczytać prelegentów')} />
)}
{speakers.data && speakers.data.length === 0 && (
<p className="rounded-md border border-dashed border-slate-300 p-4 text-center text-sm text-slate-500">
Brak prelegentów. Uczestnicy z zapisu mogą dostać głos na liście zapisanych.
</p>
)}
{speakers.data && speakers.data.length > 0 && (
<ul className="space-y-2">
{speakers.data.map((speaker) => {
const link = `${publicUrl}${speaker.joinPath}`
return (
<li
key={speaker.id}
className="flex flex-wrap items-center gap-3 rounded-md border border-slate-200 p-3"
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-slate-900">
{speaker.fullName}
</span>
<span className="hx-data block break-all text-xs text-slate-500">
{speaker.email}
</span>
</span>
<Badge tone="brand">prelegent</Badge>
<Button
type="button"
variant="secondary"
size="sm"
icon="copy"
onClick={() => copy(link)}
disabled={!publicUrl}
title={publicUrl ? link : 'Ustaw publiczny adres instancji w ustawieniach'}
>
Kopiuj link
</Button>
<IconButton
icon="trash"
label={`Usuń prelegenta ${speaker.fullName}`}
variant="ghost"
type="button"
onClick={() => {
if (window.confirm(`Usunąć prelegenta ${speaker.fullName}?`)) {
removeSpeaker.mutate(speaker.id)
}
}}
className="text-alarm-600 hover:bg-alarm-50"
/>
</li>
)
})}
</ul>
)}
<div className="mt-4 space-y-3 rounded-md border border-slate-200 bg-slate-25 p-4">
<p className="text-sm font-medium text-slate-900">Dodaj prelegenta</p>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">Imię i nazwisko</span>
<Input value={fullName} onChange={(e) => setFullName(e.target.value)} placeholder="np. Anna Kowalska" />
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-700">Adres e-mail</span>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="anna@example.pl"
/>
</label>
</div>
<p className="text-xs leading-relaxed text-slate-500">
Adres służy tylko do odróżnienia prelegentów. Zaproszenie wysyłasz sam, kopiując link.
</p>
<Button
type="button"
variant="secondary"
icon="plus"
onClick={() => addSpeaker.mutate()}
disabled={addSpeaker.isPending || !email.trim() || !fullName.trim()}
>
Dodaj prelegenta
</Button>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,637 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Webinar, WebinarRequest } from '../../api/types'
import { useAuth } from '../../auth/AuthContext'
import { useToasts } from '../../components/Toasts'
import { Button, Card, ErrorState, Field, Input, Select, Spinner, Textarea } from '../../components/ui'
import { COMMON_TIMEZONES, isoToZonedInput, zonedInputToIso } from '../../lib/dates'
import { slugify } from '../../lib/labels'
import { usePublicUrl } from '../../lib/usePublicConfig'
import { ReminderSection } from './ReminderSection'
import { ContentEditor } from '../../components/ContentEditor'
import { VideoSection } from './VideoSection'
import { FilesSection } from './FilesSection'
import { FormTabs, type FormTab } from '../../components/FormTabs'
import { Icon } from '../../components/Icon'
import { panelPath } from '../../lib/routes'
type FormState = {
title: string
slug: string
description: string
contentHtml: string
startsAt: string
endsAt: string
timezone: string
registrationOpen: boolean
registrationDeadline: string
capacity: string
joinUrl: string
/** Wybor dostawcy wideo. Pusty ciag oznacza dostawce instancji. */
videoProvider: string
recordingUrl: string
materialsUrl: string
}
/**
* Zakladki formularza.
*
* `fields` sluzy do oznaczenia zakladki kropka, gdy walidacja znajdzie blad
* w ktoryms z jej pol, i do przeskoczenia na pierwsza wadliwa zakladke przy
* probie zapisu. Bez tego blad na nieaktywnej zakladce bylby niewidoczny.
*/
const TABS: FormTab[] = [
{ id: 'podstawowe', label: 'Podstawowe', icon: 'file-text', fields: ['title', 'slug', 'description'] },
{ id: 'tresc', label: 'Treść strony', icon: 'pencil', fields: ['contentHtml'] },
{ id: 'termin', label: 'Termin', icon: 'clock', fields: ['startsAt', 'endsAt', 'timezone'] },
{ id: 'zapisy', label: 'Zapisy', icon: 'users', fields: ['capacity', 'registrationDeadline', 'joinUrl'] },
{
id: 'po-webinarze',
label: 'Po webinarze',
icon: 'video',
fields: ['recordingUrl', 'materialsUrl'],
},
{ id: 'pliki', label: 'Nagrania i materiały', icon: 'video' },
{ id: 'przypomnienia', label: 'Przypomnienia', icon: 'send' },
{ id: 'wideo', label: 'Wideo', icon: 'video' },
]
const EMPTY: FormState = {
title: '',
slug: '',
description: '',
contentHtml: '',
startsAt: '',
endsAt: '',
timezone: 'Europe/Warsaw',
registrationOpen: true,
registrationDeadline: '',
capacity: '',
joinUrl: '',
videoProvider: '',
recordingUrl: '',
materialsUrl: '',
}
export function WebinarFormPage() {
const { id } = useParams<{ id: string }>()
const isEdit = Boolean(id)
const navigate = useNavigate()
const queryClient = useQueryClient()
const toasts = useToasts()
const { isAdmin } = useAuth()
const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Record<string, string>>({})
/** Czy administrator zmienil slug recznie. Jesli nie, podpowiadamy go z tytulu. */
const [slugTouched, setSlugTouched] = useState(false)
/**
* Aktywna zakladka. Stan, a nie trasa: dzieki temu przelaczanie nie gubi
* niezapisanych zmian ani nie zapisuje formularza po kawalku.
*/
const [activeTab, setActiveTab] = useState<string>(TABS[0].id)
const existing = useQuery({
queryKey: ['webinar', id],
enabled: isEdit,
queryFn: async () => {
const response = await client.get<Webinar>(`/admin/webinars/${id}`)
return response.data
},
})
useEffect(() => {
const webinar = existing.data
if (!webinar) return
setForm({
title: webinar.title,
slug: webinar.slug,
description: webinar.description ?? '',
contentHtml: webinar.contentHtml ?? '',
startsAt: isoToZonedInput(webinar.startsAt, webinar.timezone),
endsAt: isoToZonedInput(webinar.endsAt, webinar.timezone),
timezone: webinar.timezone,
registrationOpen: webinar.registrationOpen,
registrationDeadline: isoToZonedInput(webinar.registrationDeadline, webinar.timezone),
capacity: webinar.capacity === null ? '' : String(webinar.capacity),
joinUrl: webinar.joinUrl ?? '',
videoProvider: webinar.videoProvider ?? '',
recordingUrl: webinar.recordingUrl ?? '',
materialsUrl: webinar.materialsUrl ?? '',
})
setSlugTouched(true)
}, [existing.data])
const suggestedSlug = slugify(form.title)
const effectiveSlug = slugTouched ? form.slug : suggestedSlug
// ten sam adres bazowy, z ktorego powstaja linki w mailach
const publicUrl = usePublicUrl()
const fullPublicLink = `${publicUrl}/w/${effectiveSlug}`
const [linkCopied, setLinkCopied] = useState(false)
async function copyLink() {
try {
await navigator.clipboard.writeText(fullPublicLink)
setLinkCopied(true)
window.setTimeout(() => setLinkCopied(false), 2000)
} catch {
// przegladarka moze odmowic dostepu do schowka bez HTTPS
toasts.error('Nie udało się skopiować. Zaznacz adres i skopiuj ręcznie.')
}
}
const save = useMutation({
mutationFn: async (payload: WebinarRequest) => {
const response = isEdit
? await client.put<Webinar>(`/admin/webinars/${id}`, payload)
: await client.post<Webinar>('/admin/webinars', payload)
return response.data
},
onSuccess: (webinar) => {
toasts.success(isEdit ? 'Zmiany zostały zapisane' : 'Webinar został utworzony')
queryClient.invalidateQueries({ queryKey: ['webinars'] })
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinar.id)] })
navigate(panelPath('/webinary'))
},
onError: (error) => {
setErrors(fieldErrors(error))
toasts.error(errorMessage(error, 'Nie udało się zapisać webinaru'))
},
})
/** Walidacja po stronie klienta, zanim zapytanie pojdzie do backendu. */
function validate(): Record<string, string> {
const found: Record<string, string> = {}
if (!form.title.trim()) found.title = 'Tytuł jest wymagany'
if (!form.startsAt) found.startsAt = 'Termin rozpoczęcia jest wymagany'
if (!form.endsAt) found.endsAt = 'Termin zakończenia jest wymagany'
if (form.startsAt && form.endsAt && form.endsAt <= form.startsAt) {
found.endsAt = 'Termin zakończenia musi być późniejszy niż rozpoczęcia'
}
if (form.registrationDeadline && form.startsAt && form.registrationDeadline > form.startsAt) {
found.registrationDeadline = 'Termin zapisów nie może być późniejszy niż początek webinaru'
}
if (form.capacity && (!/^\d+$/.test(form.capacity) || Number(form.capacity) < 1)) {
found.capacity = 'Limit miejsc musi być liczbą dodatnią'
}
return found
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const found = validate()
setErrors(found)
if (Object.keys(found).length > 0) {
// skaczemy na pierwsza zakladke z bledem, inaczej komunikat mowilby
// o bledzie, ktorego nigdzie nie widac
const faulty = TABS.find((tab) => (tab.fields ?? []).some((field) => found[field]))
if (faulty) setActiveTab(faulty.id)
toasts.error('Formularz zawiera błędy')
return
}
save.mutate({
title: form.title.trim(),
slug: effectiveSlug || undefined,
description: form.description.trim() || null,
contentHtml: form.contentHtml.trim() || null,
startsAt: zonedInputToIso(form.startsAt, form.timezone)!,
endsAt: zonedInputToIso(form.endsAt, form.timezone)!,
timezone: form.timezone,
registrationOpen: form.registrationOpen,
registrationDeadline: zonedInputToIso(form.registrationDeadline, form.timezone),
capacity: form.capacity ? Number(form.capacity) : null,
joinUrl: form.joinUrl.trim() || null,
videoProvider: form.videoProvider,
recordingUrl: form.recordingUrl.trim() || null,
materialsUrl: form.materialsUrl.trim() || null,
})
}
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((current) => ({ ...current, [key]: value }))
}
if (isEdit && existing.isPending) return <Spinner />
if (isEdit && existing.isError) {
return <ErrorState message={errorMessage(existing.error, 'Nie udało się wczytać webinaru')} />
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" icon="arrow-left" type="button" onClick={() => navigate(panelPath('/webinary'))}>
Wróć
</Button>
<h1 className="text-xl font-semibold text-slate-900">
{isEdit ? 'Edycja webinaru' : 'Nowy webinar'}
</h1>
</div>
<FormTabs tabs={TABS} active={activeTab} onChange={setActiveTab} errors={errors} />
{activeTab === 'podstawowe' && (
<Card title="Podstawowe dane">
<div className="space-y-4">
<Field label="Tytuł" error={errors.title}>
<Input
value={form.title}
onChange={(e) => update('title', e.target.value)}
placeholder="np. Zamówienia publiczne 2026"
required
/>
</Field>
<Field
label="Adres strony zapisu"
error={errors.slug}
hint={
!slugTouched && suggestedSlug
? 'Końcówka adresu podpowiadana z tytułu. Konflikt rozstrzyga backend sufiksem liczbowym.'
: 'Konflikt adresu rozstrzyga backend sufiksem liczbowym.'
}
>
{/*
Pelny adres skladany z APP_PUBLIC_URL z ustawien instancji, tego samego,
ktory trafia do linkow w mailach. Administrator edytuje tylko koncowke,
a widzi i kopiuje adres w calosci.
*/}
<div className="flex rounded-md shadow-sm">
{publicUrl && (
<span className="inline-flex select-none items-center rounded-l-md border border-r-0 border-slate-200 bg-slate-50 px-3 text-sm text-slate-500">
{publicUrl}/w/
</span>
)}
<Input
value={effectiveSlug}
onChange={(e) => {
setSlugTouched(true)
update('slug', e.target.value)
}}
placeholder={suggestedSlug || 'adres-strony-zapisu'}
className={publicUrl ? 'rounded-l-none' : ''}
/>
</div>
{/*
Bez APP_PUBLIC_URL nie da sie zlozyc pelnego adresu. Sklejanie go
z pustego prefiksu dawaloby link postaci "/w/slug", ktory nie dziala
po wklejeniu w mailu ani w komunikatorze, wiec zamiast tego mowimy
wprost, czego brakuje.
*/}
{!publicUrl && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-warn-500/25 bg-warn-50 p-3 text-sm text-warn-600">
<Icon name="alert-triangle" size={18} />
<p className="min-w-0 flex-1">
Nie ustawiono publicznego adresu instancji, więc nie można pokazać pełnego
linku do strony zapisu. Ten sam adres trafia do linków w mailach do uczestników.{' '}
{isAdmin ? (
<>
Uzupełnij pole <strong>APP_PUBLIC_URL</strong> w{' '}
<Link to={panelPath('/ustawienia')} className="font-medium underline">
Ustawieniach
</Link>
.
</>
) : (
<>
Poproś administratora o uzupełnienie pola <strong>APP_PUBLIC_URL</strong>{' '}
w ustawieniach instancji.
</>
)}
</p>
</div>
)}
{publicUrl && effectiveSlug && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
{fullPublicLink}
</code>
<Button
type="button"
variant="secondary"
icon={linkCopied ? 'check' : 'copy'}
onClick={copyLink}
className="shrink-0 !py-1 text-xs"
>
{linkCopied ? 'Skopiowano' : 'Kopiuj'}
</Button>
{isEdit && (
<a
href={fullPublicLink}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-50"
>
<Icon name="arrow-left" size={16} />
Otwórz
</a>
)}
</div>
)}
</Field>
<Field
label="Krótki opis"
error={errors.description}
hint="Jedno, dwa zdania. Trafia do zajawki linku w komunikatorach i do maili. Bez formatowania."
>
<Textarea
rows={3}
value={form.description}
onChange={(e) => update('description', e.target.value)}
placeholder="np. Praktyczne szkolenie z obsługi zgłoszeń awarii"
/>
</Field>
</div>
</Card>
)}
{activeTab === 'tresc' && (
<Card
title="Treść strony zapisu"
description="Pojawi się nad formularzem zapisu. Formularz zawsze zostaje na dole strony."
>
<ContentEditor
value={form.contentHtml}
onChange={(html) => update('contentHtml', html)}
webinarId={isEdit && id ? Number(id) : null}
/>
</Card>
)}
{activeTab === 'termin' && (
<Card title="Termin" description="Godziny podawane są w strefie webinaru">
<div className="space-y-4">
<Field label="Strefa czasowa" error={errors.timezone}>
<Select value={form.timezone} onChange={(e) => update('timezone', e.target.value)}>
{COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
</Select>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Rozpoczęcie" error={errors.startsAt} hint={`w strefie ${form.timezone}`}>
<Input
type="datetime-local"
value={form.startsAt}
onChange={(e) => update('startsAt', e.target.value)}
required
/>
</Field>
<Field label="Zakończenie" error={errors.endsAt} hint={`w strefie ${form.timezone}`}>
<Input
type="datetime-local"
value={form.endsAt}
onChange={(e) => update('endsAt', e.target.value)}
required
/>
</Field>
</div>
</div>
</Card>
)}
{activeTab === 'zapisy' && (
<Card title="Zapisy">
<div className="space-y-4">
<label className="flex items-start gap-2.5">
<input
type="checkbox"
checked={form.registrationOpen}
onChange={(e) => update('registrationOpen', e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
/>
<span className="text-sm">
<span className="font-semibold text-slate-800">Zapisy otwarte</span>
<span className="block text-xs text-slate-500">
Twoja decyzja. Niezależnie od niej zapisy zamykają się same po wyczerpaniu
miejsc albo po minięciu terminu, a po rezygnacji uczestnika wracają do tego ustawienia.
</span>
</span>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="Termin zapisów"
error={errors.registrationDeadline}
hint="Puste oznacza brak terminu granicznego"
>
<Input
type="datetime-local"
value={form.registrationDeadline}
onChange={(e) => update('registrationDeadline', e.target.value)}
/>
</Field>
<Field label="Limit miejsc" error={errors.capacity} hint="Puste oznacza brak limitu">
<Input
type="number"
min={1}
value={form.capacity}
onChange={(e) => update('capacity', e.target.value)}
placeholder="bez limitu"
/>
</Field>
</div>
<Field
label="Link do spotkania"
error={errors.joinUrl}
hint="Wysyłany uczestnikom w mailu potwierdzającym. Nie jest pokazywany publicznie."
>
<Input
type="url"
value={form.joinUrl}
onChange={(e) => update('joinUrl', e.target.value)}
placeholder="https://"
/>
</Field>
</div>
</Card>
)}
{activeTab === 'po-webinarze' && (
<Card title="Po webinarze">
<div className="space-y-4">
<p className="text-sm text-slate-500">
Oba adresy trafiają do maila podsumowującego, wysyłanego automatycznie po
zakończeniu webinaru. Uzupełnij je przed wysyłką podsumowania, a jeśli nagranie
pojawi się później, użyj przycisku poniżej.
</p>
<Field
label="Adres nagrania"
error={errors.recordingUrl}
hint="Dołączany do podsumowania jako przycisk „Obejrzyj nagranie”. Puste oznacza brak nagrania."
>
<Input
type="url"
value={form.recordingUrl}
onChange={(e) => update('recordingUrl', e.target.value)}
placeholder="https://"
/>
</Field>
<Field
label="Adres materiałów"
error={errors.materialsUrl}
hint="Prezentacja, pliki do pobrania. Dołączany do podsumowania jako „Pobierz materiały”."
>
<Input
type="url"
value={form.materialsUrl}
onChange={(e) => update('materialsUrl', e.target.value)}
placeholder="https://"
/>
</Field>
{isEdit && id && (
<RecordingNotice webinarId={Number(id)} savedUrl={existing.data?.recordingUrl ?? null} />
)}
</div>
</Card>
)}
{/*
Nagrania i materialy wisza na id webinaru, wiec sekcja ma sens dopiero
po zapisaniu. Przy tworzeniu pokazujemy sama informacje.
*/}
{activeTab === 'pliki' && isEdit && id && <FilesSection webinarId={Number(id)} />}
{activeTab === 'pliki' && !isEdit && (
<Card title="Nagrania i materiały">
<p className="text-sm text-slate-500">
Pliki będzie można wgrać po zapisaniu webinaru.
</p>
</Card>
)}
{/*
Sekcja przypomnien wymaga istniejacego webinaru, bo reguly wisza na jego id.
Przy tworzeniu backend zaklada domyslny zestaw z ustawienia DEFAULT_REMINDERS,
widoczny po zapisaniu.
*/}
{activeTab === 'przypomnienia' && isEdit && id && (
<ReminderSection webinarId={Number(id)} timezone={form.timezone} />
)}
{activeTab === 'wideo' && isEdit && id && (
<VideoSection
webinarId={Number(id)}
joinUrl={form.joinUrl}
videoProvider={form.videoProvider}
onProviderChange={(value) => update('videoProvider', value)}
/>
)}
{activeTab === 'wideo' && !isEdit && (
<Card title="Wideo">
<p className="text-sm text-slate-500">
Pokój i prelegentów ustawisz po zapisaniu webinaru.
</p>
</Card>
)}
{activeTab === 'przypomnienia' && !isEdit && (
<Card title="Przypomnienia">
<p className="text-sm text-slate-500">
Po zapisaniu webinaru zostanie założony domyślny zestaw przypomnień
z ustawień instancji. Będzie go można zmienić w tym formularzu.
</p>
</Card>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" type="button" onClick={() => navigate(panelPath('/webinary'))}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
</form>
)
}
/**
* Wysylka powiadomienia o dostepnym nagraniu.
*
* Osobno od podsumowania, bo nagranie czesto pojawia sie dopiero po jego
* wyslaniu: montaz trwa dluzej niz godzina, a podsumowania nie da sie
* powtorzyc. Powiadomienie idzie wylacznie do uczestnikow ze statusem
* CONFIRMED i tylko raz na odbiorce, czego pilnuje unikalny indeks w bazie.
*
* Przycisk dziala na **zapisanym** adresie nagrania, nie na tresci pola:
* wyslanie powiadomienia o adresie, ktorego jeszcze nie zapisano, konczyloby
* sie mailem z linkiem sprzed edycji.
*/
function RecordingNotice({ webinarId, savedUrl }: { webinarId: number; savedUrl: string | null }) {
const toasts = useToasts()
const notify = useMutation({
mutationFn: async () => {
const response = await client.post<{ queued: number; skipped: number }>(
`/admin/webinars/${webinarId}/notify-recording`,
)
return response.data
},
onSuccess: (result) => {
if (result.queued === 0 && result.skipped > 0) {
toasts.success(`Wszyscy uczestnicy (${result.skipped}) już dostali to powiadomienie.`)
} else if (result.queued === 0) {
toasts.success('Nie ma komu wysłać powiadomienia: brak potwierdzonych zapisów.')
} else {
const extra = result.skipped > 0 ? `, pominięto ${result.skipped} (już powiadomieni)` : ''
toasts.success(`Zakolejkowano ${result.queued} powiadomień o nagraniu${extra}.`)
}
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wysłać powiadomienia')),
})
const hasSavedUrl = savedUrl !== null && savedUrl.trim() !== ''
return (
<div className="rounded-md border border-slate-200 bg-slate-25 p-4">
<p className="text-sm font-semibold text-slate-800">Powiadom o nagraniu</p>
<p className="mt-1 text-sm text-slate-500">
Osobna wiadomość dla sytuacji, gdy nagranie pojawiło się po wysłaniu podsumowania.
Trafi tylko do uczestników z potwierdzonym zapisem i tylko raz, nawet po kilku
kliknięciach.
</p>
{!hasSavedUrl && (
<p className="mt-2 text-sm text-warn-600">
Najpierw uzupełnij adres nagrania i zapisz webinar.
</p>
)}
<div className="mt-3">
<Button
type="button"
variant="secondary"
icon="mail"
disabled={!hasSavedUrl || notify.isPending}
onClick={() => {
if (confirm('Wysłać powiadomienie o nagraniu do wszystkich potwierdzonych uczestników?')) {
notify.mutate()
}
}}
>
{notify.isPending ? 'Wysyłanie...' : 'Wyślij powiadomienie o nagraniu'}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,575 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { CopyResult, DeletionImpact, Page, Webinar, WebinarStatus } from '../../api/types'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { SortableHeader, type SortState } from '../../components/SortableHeader'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, EmptyState, ErrorState, IconButton, Input, Select, Spinner } from '../../components/ui'
import { formatDateTimeParts, formatLocal } from '../../lib/dates'
import { CLOSED_REASON_LABEL, WEBINAR_STATUS_LABEL, WEBINAR_STATUS_TONE } from '../../lib/labels'
import { panelPath } from '../../lib/routes'
/**
* Zakladki listy.
*
* Podzial idzie po **terminie zakonczenia**, a nie po statusie FINISHED:
* nic w aplikacji tego statusu nie ustawia, wiec zakladka po nim byla by
* zawsze pusta. Backend stosuje ta sama regule co strona publiczna.
*/
const SCOPES = [
{ id: 'ACTIVE', label: 'Aktywne', icon: 'presentation' as const },
{ id: 'FINISHED', label: 'Zakończone', icon: 'check-circle' as const },
]
export function WebinarListPage() {
const [scope, setScope] = useState<'ACTIVE' | 'FINISHED'>('ACTIVE')
const [status, setStatus] = useState<WebinarStatus | ''>('')
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
/** Domyslnie najnowsze na gorze, czyli ostatnio dodane. */
const [sort, setSort] = useState<SortState>({ field: 'createdAt', direction: 'desc' })
const navigate = useNavigate()
const queryClient = useQueryClient()
const toasts = useToasts()
const query = useQuery({
queryKey: ['webinars', scope, status, search, page, sort.field, sort.direction],
queryFn: async () => {
const response = await client.get<Page<Webinar>>('/admin/webinars', {
params: {
scope,
status: status || undefined,
search: search || undefined,
page,
size: PAGE_SIZE,
sort: sort.field,
direction: sort.direction,
},
})
return response.data
},
})
/** Kazda zmiana zakresu, filtru albo sortowania cofa na pierwsza strone. */
function changeScope(next: 'ACTIVE' | 'FINISHED') {
setScope(next)
setPage(0)
}
function changeSort(next: SortState) {
setSort(next)
setPage(0)
}
const publish = useMutation({
mutationFn: (id: number) => client.post(`/admin/webinars/${id}/publish`),
onSuccess: () => {
toasts.success('Webinar został opublikowany')
queryClient.invalidateQueries({ queryKey: ['webinars'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się opublikować webinaru')),
})
const cancel = useMutation({
mutationFn: (id: number) => client.post(`/admin/webinars/${id}/cancel`),
onSuccess: () => {
toasts.success('Webinar został odwołany')
queryClient.invalidateQueries({ queryKey: ['webinars'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się odwołać webinaru')),
})
/**
* Kopiowanie webinaru.
*
* Po skopiowaniu przechodzimy od razu do edycji kopii: termin jest tylko
* zastepczy i wymaga poprawienia, zanim da sie kopie opublikowac.
* Zostawienie uzytkownika na liscie oznaczaloby szukanie nowego wiersza
* wsrod kilkudziesieciu innych.
*/
const copy = useMutation({
mutationFn: async (webinar: Webinar) => {
const response = await client.post<CopyResult>(`/admin/webinars/${webinar.id}/copy`, {})
return response.data
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['webinars'] })
toasts.success(
`Utworzono kopię: ${result.reminderRules} reguł przypomnień, ` +
`${result.offerTopics} tematów zapytań, ${result.materials} materiałów. ` +
'Ustaw właściwy termin, bo kopia ma tymczasowy.',
)
navigate(panelPath(`/webinary/${result.id}/edycja`))
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się skopiować webinaru')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/webinars/${id}`),
onSuccess: () => {
toasts.success('Webinar został usunięty')
queryClient.invalidateQueries({ queryKey: ['webinars'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć webinaru')),
})
/**
* Usuniecie webinaru.
*
* Przed pytaniem pobieramy liczby z backendu, zeby powiedziec wprost, co
* przepadnie. "Czy na pewno?" bez tej informacji nie daje uzytkownikowi
* niczego, na czym moglby oprzec decyzje: przy webinarze z setka zapisow
* i wydanymi certyfikatami odpowiedz jest zupelnie inna niz przy pustym
* szkicu.
*
* Podpowiadamy odwolanie, bo w wiekszosci przypadkow to ono jest tym,
* czego uzytkownik naprawde chce: zamyka zapisy i chowa webinar, ale
* zostawia dane.
*/
async function handleDelete(webinar: Webinar) {
let impact: DeletionImpact
try {
const response = await client.get<DeletionImpact>(
`/admin/webinars/${webinar.id}/deletion-impact`,
)
impact = response.data
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się sprawdzić, co zostanie usunięte'))
return
}
const losses: string[] = []
if (impact.registrations > 0) losses.push(`${impact.registrations} zapisów uczestników`)
if (impact.certificates > 0) losses.push(`${impact.certificates} wydanych certyfikatów`)
const detail =
losses.length > 0
? `Bezpowrotnie przepadnie: ${losses.join(', ')}, a także obecność, wiadomości z czatu, pytania, nagrania i materiały.`
: 'Ten webinar nie ma jeszcze zapisów ani certyfikatów.'
const confirmed = window.confirm(
`Usunąć webinar "${webinar.title}"?
${detail}
` +
'Tej operacji nie da się cofnąć. Jeśli chodzi Ci tylko o zamknięcie zapisów, użyj "Odwołaj".',
)
if (confirmed) remove.mutate(webinar.id)
}
function handleCancel(webinar: Webinar) {
const confirmed = window.confirm(
`Odwołać webinar "${webinar.title}"?\n\nZapisy zostaną zamknięte, a webinar zniknie ze strony publicznej.`,
)
if (confirmed) cancel.mutate(webinar.id)
}
/** Akcje wspolne dla widoku tabeli i kart. */
function actions(webinar: Webinar) {
return (
<div className="flex gap-1">
{/*
Szczegoly otwieraja sie w nowej karcie, wiec musi to byc prawdziwy
link, a nie przycisk: tylko wtedy dziala srodkowy przycisk myszy,
Ctrl+klik i menu kontekstowe przegladarki.
rel="noopener" odcina nowej karcie dostep do window.opener.
*/}
<a
href={panelPath(`/webinary/${webinar.id}`)}
target="_blank"
rel="noopener noreferrer"
aria-label="Podgląd"
title="Podgląd"
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-600 transition-colors duration-fast ease-standard hover:bg-slate-100 hover:text-slate-900"
>
<Icon name="eye" size={18} />
</a>
<IconButton
icon="pencil"
label="Edytuj"
onClick={() => navigate(panelPath(`/webinary/${webinar.id}/edycja`))}
/>
{/*
Prowadzenie ma sens tylko dla webinaru opublikowanego. Gdy webinar
WLASNIE TRWA (jest po starcie, a przed koncem), prowadzenie jest
najwazniejsza akcja, wiec pokazujemy WYRAZNY przycisk z podpisem,
a nie sama ikonke ginaca wsrod innych. Poza czasem trwania zostaje
zwykla ikonka.
*/}
{webinar.status === 'PUBLISHED' && (
isLiveNow(webinar) ? (
<Button
type="button"
icon="presentation"
onClick={() => navigate(panelPath(`/webinary/${webinar.id}/prowadzenie`))}
>
Prowadź
</Button>
) : (
<IconButton
icon="presentation"
label="Prowadź spotkanie"
onClick={() => navigate(panelPath(`/webinary/${webinar.id}/prowadzenie`))}
/>
)
)}
{webinar.status !== 'PUBLISHED' && webinar.status !== 'CANCELLED' && (
<IconButton
icon="upload"
label="Publikuj"
onClick={() => publish.mutate(webinar.id)}
disabled={publish.isPending}
/>
)}
<IconButton
icon="copy"
label="Kopiuj"
onClick={() => copy.mutate(webinar)}
disabled={copy.isPending}
/>
{webinar.status !== 'CANCELLED' && (
<IconButton
icon="x-circle"
label="Odwołaj"
onClick={() => handleCancel(webinar)}
disabled={cancel.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
)}
<IconButton
icon="trash"
label="Usuń"
onClick={() => handleDelete(webinar)}
disabled={remove.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
</div>
)
}
function registrationBadge(webinar: Webinar) {
return webinar.effectivelyOpen ? (
<Badge tone="ok" dot>
Otwarte
</Badge>
) : (
<Badge tone="neutral">
{webinar.closedReason ? CLOSED_REASON_LABEL[webinar.closedReason] : 'Zamknięte'}
</Badge>
)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl">Webinary</h1>
<p className="text-sm text-slate-500">Lista wydarzeń i zapisów uczestników</p>
</div>
<Button icon="plus" onClick={() => navigate(panelPath('/webinary/nowy'))}>
Nowy webinar
</Button>
</div>
{/*
Zakladki przewijaja sie w poziomie wewnatrz wlasnego kontenera,
wiec na telefonie nie rozpychaja dokumentu.
*/}
<div role="tablist" aria-label="Zakres listy" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{SCOPES.map((item) => {
const isActive = item.id === scope
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => changeScope(item.id as 'ACTIVE' | 'FINISHED')}
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
isActive
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<Icon name={item.icon} size={16} />
{item.label}
</button>
)
})}
</div>
<Card>
<div className="flex flex-wrap items-end gap-3">
<div className="w-full sm:w-72">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Szukaj</label>
<Input
value={search}
placeholder="Tytuł webinaru"
onChange={(e) => {
setSearch(e.target.value)
// zmiana filtru cofa na pierwsza strone: inaczej stojac na
// stronie 2 uzytkownik widzi pusta tabele i wyglada to jak brak danych
setPage(0)
}}
/>
</div>
<div className="w-full sm:w-56">
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Status</label>
<Select
value={status}
onChange={(e) => {
setStatus(e.target.value as WebinarStatus | '')
setPage(0)
}}
>
<option value="">Wszystkie</option>
{(Object.keys(WEBINAR_STATUS_LABEL) as WebinarStatus[]).map((value) => (
<option key={value} value={value}>
{WEBINAR_STATUS_LABEL[value]}
</option>
))}
</Select>
</div>
{query.data && (
<p className="pb-2.5 text-sm text-slate-500">
Znaleziono: <strong className="hx-data text-slate-700">{query.data.totalElements}</strong>
</p>
)}
</div>
</Card>
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{query.isPending && <Spinner />}
{query.isError && (
<div className="p-5">
<ErrorState message={errorMessage(query.error, 'Nie udało się wczytać listy webinarów')} />
</div>
)}
{query.data && query.data.content.length === 0 && (
<EmptyState
icon="presentation"
title="Brak webinarów"
description={status ? 'Zmień filtr statusu albo utwórz nowy webinar.' : 'Utwórz pierwszy webinar.'}
/>
)}
{query.data && query.data.content.length > 0 && (
<>
{/*
Na telefonie lista kart, na duzym ekranie tabela.
Tabela z minimalna szerokoscia rozpychala strone na waskim ekranie,
a poziome przewijanie calej strony jest gorsze niz zmiana ukladu.
*/}
<ul className="divide-y divide-slate-100 lg:hidden">
{query.data.content.map((webinar) => (
<li key={webinar.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">{webinar.title}</p>
<p className="hx-data truncate text-xs text-slate-400">/{webinar.slug}</p>
</div>
<Badge tone={WEBINAR_STATUS_TONE[webinar.status]}>
{WEBINAR_STATUS_LABEL[webinar.status]}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Icon name="clock" size={16} className="text-slate-400" />
<span className="hx-data">{startsAtInline(webinar)}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link
to={panelPath(`/webinary/${webinar.id}/zapisy`)}
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
>
<Icon name="users" size={16} />
<span className="hx-data">
{webinar.registeredCount}
{webinar.capacity !== null ? ` / ${webinar.capacity}` : ''}
</span>
{webinar.capacity === null && <span className="text-xs">bez limitu</span>}
</Link>
{/* zapytania ofertowe pokazujemy tylko gdy sa, zeby nie zasmiecac karty */}
{webinar.offerCount > 0 && (
<Link
to={panelPath(`/webinary/${webinar.id}`)}
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
>
<Icon name="send" size={16} />
<span className="hx-data">{webinar.offerCount}</span>
</Link>
)}
{registrationBadge(webinar)}
<div className="ml-auto">{actions(webinar)}</div>
</div>
</li>
))}
</ul>
{/*
Przewijanie w poziomie **wewnatrz kontenera tabeli**, nie na stronie.
Po dolozeniu kolumny "Dodano" tabela bywa szersza niz panel przy
waskim oknie i kolumna akcji stawala sie nieosiagalna. Sama strona
nadal nie przewija sie w poziomie, zgodnie z zasada responsywnosci.
*/}
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
<SortableHeader field="title" sort={sort} onSort={changeSort}>
Tytuł
</SortableHeader>
<SortableHeader field="startsAt" sort={sort} onSort={changeSort}>
Termin
</SortableHeader>
<SortableHeader field="status" sort={sort} onSort={changeSort}>
Status
</SortableHeader>
{/*
Liczba zapisanych jest doliczana osobnym zapytaniem do juz
pobranej strony, wiec nie da sie po niej sortowac w bazie.
Sortowanie tylko biezacej strony wprowadzaloby w blad,
wiec ta kolumna celowo nie jest klikalna.
*/}
<th className="px-4 py-3">Zapisani</th>
{/*
Liczba zapytan ofertowych, doliczana tym samym sposobem co
liczba zapisanych, wiec tak samo nie jest sortowalna.
Chowana ponizej 2xl razem z kolumna "Dodano": przy 1280px
obie naraz rozpychaja tabele ponad szerokosc panelu.
*/}
<th className="hidden px-4 py-3 2xl:table-cell">Zapytania</th>
<SortableHeader field="registrationOpen" sort={sort} onSort={changeSort}>
Zapisy
</SortableHeader>
{/*
Kolumna informacyjna, wiec chowamy ja ponizej 2xl. Zmierzone:
przy 1280px jej dolozenie rozpycha tabele ponad szerokosc
panelu i kolumna akcji wymaga przewijania. Sortowanie po
dacie dodania jest i tak domyslne, wiec na wezszych ekranach
nic sie nie traci.
*/}
<SortableHeader field="createdAt" sort={sort} onSort={changeSort} className="hidden 2xl:table-cell">
Dodano
</SortableHeader>
<th className="px-4 py-3 text-right">Akcje</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{query.data.content.map((webinar) => (
<tr key={webinar.id} className="transition duration-fast ease-standard hover:bg-slate-25">
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{webinar.title}</p>
<p className="hx-data text-xs text-slate-400">/{webinar.slug}</p>
</td>
<td className="hx-data whitespace-nowrap px-4 py-3 text-slate-600">
<StartsAt webinar={webinar} />
</td>
<td className="px-4 py-3">
<Badge tone={WEBINAR_STATUS_TONE[webinar.status]}>
{WEBINAR_STATUS_LABEL[webinar.status]}
</Badge>
</td>
<td className="whitespace-nowrap px-4 py-3">
<Link
to={panelPath(`/webinary/${webinar.id}/zapisy`)}
className="hx-data font-medium text-blue-700 hover:underline"
>
{webinar.registeredCount}
{webinar.capacity !== null ? ` / ${webinar.capacity}` : ' (bez limitu)'}
</Link>
</td>
{/*
Zero pokazujemy jako kreske, a nie jako "0": pusta wartosc
czyta sie szybciej niz liczba, ktora i tak nic nie wnosi.
Link prowadzi wprost do zakladki zapytan tego webinaru.
*/}
<td className="hidden whitespace-nowrap px-4 py-3 2xl:table-cell">
{webinar.offerCount > 0 ? (
<Link
to={panelPath(`/webinary/${webinar.id}`)}
className="hx-data font-medium text-blue-700 hover:underline"
>
{webinar.offerCount}
</Link>
) : (
<span className="hx-data text-slate-400">-</span>
)}
</td>
<td className="px-4 py-3">{registrationBadge(webinar)}</td>
<td className="hx-data hidden whitespace-nowrap px-4 py-3 text-slate-500 2xl:table-cell">
{formatLocal(webinar.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end">{actions(webinar)}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{query.data && (
<Pagination
page={query.data.number}
totalPages={query.data.totalPages}
totalElements={query.data.totalElements}
onChange={setPage}
itemLabel="webinarów"
/>
)}
</div>
</div>
)
}
/**
* Termin w dwoch liniach: data, pod nia godzina.
*
* Bez nazwy strefy, bo na liscie nie ma jej po co porownywac, a w jednej linii
* caly napis zabieral tyle miejsca, ze kolumna akcji uciekala poza ekran.
* Strefa jest nadal jawna tam, gdzie ma znaczenie: w formularzu webinaru,
* na stronie publicznej i na liscie zapisanych.
*/
function StartsAt({ webinar }: { webinar: Webinar }) {
const { date, time } = formatDateTimeParts(webinar.startsAt, webinar.timezone)
return (
<>
<span className="block">{date}</span>
<span className="block text-xs text-slate-400">{time}</span>
</>
)
}
/** Ten sam termin w jednej linii, do karty na telefonie. */
function startsAtInline(webinar: Webinar): string {
const { date, time } = formatDateTimeParts(webinar.startsAt, webinar.timezone)
return time ? `${date}, ${time}` : date
}
/**
* Czy webinar wlasnie trwa: jest po starcie i przed koncem.
*
* Uzywamy tego samego marginesu, co okno wejscia do pokoju (15 min przed
* startem), zeby przycisk "Prowadz" pojawil sie wyraznie juz TUZ przed startem,
* a nie dokladnie o godzinie zero. Porownujemy chwile UTC, wiec strefa webinaru
* nie ma tu znaczenia.
*/
function isLiveNow(webinar: Webinar): boolean {
const now = Date.now()
const starts = new Date(webinar.startsAt).getTime()
const ends = new Date(webinar.endsAt).getTime()
const OPEN_BEFORE_MS = 15 * 60 * 1000
return now >= starts - OPEN_BEFORE_MS && now <= ends
}
@@ -0,0 +1,508 @@
import { Suspense, lazy, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { Page, Registration, Webinar } from '../../api/types'
import { Icon } from '../../components/Icon'
import { BannerSection } from '../banners/BannerSection'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { Badge, Card, EmptyState, ErrorState, Spinner } from '../../components/ui'
import { formatDateTimeParts, formatWithZone } from '../../lib/dates'
import type { InteractionView } from '../../lib/useInteractions'
import { OfferSection } from './OfferSection'
import { InvitationSection } from './InvitationSection'
import { CertificateSection } from './CertificateSection'
import {
CONSENT_LABEL,
REGISTRATION_STATUS_LABEL,
REGISTRATION_STATUS_TONE,
WEBINAR_STATUS_LABEL,
WEBINAR_STATUS_TONE,
} from '../../lib/labels'
import { panelPath } from '../../lib/routes'
/*
* Statystyki ladowane leniwie: ciagna za soba @mui/x-charts, ktore wazy
* wiecej niz cala reszta panelu, a jest potrzebne wylacznie na tej jednej
* zakladce. Bez podzialu kazde otwarcie webinaru pobieraloby wykresy.
*/
const StatsSection = lazy(() =>
import('./StatsSection').then((m) => ({ default: m.StatsSection })))
/** Historia czatu z panelu moderacji. Ten sam ksztalt co w panelu prowadzenia. */
type ModerationData = { chat: InteractionView[]; questions: InteractionView[] }
/** Zakladki szczegolow webinaru. Wartosc jest tez kluczem w TABS. */
type PreviewTab =
| 'tresc'
| 'zaproszenia'
| 'zapisani'
| 'banery'
| 'certyfikaty'
| 'statystyki'
| 'zapytania'
| 'czat'
/**
* Szczegoly webinaru: tresc strony zapisu, zapisani, zapytania ofertowe i czat.
*
* Osobna strona pod wlasnym adresem, a nie okno modalne. Dzieki temu da sie ja
* otworzyc w nowej karcie, zostawic obok listy, odswiezyc i podeslac linkiem.
*
* **Wszystko, co dotyczy jednego webinaru, jest tutaj.** Zapytania ofertowe
* i historia czatu naleza do konkretnego wydarzenia, wiec sa jego zakladkami,
* a nie osobnym ekranem globalnym. Ta sama zasada obowiazuje kazde kolejne
* powiazanie: nowy rodzaj danych webinaru dostaje zakladke tutaj.
*
* Podglad wylacznie czyta. Obsluga zapytania (notatka, oznaczenie jako
* zalatwione) jest w zakladce zapytan, bo to jedyne miejsce, gdzie handlowiec
* je oglada.
*/
export function WebinarPreviewPage() {
const { id } = useParams<{ id: string }>()
const [tab, setTab] = useState<PreviewTab>('tresc')
const [page, setPage] = useState(0)
const webinar = useQuery({
queryKey: ['webinar', id],
queryFn: async () => {
const response = await client.get<Webinar>(`/admin/webinars/${id}`)
return response.data
},
})
const registrations = useQuery({
queryKey: ['registrations', id, '', page],
enabled: tab === 'zapisani',
queryFn: async () => {
const response = await client.get<Page<Registration>>(
`/admin/webinars/${id}/registrations`,
{ params: { page, size: PAGE_SIZE } },
)
return response.data
},
})
/*
* Historia czatu z tego samego endpointu moderacji, co panel prowadzenia.
* Tutaj sluzy wylacznie do czytania po spotkaniu, wiec nie ma WebSocketu:
* archiwum sie nie zmienia, a otwarte polaczenie po webinarze byloby
* utrzymywane bez powodu.
*/
const moderation = useQuery({
queryKey: ['moderation', id],
enabled: tab === 'czat',
queryFn: async () => {
const response = await client.get<ModerationData>(`/admin/webinars/${id}/interactions`)
return response.data
},
})
if (webinar.isPending) return <Spinner />
if (webinar.isError) {
return <ErrorState message={errorMessage(webinar.error, 'Nie udało się wczytać webinaru')} />
}
const data = webinar.data
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold text-slate-900">{data.title}</h1>
<Badge tone={WEBINAR_STATUS_TONE[data.status]}>
{WEBINAR_STATUS_LABEL[data.status]}
</Badge>
</div>
<p className="hx-data mt-1 text-xs text-slate-500">
{formatWithZone(data.startsAt, data.timezone)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
to={panelPath(`/webinary/${data.id}/edycja`)}
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-4 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
>
<Icon name="pencil" size={16} />
Edytuj
</Link>
<a
href={`/w/${data.slug}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-4 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
>
<Icon name="eye" size={16} />
Strona zapisu
</a>
</div>
</div>
<nav
role="tablist"
aria-label="Sekcje podglądu"
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
>
{(
[
['tresc', 'Treść strony', 'file-text', null],
['zaproszenia', 'Zaproszenia', 'mail', null],
['zapisani', 'Zapisani', 'users', data.registeredCount],
['banery', 'Banery', 'image', null],
['certyfikaty', 'Certyfikaty', 'file-text', null],
['statystyki', 'Statystyki', 'presentation', null],
['zapytania', 'Zapytania ofertowe', 'send', data.offerCount],
['czat', 'Historia czatu', 'message-square', null],
] as const
).map(([value, label, icon, count]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
tab === value
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`}
>
<Icon name={icon} size={16} />
{label}
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
</button>
))}
</nav>
{tab === 'tresc' && <ContentTab webinar={data} />}
{/* banery tego webinaru: ten sam komponent co dzial globalny */}
{tab === 'banery' && <BannerSection webinarId={data.id} />}
{tab === 'zapisani' && (
<Card>
{registrations.isPending && <Spinner />}
{registrations.isError && (
<ErrorState
message={errorMessage(registrations.error, 'Nie udało się wczytać zapisanych')}
/>
)}
{registrations.data && (
<RegistrationsTab data={registrations.data} webinar={data} onPage={setPage} />
)}
</Card>
)}
{/* pierwszy etap sciezki: informacja z linkiem, dopiero potem zapis */}
{tab === 'zaproszenia' && <InvitationSection webinar={data} />}
{tab === 'certyfikaty' && <CertificateSection webinar={data} />}
{/* wszystkie liczby przychodza policzone z bazy, front tylko rysuje */}
{tab === 'statystyki' && (
<Suspense fallback={<Spinner />}>
<StatsSection webinarId={data.id} timezone={data.timezone} />
</Suspense>
)}
{/*
Zapytania tego jednego webinaru. Ten sam komponent obsluguje ekran
zbiorczy, wiec obsluga zapytania wyglada tak samo niezaleznie od tego,
skad handlowiec do niego trafil.
*/}
{tab === 'zapytania' && <OfferSection webinarId={data.id} />}
{tab === 'czat' && (
<Card>
{moderation.isPending && <Spinner />}
{moderation.isError && (
<ErrorState message={errorMessage(moderation.error, 'Nie udało się wczytać czatu')} />
)}
{moderation.data && <ChatHistoryTab chat={moderation.data.chat} webinar={data} />}
</Card>
)}
</div>
)
}
/** Tresc strony zapisu tak, jak zobaczy ja uczestnik. */
function ContentTab({ webinar }: { webinar: Webinar }) {
return (
<Card>
<div className="space-y-4">
{webinar.description && (
<p className="whitespace-pre-line rounded-md border border-slate-200 bg-slate-25 p-3 text-sm text-slate-700">
{webinar.description}
</p>
)}
{/*
Tresc jest oczyszczana przy zapisie na backendzie (biala lista jsoup),
wiec w bazie nie ma niczego, czego nie wolno wyswietlic. Nie czyscimy
jej drugi raz w przegladarce, bo to tylko udawaloby zabezpieczenie.
*/}
{webinar.contentHtml ? (
<div
className="prose prose-slate max-w-none overflow-x-auto prose-headings:font-semibold prose-img:mx-auto prose-img:h-auto prose-img:max-w-full prose-img:rounded-md"
dangerouslySetInnerHTML={{ __html: webinar.contentHtml }}
/>
) : (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
Ten webinar nie ma własnej treści strony. Uczestnik zobaczy sam formularz zapisu
wraz z terminem i opisem.
</p>
)}
</div>
</Card>
)
}
/** Lista zapisanych wraz z danymi kontaktowymi. */
function RegistrationsTab({
data,
webinar,
onPage,
}: {
data: Page<Registration>
webinar: Webinar
onPage: (page: number) => void
}) {
if (data.totalElements === 0) {
return (
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
Nikt jeszcze się nie zapisał.
</p>
)
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-slate-500">
Zapisanych: <span className="hx-data font-medium text-slate-900">{data.totalElements}</span>
{webinar.capacity !== null && (
<span className="hx-data text-slate-400"> / {webinar.capacity}</span>
)}
</p>
<Link
to={panelPath(`/webinary/${webinar.id}/zapisy`)}
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
>
<Icon name="users" size={16} />
Pełna lista i eksport CSV
</Link>
</div>
{/* karty do lg, tabela od lg w gore: zgodnie z zasada responsywnosci */}
<ul className="space-y-2 lg:hidden">
{data.content.map((registration) => (
<li key={registration.id} className="rounded-md border border-slate-200 p-3">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</p>
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
{REGISTRATION_STATUS_LABEL[registration.status]}
</Badge>
</div>
<ContactLines registration={registration} />
<ConsentChips registration={registration} />
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-100 bg-slate-25 text-left">
<tr className="hx-eyebrow">
<th className="px-3 py-2">Uczestnik</th>
<th className="px-3 py-2">Kontakt</th>
<th className="px-3 py-2">Firma</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Zapisano</th>
<th className="px-3 py-2">Zgody</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.content.map((registration) => {
const { date, time } = formatDateTimeParts(registration.createdAt, webinar.timezone)
return (
<tr key={registration.id}>
<td className="px-3 py-2 font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</td>
<td className="px-3 py-2">
<a
href={`mailto:${registration.email}`}
className="hx-data block text-blue-700 hover:underline"
>
{registration.email}
</a>
{registration.phone && (
<a
href={`tel:${registration.phone}`}
className="hx-data block text-xs text-slate-500 hover:underline"
>
{registration.phone}
</a>
)}
</td>
<td className="px-3 py-2 text-slate-600">{registration.company || '-'}</td>
<td className="px-3 py-2">
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
{REGISTRATION_STATUS_LABEL[registration.status]}
</Badge>
</td>
<td className="hx-data whitespace-nowrap px-3 py-2 text-slate-600">
<span className="block">{date}</span>
<span className="block text-xs text-slate-400">{time}</span>
</td>
<td className="px-3 py-2">
<ConsentChips registration={registration} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<Pagination
page={data.number}
totalPages={data.totalPages}
totalElements={data.totalElements}
onChange={onPage}
itemLabel="zapisanych"
/>
</div>
)
}
function ContactLines({ registration }: { registration: Registration }) {
return (
<div className="mt-1 space-y-0.5">
<a
href={`mailto:${registration.email}`}
className="hx-data block break-all text-sm text-blue-700 hover:underline"
>
{registration.email}
</a>
{registration.phone && (
<a href={`tel:${registration.phone}`} className="hx-data block text-sm text-slate-600">
{registration.phone}
</a>
)}
{registration.company && <p className="text-sm text-slate-500">{registration.company}</p>}
</div>
)
}
/**
* Zgody uczestnika.
*
* Pokazujemy wylacznie **najnowsza** zgode kazdego typu: zgody sa dopisywane,
* nigdy nadpisywane, wiec przy ponownym zapisie jest ich kilka i pokazanie
* wszystkich mylilo by co do stanu biezacego.
*/
function ConsentChips({ registration }: { registration: Registration }) {
const latest = new Map<string, boolean>()
for (const consent of registration.consents) {
latest.set(consent.type, consent.granted)
}
if (latest.size === 0) return <span className="text-xs text-slate-400">-</span>
return (
<div className="mt-2 flex flex-wrap gap-1 lg:mt-0">
{[...latest.entries()].map(([type, granted]) => (
<span
key={type}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-2xs ${
granted ? 'bg-ok-50 text-ok-600' : 'bg-slate-100 text-slate-500'
}`}
title={CONSENT_LABEL[type as keyof typeof CONSENT_LABEL] ?? type}
>
<Icon name={granted ? 'check' : 'x'} size={12} />
{shortConsent(type)}
</span>
))}
</div>
)
}
/**
* Historia czatu z webinaru: kto, co i kiedy napisal.
*
* Archiwum po spotkaniu, wiec bez WebSocketu i bez moderacji. Wiadomosci
* usuniete przez moderatora maja status REJECTED i **zostaja na liscie**,
* wyraznie oznaczone: prowadzacy ma widziec, co zostalo usuniete, bo to jedyny
* slad po interwencji. Kasowanie wiersza odebraloby mu ten slad.
*
* Czas jest w strefie webinaru, a nie przegladarki: rozmowa toczyla sie
* w czasie wydarzenia i tylko w tej strefie kolejnosc wypowiedzi ma sens
* dla kogos, kto oglada to z innego kraju.
*/
function ChatHistoryTab({ chat, webinar }: { chat: InteractionView[]; webinar: Webinar }) {
if (chat.length === 0) {
return (
<EmptyState
icon="message-square"
title="Brak wiadomości"
description="Podczas tego webinaru nikt nie napisał na czacie."
/>
)
}
return (
<div className="space-y-3">
<p className="text-sm text-slate-500">
Wiadomości: <span className="hx-data font-medium text-slate-900">{chat.length}</span>
<span className="text-slate-400"> - czas w strefie {webinar.timezone}</span>
</p>
<ul className="space-y-2">
{chat.map((message) => {
const { date, time } = formatDateTimeParts(message.createdAt, webinar.timezone)
const removed = message.status === 'REJECTED'
return (
<li
key={message.id}
className={`rounded-md border p-3 ${
removed ? 'border-slate-200 bg-slate-25' : 'border-slate-200'
}`}
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-slate-900">{message.authorName}</span>
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
</div>
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
{date} {time}
</span>
</div>
<p
className={`mt-1 whitespace-pre-wrap break-words text-sm ${
removed ? 'text-slate-400 line-through' : 'text-slate-700'
}`}
>
{message.content}
</p>
</li>
)
})}
</ul>
</div>
)
}
/** Krotka etykieta zgody, zeby chipy nie rozpychaly kolumny. */
function shortConsent(type: string): string {
if (type === 'TERMS') return 'regulamin'
if (type === 'MARKETING_EMAIL') return 'e-mail'
if (type === 'MARKETING_SMS') return 'SMS'
return type
}