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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.env
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+24
View File
@@ -0,0 +1,24 @@
# --- etap 1: build ---
FROM node:22-alpine AS build
WORKDIR /build
# najpierw manifesty, zeby warstwa z node_modules byla cache'owana
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# frontend odpytuje backend przez wzgledny /api, ktory nginx proxuje dalej
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# --- etap 2: runtime ---
FROM nginx:1.27-alpine
COPY --from=build /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Platforma webinarowa</title>
<meta name="description" content="Zapisy na szkolenia i webinary online." />
<meta name="theme-color" content="#2C8ABD" />
<!--
Zajawka dla komunikatorow. Wartosci per webinar ustawia useDocumentMeta
po uruchomieniu skryptu. Klienci nieuruchamiajacy JavaScriptu zobacza to,
co ponizej, wiec tresc musi byc sensowna takze bez tytulu wydarzenia.
-->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Platforma webinarowa" />
<meta property="og:title" content="Zapisy na webinar" />
<meta property="og:description" content="Wypełnij formularz, aby zapisać się na wydarzenie." />
<meta name="twitter:card" content="summary" />
<!-- IBM Plex Sans do interfejsu, IBM Plex Mono do danych i terminow -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+172
View File
@@ -0,0 +1,172 @@
# Rozpoznanie robotow komunikatorow po naglowku User-Agent.
# Aplikacja jest SPA, wiec bot nieuruchamiajacy JavaScriptu nie zobaczy tytulu
# wydarzenia. Zadania takich robotow na /w/* kierujemy do backendu, ktory zwraca
# maly dokument HTML z meta tagami. Zwykli uzytkownicy dostaja normalna aplikacje.
map $http_user_agent $is_crawler {
default 0;
"~*facebookexternalhit" 1;
"~*Twitterbot" 1;
"~*LinkedInBot" 1;
"~*WhatsApp" 1;
"~*Slackbot" 1;
"~*Discordbot" 1;
"~*TelegramBot" 1;
"~*Googlebot" 1;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Nginx nie zdradza swojej wersji w naglowku Server ani na stronach bledow.
server_tokens off;
# Domyslny limit tresci zadania. Duze pliki (nagrania, materialy) maja
# wlasny, znacznie wyzszy limit ustawiony punktowo na swoich sciezkach.
# Bez tej linii limit /api/ (2100m) obejmowalby KAZDY endpoint, w tym
# otwarte /api/public/** i /api/auth/login, wiec dowolny nieuwierzytelniony
# nadawca moglby przyslac 2 GB i wyczerpac dysk albo pasmo.
client_max_body_size 1m;
# --- Naglowki bezpieczenstwa ---
# Dokladane do KAZDEJ odpowiedzi (`always`, wiec takze przy bledach).
# Aplikacja renderuje tresc HTML tworzona przez administratora
# (tresc strony, banery, szablon certyfikatu) przez dangerouslySetInnerHTML.
# HtmlSanitizer po stronie backendu jest pierwsza bariera, a CSP druga,
# niezalezna warstwa: nawet gdyby cos przeszlo sanityzacje, przegladarka
# nie wykona wstrzyknietego skryptu z obcego zrodla.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(self), camera=(self)" always;
# CSP dobrana do faktycznych zaleznosci aplikacji:
# - script-src 'self': zadnego skryptu z obcej domeny, blokuje wstrzykniecia.
# 'wasm-unsafe-eval' jest wymagane przez klienta LiveKit (dekodery WASM).
# - style-src 'unsafe-inline': React, Tailwind, TipTap i LiveKit wstrzykuja
# style inline; Google Fonts dostarcza arkusz z fonts.googleapis.com.
# - font-src: pliki fontow IBM Plex z fonts.gstatic.com.
# - img-src data:/blob:: obrazki edytora i miniatury; https: okladki lekcji.
# - connect-src: API i WebSocket ida na wlasny host (ws:/wss:); LiveKit laczy
# sie z wlasnym serwerem, wiec dopuszczamy dowolny wss:.
# - frame-src: pokoj wideo (LiveKit/Jitsi) oraz wideo lekcji (external_video_url,
# dowolny host http/https) osadzane sa w iframe.
# - frame-ancestors 'self': panel osadza wlasne iframe, wiec 'self' zamiast
# pelnego zakazu; chroni przed clickjackingiem z obcej strony.
# - object-src 'none', base-uri 'self': domkniecie klasycznych wektorow.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; connect-src 'self' ws: wss:; frame-src 'self' https: http:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" always;
# Interfejs jest po polsku, wiec kazda odpowiedz tekstowa musi jawnie
# deklarowac UTF-8. Bez tego naglowek to samo "application/javascript",
# a przegladarka zgaduje kodowanie pakietu, w ktorym siedza polskie znaki.
# charset_types domyslnie nie obejmuje JavaScriptu, stad jawna lista.
charset utf-8;
charset_types text/html text/css text/plain text/xml
application/javascript application/json image/svg+xml;
# Strona webinaru. Robot dostaje zajawke z backendu, czlowiek aplikacje.
# Rozgalezienie przez error_page zamiast if, bo "if" w kontekscie location
# w polaczeniu z try_files zachowuje sie nieprzewidywalnie.
location ~ ^/w/(?<webinar_slug>.+)$ {
error_page 418 = @crawler_preview;
recursive_error_pages on;
if ($is_crawler) {
return 418;
}
try_files $uri /index.html;
}
location @crawler_preview {
proxy_pass http://backend:8080/api/preview/w/$webinar_slug;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA: kazda sciezka nieodpowiadajaca plikowi trafia do index.html
location / {
try_files $uri $uri/ /index.html;
}
# Otwarte endpointy bez plikow: logowanie i webhooki. Male zadania JSON/XML,
# wiec twardy limit 1 MB. Sa dostepne bez uwierzytelnienia, wiec to one sa
# najtanszym celem do zalania duzym zadaniem. Prefiks `^~` wygrywa ze zwyklym
# `/api/` i nie pozwala regexpom nizej przejac tych sciezek.
location ^~ /api/auth/ {
proxy_pass http://backend:8080/api/auth/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1m;
}
location ^~ /api/webhooks/ {
proxy_pass http://backend:8080/api/webhooks/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1m;
}
# Wgrywanie i serwowanie DUZYCH plikow: nagrania i materialy webinaru.
# Tylko te sciezki dostaja limit liczony w gigabajtach; reszta API zostaje
# przy limicie posrednim, a otwarte endpointy przy 1 MB z poziomu server.
# Regexp lapie zarowno wgranie (POST na /recordings, /materials), jak i
# pobranie (/api/public/recordings/{id}, /api/public/materials/{id}).
location ~ ^/api/(admin/webinars/[0-9]+/(recordings|materials)|public/(recordings|materials)/) {
proxy_pass http://backend:8080$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Nagranie potrafi miec 2 GB. Wartosc nie mniejsza niz RECORDING_MAX_SIZE_MB.
client_max_body_size 2100m;
# Wgranie i pobranie duzego pliku trwaja dluzej niz domyslne 60 s.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Pliki ida strumieniowo, bez buforowania calosci przed oddaniem.
proxy_buffering off;
proxy_request_buffering off;
}
# proxy do backendu, nazwa uslugi z docker compose
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Posredni limit dla reszty API: obrazki tresci, branding, import CSV.
# Wyzszy niz MEDIA_MAX_SIZE_MB (domyslnie 5), z zapasem na branding.
client_max_body_size 25m;
# Pobranie nagrania z tej trasy nie idzie (ma wlasna lokalizacje wyzej),
# ale zostawiamy wydluzony odczyt na dluzsze operacje panelu.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# WebSocket ze STOMP do czatu, pytan i zgloszen do zabrania glosu
location /ws {
proxy_pass http://backend:8080/api/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
}
+5306
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "webinar-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@jitsi/react-sdk": "^1.4.4",
"@livekit/components-react": "^2.9.24",
"@livekit/components-styles": "^1.2.0",
"@mui/x-charts": "^9.12.0",
"@stomp/stompjs": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/react-query": "^5.62.11",
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@zoom/meetingsdk": "^6.2.0",
"axios": "^1.7.9",
"livekit-client": "^2.22.1",
"lucide-react": "^1.35.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1"
},
"//overrides": "@zoom/meetingsdk deklaruje sztywny peer react@18.2.0. Wymuszamy react z projektu (linia 18, w pelni zgodna), zeby npm ci nie przerywal bledem ERESOLVE. Celowane na react, wiec nie rozluznia rozwiazywania pozostalych peer deps (np. @mui/material dla @mui/x-charts).",
"overrides": {
"@zoom/meetingsdk": {
"react": "$react",
"react-dom": "$react-dom"
}
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"vite": "^6.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+403
View File
@@ -0,0 +1,403 @@
import { Suspense, lazy } from 'react'
import type { ReactNode } from 'react'
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'
import { RequireAdmin, RequireAuth } from './auth/Guards'
import { Spinner } from './components/ui'
import { LEGACY_PANEL_SEGMENTS, PANEL_PREFIX, STUDENT_ROUTES, panelPath } from './lib/routes'
/*
* Pulpit ladowany leniwie, bo ciagnie za soba @mui/x-charts.
* Biblioteka wykresow wazy wiecej niz cala reszta panelu, a jest potrzebna
* wylacznie na dwoch ekranach: tutaj i w statystykach webinaru. Bez podzialu
* kazde wejscie do panelu pobieraloby ja niepotrzebnie.
*/
const DashboardPage = lazy(() =>
import('./pages/DashboardPage').then((m) => ({ default: m.DashboardPage })))
import { Layout } from './components/Layout'
import { LoginPage } from './pages/LoginPage'
import { LegalPage } from './pages/public/LegalPage'
import { CookieBanner } from './components/CookieBanner'
import { RecordingTemplate } from './pages/public/RecordingTemplate'
import { MeetingPage } from './pages/public/MeetingPage'
import { MyRegistrationPage } from './pages/public/MyRegistrationPage'
import { PublicWebinarPage } from './pages/public/PublicWebinarPage'
import { UnsubscribePage } from './pages/public/UnsubscribePage'
import { CertificateVerifyPage } from './pages/public/CertificateVerifyPage'
import { ContactListPage } from './pages/contacts/ContactListPage'
import { WebinarListPage } from './pages/webinars/WebinarListPage'
import { WebinarFormPage } from './pages/webinars/WebinarFormPage'
import { RegistrationListPage } from './pages/webinars/RegistrationListPage'
import { HostConsolePage } from './pages/webinars/HostConsolePage'
import { BannersPage } from './pages/banners/BannersPage'
import { CourseListPage } from './pages/courses/CourseListPage'
import { CourseFormPage } from './pages/courses/CourseFormPage'
import { CourseDetailPage } from './pages/courses/CourseDetailPage'
import { StudentListPage } from './pages/courses/StudentListPage'
import { StudentDetailPage } from './pages/courses/StudentDetailPage'
import { ProgressPage } from './pages/courses/ProgressPage'
import { CertificateOverviewPage } from './pages/courses/CertificateOverviewPage'
import { OrderListPage } from './pages/orders/OrderListPage'
import { OffersPage } from './pages/webinars/OffersPage'
import { WebinarPreviewPage } from './pages/webinars/WebinarPreviewPage'
import { SettingsLayout } from './pages/settings/SettingsLayout'
import { AppSettingsPage } from './pages/settings/AppSettingsPage'
import { BrandingSettingsPage } from './pages/settings/BrandingSettingsPage'
import { MailSettingsPage } from './pages/settings/MailSettingsPage'
import { SmsSettingsPage } from './pages/settings/SmsSettingsPage'
import { VideoSettingsPage } from './pages/settings/VideoSettingsPage'
import { ReminderSettingsPage } from './pages/settings/ReminderSettingsPage'
import { ConsentSettingsPage } from './pages/settings/ConsentSettingsPage'
import { CompanySettingsPage } from './pages/settings/CompanySettingsPage'
import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
import { UserListPage } from './pages/users/UserListPage'
import {
AcademyChoicePage,
AcademyPasswordResetPage,
} from './academy/AcademyLoginPage'
import { NotFoundPage } from './pages/NotFoundPage'
import {
AcademyNewPasswordPage,
AcademyRegisterPage,
AcademyVerifyPage,
} from './academy/AcademyRegisterPage'
import { RequireStudent } from './academy/RequireStudent'
import { StudentDashboardPage } from './academy/StudentDashboardPage'
/*
* Publiczna czesc Akademii ladowana leniwie, tak samo jak wykresy i biblioteki
* wideo. Panel i Akademia to dwie rozne aplikacje dla dwoch roznych osob:
* administrator nigdy nie otwiera strony kursu, a kursant nigdy nie otwiera
* panelu, wiec doklejanie jednego do drugiego oznaczaloby, ze kazdy pobiera
* dwa razy wiecej, niz uzywa. Bez podzialu glowny pakiet rosl z 859 kB do
* 1734 kB.
*
* Aliasy w nazwach: panel ma juz wlasne CourseListPage i WebinarListPage.
*/
const AcademyHomePage = lazy(() =>
import('./academy/AcademyHomePage').then((m) => ({ default: m.AcademyHomePage })))
const AcademyCourseListPage = lazy(() =>
import('./academy/CourseListPage').then((m) => ({ default: m.CourseListPage })))
const AcademyWebinarListPage = lazy(() =>
import('./academy/CourseListPage').then((m) => ({ default: m.WebinarListPage })))
const AcademyPricingPage = lazy(() =>
import('./academy/CourseListPage').then((m) => ({ default: m.PricingPage })))
const CoursePage = lazy(() =>
import('./academy/CoursePage').then((m) => ({ default: m.CoursePage })))
const CoursePlayerPage = lazy(() =>
import('./academy/CoursePlayerPage').then((m) => ({ default: m.CoursePlayerPage })))
const CheckoutPage = lazy(() =>
import('./academy/CheckoutPage').then((m) => ({ default: m.CheckoutPage })))
const PaymentReturnPage = lazy(() =>
import('./academy/PaymentReturnPage').then((m) => ({ default: m.PaymentReturnPage })))
const QuizPage = lazy(() =>
import('./academy/QuizPage').then((m) => ({ default: m.QuizPage })))
/** Opakowanie leniwej trasy: jedno miejsce zamiast Suspense przy kazdej. */
function Lazy({ children }: { children: ReactNode }) {
return <Suspense fallback={<Spinner />}>{children}</Suspense>
}
/**
* Przekierowanie starego adresu panelu pod prefiks /panel.
*
* Przepisuje CALA sciezke wraz z parametrami zapytania i kotwica, zeby
* zapisana zakladka na konkretny webinar albo na strone listy z filtrem
* prowadzila w to samo miejsce co wczesniej, a nie na sam pulpit.
*/
function LegacyPanelRedirect() {
const location = useLocation()
return (
<Navigate
to={panelPath(location.pathname) + location.search + location.hash}
replace
/>
)
}
/**
* Przekierowanie starego adresu z tokenem w sciezce.
*
* Token przepisujemy bez zmian: link z wiadomosci e-mail jest jednorazowy
* i wygasa, wiec zgubienie go przy przekierowaniu oznaczaloby, ze kursant
* musi prosic o nowy.
*/
function LegacyTokenRedirect({ to }: { to: string }) {
const { token } = useParams()
return <Navigate to={`${to}/${token ?? ''}`} replace />
}
export function App() {
return (
<>
<Routes>
{/*
Trasy publiczne. Poza RequireAuth i poza ukladem panelu, korzystaja
z osobnego klienta axios bez naglowka autoryzacji.
*/}
<Route path="/w/:slug" element={<PublicWebinarPage />} />
<Route path="/zapis/:token" element={<MyRegistrationPage />} />
{/* wejscie do pokoju: token uczestnika albo prelegenta, rozpoznawany po stronie strony */}
<Route path="/spotkanie/:token" element={<MeetingPage />} />
{/*
Strona, ktora nagrywa Egress. Nie jest dla ludzi: pokazuje sam obraz
spotkania, bez panelu interakcji i banerow. Token przychodzi w adresie,
bo Egress otwiera ja wlasna przegladarka i nie ma jak sie uwierzytelnic.
*/}
<Route path="/nagrywanie" element={<RecordingTemplate />} />
{/* wypisanie z marketingu: bez logowania, token z linku w stopce maila */}
<Route path="/wypisanie/:token" element={<UnsubscribePage />} />
{/*
Weryfikacja certyfikatu, bez logowania. Wzorzec z gwiazdka, bo domyslny
format numeru to "2026/1": ukosnik rozbilby zwykly parametr na dwa
segmenty i trasa nigdy by sie nie dopasowala.
*/}
<Route path="/certyfikat/*" element={<CertificateVerifyPage />} />
{/* Dokumenty prawne: tresc z konfiguracji instancji */}
<Route path="/regulamin" element={<LegalPage kind="terms" />} />
<Route path="/polityka-prywatnosci" element={<LegalPage kind="privacy" />} />
<Route path="/logowanie" element={<LoginPage />} />
{/*
Publiczna czesc Akademii. KORZEN NALEZY DO GOSCIA: pod "/" jest strona
glowna, dostepna bez logowania. Wczesniej korzen nalezal do pulpitu
panelu, wiec niezalogowany gosc wchodzacy na adres instancji dostawal
formularz logowania administratora zamiast oferty szkolen.
*/}
<Route path="/" element={<Lazy><AcademyHomePage /></Lazy>} />
<Route path="/kursy" element={<Lazy><AcademyCourseListPage /></Lazy>} />
<Route path="/webinary" element={<Lazy><AcademyWebinarListPage /></Lazy>} />
<Route path="/cennik" element={<Lazy><AcademyPricingPage /></Lazy>} />
<Route path="/kurs/:slug" element={<Lazy><CoursePage /></Lazy>} />
<Route
path="/kurs/:slug/nauka"
element={
<RequireStudent>
<Lazy><CoursePlayerPage /></Lazy>
</RequireStudent>
}
/>
<Route
path="/kurs/:slug/zakup"
element={
<RequireStudent>
<Lazy><CheckoutPage /></Lazy>
</RequireStudent>
}
/>
<Route
path="/kurs/:slug/platnosc"
element={
<RequireStudent>
<Lazy><PaymentReturnPage /></Lazy>
</RequireStudent>
}
/>
{/*
Sprawdzian konczacy lekcje. Adres idzie po identyfikatorze LEKCJI,
a nie kursu: quiz nalezy do lekcji, a odtwarzacz linkuje tu wprost
z pozycji programu.
*/}
<Route
path="/kurs/lekcja/:lessonId/sprawdzian"
element={
<RequireStudent>
<Lazy><QuizPage /></Lazy>
</RequireStudent>
}
/>
{/*
Konta kursantow na korzeniu. Rejestracja, przypomnienie hasla
i potwierdzenie adresu dotycza WYLACZNIE kont uczestnikow: konta panelu
zaklada administrator instancji.
*/}
<Route path={STUDENT_ROUTES.register} element={<AcademyRegisterPage />} />
<Route path={STUDENT_ROUTES.verify + '/:token'} element={<AcademyVerifyPage />} />
<Route path={STUDENT_ROUTES.passwordReset} element={<AcademyPasswordResetPage />} />
<Route path={STUDENT_ROUTES.newPassword + '/:token'} element={<AcademyNewPasswordPage />} />
{/* wybor sciezki, gdy adres ma konto w obu swiatach */}
<Route
path={STUDENT_ROUTES.choice}
element={
<RequireStudent>
<AcademyChoicePage />
</RequireStudent>
}
/>
<Route
path={STUDENT_ROUTES.dashboard}
element={
<RequireStudent>
<StudentDashboardPage />
</RequireStudent>
}
/>
{/*
PANEL ADMINISTRACYJNY POD WLASNYM PREFIKSEM.
---------------------------------------------------------------------
Wszystkie trasy panelu sa zagniezdzone pod /panel. Sciezki wewnatrz sa
wzgledne, wiec prefiks wystepuje dokladnie raz i zmiana jego nazwy jest
zmiana jednej stalej w lib/routes.ts.
*/}
<Route
path={PANEL_PREFIX}
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
{/* pulpit: pierwsze, co widzi zalogowany uzytkownik panelu */}
<Route
index
element={
<Suspense fallback={<Spinner />}>
<DashboardPage />
</Suspense>
}
/>
<Route path="pulpit" element={<Navigate to={panelPath()} replace />} />
<Route path="webinary" element={<WebinarListPage />} />
<Route path="webinary/nowy" element={<WebinarFormPage />} />
<Route path="webinary/:id/edycja" element={<WebinarFormPage />} />
{/* szczegoly otwierane w nowej karcie, wiec musza miec wlasny adres */}
<Route path="webinary/:id" element={<WebinarPreviewPage />} />
<Route path="webinary/:id/zapisy" element={<RegistrationListPage />} />
<Route path="webinary/:id/prowadzenie" element={<HostConsolePage />} />
{/*
Kursy Akademii w panelu. Trasa szczegolow jest po kursy/nowy, ale
kolejnosc i tak nie decyduje: React Router wyzej ocenia segment staly
niz parametr.
*/}
<Route path="kursy" element={<CourseListPage />} />
<Route path="kursy/nowy" element={<CourseFormPage />} />
<Route path="kursy/kursanci" element={<StudentListPage />} />
<Route path="kursy/kursanci/:id" element={<StudentDetailPage />} />
<Route path="kursy/postepy" element={<ProgressPage />} />
<Route path="kursy/certyfikaty" element={<CertificateOverviewPage />} />
<Route path="kursy/:id" element={<CourseDetailPage />} />
<Route path="kursy/:id/edycja" element={<CourseFormPage />} />
<Route path="zamowienia" element={<OrderListPage />} />
<Route path="kontakty" element={<ContactListPage />} />
<Route path="zapytania" element={<OffersPage />} />
{/*
Banery globalne dotycza calej instancji, tak samo jak ustawienia,
wiec zarzadza nimi wylacznie ADMIN. Backend jest zrodlem prawdy,
RequireAdmin tylko oszczedza uzytkownikowi bledu 403.
*/}
<Route
path="banery"
element={
<RequireAdmin>
<BannersPage />
</RequireAdmin>
}
/>
{/*
Ustawienia sa podzielone na podstrony, kazdy obszar ma wlasny adres.
RequireAdmin siedzi na trasie nadrzednej, wiec obejmuje wszystkie
podstrony naraz i nie da sie dodac nowej bez ochrony.
*/}
<Route
path="ustawienia"
element={
<RequireAdmin>
<SettingsLayout />
</RequireAdmin>
}
>
<Route index element={<AppSettingsPage />} />
<Route path="grafika" element={<BrandingSettingsPage />} />
<Route path="email" element={<MailSettingsPage />} />
<Route path="sms" element={<SmsSettingsPage />} />
<Route path="wideo" element={<VideoSettingsPage />} />
<Route path="przypomnienia" element={<ReminderSettingsPage />} />
<Route path="zgody" element={<ConsentSettingsPage />} />
<Route path="dane-firmy" element={<CompanySettingsPage />} />
<Route path="strona-glowna" element={<HomePageSettingsPage />} />
{/* nieznana podstrona wraca na pierwsza zakladke, nie na liste webinarow */}
<Route path="*" element={<Navigate to={panelPath('/ustawienia')} replace />} />
</Route>
<Route
path="uzytkownicy"
element={
<RequireAdmin>
<UserListPage />
</RequireAdmin>
}
/>
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
<Route path="*" element={<Navigate to={panelPath()} replace />} />
</Route>
{/*
PRZEKIEROWANIA ZE STARYCH ADRESOW PANELU.
---------------------------------------------------------------------
Zapisane zakladki i linki w wiadomosciach nie moga prowadzic w pustke.
Kazdy stary segment panelu dostaje dwie trasy: dokladne dopasowanie
segmentu i wariant z reszta sciezki, przepisywana bez zmian.
"/KURSY" I "/WEBINARY" CELOWO NIE SA NA TEJ LISCIE.
---------------------------------------------------------------------
Oba adresy naleza teraz do publicznych list (deklaracje wyzej). Kolizje
rozstrzygnieto na korzysc czesci publicznej: sa to adresy krotkie
i trafiajace na zewnatrz, wiec maja pokazywac oferte, a nie ekran dla
kilku osob w firmie.
Jest to swiadoma strata: administrator z zapisana zakladka trafi na
oferte zamiast do panelu. Nie da sie tego rozstrzygnac trasa, bo pod
jednym adresem nie moga stac dwie rozne strony. Lagodzi to pasek
`PanelMovedNotice` na obu listach, widoczny wylacznie dla rol panelu.
Dopasowanie i tak obejmuje PELNY SEGMENT, nigdy sam prefiks: gdyby
kiedys wrocil tu segment "kursy", dopasowanie po prefiksie lapaloby
takze publiczne "/kurs/:slug".
*/}
{LEGACY_PANEL_SEGMENTS.map((segment) => (
<Route key={segment} path={'/' + segment}>
<Route index element={<LegacyPanelRedirect />} />
<Route path="*" element={<LegacyPanelRedirect />} />
</Route>
))}
{/* dawna strona glowna Akademii i jej podstrony */}
<Route path="/akademia" element={<Navigate to="/" replace />} />
<Route path="/akademia/kursy" element={<Navigate to="/kursy" replace />} />
<Route path="/akademia/webinary" element={<Navigate to="/webinary" replace />} />
<Route path="/akademia/cennik" element={<Navigate to="/cennik" replace />} />
{/* dawne trasy kont kursantow */}
<Route path="/akademia/logowanie" element={<Navigate to={STUDENT_ROUTES.login} replace />} />
<Route
path="/akademia/rejestracja"
element={<Navigate to={STUDENT_ROUTES.register} replace />}
/>
<Route
path="/akademia/odzyskiwanie-hasla"
element={<Navigate to={STUDENT_ROUTES.passwordReset} replace />}
/>
<Route
path="/akademia/potwierdzenie/:token"
element={<LegacyTokenRedirect to={STUDENT_ROUTES.verify} />}
/>
<Route
path="/akademia/nowe-haslo/:token"
element={<LegacyTokenRedirect to={STUDENT_ROUTES.newPassword} />}
/>
<Route path="/akademia/wybor" element={<Navigate to={STUDENT_ROUTES.choice} replace />} />
{/*
Nieznany adres pokazuje strone 404, a NIE przekierowuje na korzen.
Ciche przekierowanie na strone glowna ukrywa literowke w adresie
i wyglada jak awaria: uzytkownik nie wie, czy trafil zle, czy strona
przestala istniec.
*/}
<Route path="*" element={<NotFoundPage />} />
</Routes>
<CookieBanner />
</>
)
}
+204
View File
@@ -0,0 +1,204 @@
import { Link } from 'react-router-dom'
import { Icon } from '../components/Icon'
import type { IconName } from '../components/Icon'
import { Badge } from '../components/ui'
import type { CourseLevel, LessonType, PublicCourseCard } from '../api/types'
/**
* Drobne czesci wspolne dla stron Akademii.
*
* Kafelek kursu pokazuje sie na stronie glownej, na liscie kursow i w cenniku,
* a pasek postepu na pulpicie i w odtwarzaczu. Trzy kopie tego samego kafelka
* rozjechalyby sie przy pierwszej poprawce.
*/
export const LEVEL_LABEL: Record<CourseLevel, string> = {
BASIC: 'Podstawowy',
INTERMEDIATE: 'Średniozaawansowany',
ADVANCED: 'Zaawansowany',
}
export const LESSON_TYPE_LABEL: Record<LessonType, string> = {
VIDEO: 'Nagranie',
TEXT: 'Materiał do czytania',
FILE: 'Plik do pobrania',
WEBINAR: 'Webinar',
QUIZ: 'Sprawdzian',
}
export const LESSON_TYPE_ICON: Record<LessonType, IconName> = {
VIDEO: 'play',
TEXT: 'file-text',
FILE: 'download',
WEBINAR: 'presentation',
QUIZ: 'list-checks',
}
/**
* Cena brutto albo oznaczenie kursu darmowego.
*
* Brak ceny znaczy "darmowy", a nie "cena zero": to dwie rozne decyzje,
* rozstrzygniete po stronie backendu przez `Course.isFree()`.
*/
export function formatPrice(price: string | number | null, currency: string) {
if (price === null || price === undefined) {
return 'Bezpłatny'
}
const value = typeof price === 'string' ? Number(price) : price
if (!Number.isFinite(value)) {
return 'Bezpłatny'
}
return new Intl.NumberFormat('pl-PL', {
style: 'currency',
currency: currency || 'PLN',
minimumFractionDigits: 2,
}).format(value)
}
/** Czas trwania w formie, w ktorej mysli czlowiek: godziny i minuty. */
export function formatCourseDuration(minutes: number | null) {
if (!minutes || minutes <= 0) {
return null
}
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`
}
/** Adres okladki. Pusty identyfikator oznacza kafelek bez obrazka. */
export function mediaUrl(mediaId: number | null) {
return mediaId ? `/api/public/media/${mediaId}` : null
}
export function CourseCover({
mediaId,
title,
className = '',
}: {
mediaId: number | null
title: string
className?: string
}) {
const url = mediaUrl(mediaId)
if (!url) {
return (
<div
className={`flex items-center justify-center bg-gradient-hydro ${className}`}
aria-hidden="true"
>
<Icon name="graduation-cap" size={32} className="text-white/70" />
</div>
)
}
return (
<img
src={url}
alt={`Okładka kursu ${title}`}
loading="lazy"
className={`object-cover ${className}`}
/>
)
}
/** Kafelek kursu na liscie. */
export function CourseCard({ course }: { course: PublicCourseCard }) {
const duration = formatCourseDuration(course.durationMinutes)
return (
<Link
to={`/kurs/${course.slug}`}
className="group flex flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm transition-shadow duration-fast ease-standard hover:shadow-md focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-blue-500/45"
>
<CourseCover mediaId={course.coverMediaId} title={course.title} className="h-40 w-full" />
<div className="flex flex-1 flex-col p-4">
<div className="flex flex-wrap items-center gap-1.5">
{course.category && <Badge tone="info">{course.category}</Badge>}
{course.free ? (
<Badge tone="ok">Bezpłatny</Badge>
) : (
<Badge tone="brand">{formatPrice(course.priceGross, course.currency)}</Badge>
)}
</div>
<h3 className="mt-2.5 text-base font-semibold leading-snug text-slate-900 group-hover:text-blue-600">
{course.title}
</h3>
{course.subtitle && (
<p className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-600">
{course.subtitle}
</p>
)}
<div className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-3 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<Icon name="book-open" size={14} />
<span className="hx-data">{course.lessonCount}</span>
{lessonWord(course.lessonCount)}
</span>
{duration && (
<span className="flex items-center gap-1.5">
<Icon name="clock" size={14} />
<span className="hx-data">{duration}</span>
</span>
)}
{course.level && <span>{LEVEL_LABEL[course.level]}</span>}
{course.certificateEnabled && (
<span className="flex items-center gap-1.5 text-blue-600">
<Icon name="award" size={14} />
Certyfikat
</span>
)}
</div>
</div>
</Link>
)
}
/** Odmiana slowa "lekcja" przez liczbe: 1 lekcja, 2 lekcje, 5 lekcji. */
export function lessonWord(count: number) {
if (count === 1) return 'lekcja'
const rest10 = count % 10
const rest100 = count % 100
if (rest10 >= 2 && rest10 <= 4 && (rest100 < 12 || rest100 > 14)) return 'lekcje'
return 'lekcji'
}
/** Pasek postepu kursu. Liczba pochodzi z backendu, z lekcji wymaganych. */
export function ProgressBar({
percent,
label,
className = '',
}: {
percent: number
label?: string
className?: string
}) {
const safe = Math.max(0, Math.min(100, percent))
return (
<div className={className}>
{label && (
<div className="mb-1.5 flex items-center justify-between text-xs text-slate-600">
<span>{label}</span>
<span className="hx-data font-medium text-slate-900">{safe}%</span>
</div>
)}
<div
className="h-2 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={safe}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label || 'Postęp kursu'}
>
<div
className="h-full rounded-full bg-blue-500 transition-[width] duration-base ease-standard"
style={{ width: `${safe}%` }}
/>
</div>
</div>
)
}
+507
View File
@@ -0,0 +1,507 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { publicClient } from '../api/publicClient'
import type { AcademyHome, SectionItem } from '../api/types'
import { Icon } from '../components/Icon'
import { Button, ErrorState, Spinner } from '../components/ui'
import { useDocumentMeta } from '../lib/useDocumentMeta'
import { AcademyLayout } from './AcademyLayout'
import { CourseCard, formatPrice, mediaUrl } from './AcademyBits'
import { HomeWebinarCard } from './HomeWebinarCard'
import { useStudentAuth } from './StudentAuthContext'
import { STUDENT_ROUTES, panelPath } from '../lib/routes'
import { AdminEmptyHint, AdminSectionHint, SampleBadge } from './AdminHint'
type PriceFilter = 'all' | 'free' | 'paid'
/**
* Strona glowna Akademii.
*
* Cala tresc pochodzi z backendu: liczniki licza sie z bazy, a naglowki
* i sekcje statyczne z ustawien oraz z tabeli `page_sections`. W kodzie nie ma
* ani jednej liczby ani zdania, ktorego administrator nie moglby zmienic
* bez wdrozenia.
*
* RYTM TLA WEDLUG MAKIETY.
* ---------------------------------------------------------------------------
* Sekcje maja naprzemienne tla: biale, szare, ciemne granatowe przy
* prowadzacych. Bez tego strona byla plaska i nie bylo widac, gdzie konczy sie
* jedna sekcja, a zaczyna druga. Tla ida na pelna szerokosc okna, a tresc
* zostaje w kontenerze `max-w-container`, wiec `AcademyLayout` dostaje
* `bleed`: inaczej pasy koloru urywalyby sie na 1200 px.
*/
export function AcademyHomePage() {
const { student } = useStudentAuth()
const [filter, setFilter] = useState<PriceFilter>('all')
const [category, setCategory] = useState<string>('')
const { data, isLoading, isError } = useQuery({
queryKey: ['academy', 'home'],
queryFn: async () => (await publicClient.get<AcademyHome>('/public/academy/home')).data,
})
useDocumentMeta({
title: data?.heroTitle ?? 'Akademia',
description: data?.heroText,
})
const courses = useMemo(() => {
if (!data) return []
return data.courses.filter((c) => {
if (filter === 'free' && !c.free) return false
if (filter === 'paid' && c.free) return false
if (category && c.category !== category) return false
return true
})
}, [data, filter, category])
if (isLoading) {
return (
<AcademyLayout>
<Spinner label="Wczytywanie Akademii..." />
</AcademyLayout>
)
}
if (isError || !data) {
return (
<AcademyLayout>
<ErrorState message="Nie udało się wczytać strony. Spróbuj odświeżyć." />
</AcademyLayout>
)
}
/*
* Liczniki wedlug makiety: godziny materialu, kursanci, ukonczone szkolenia,
* kursy. Kazdy opisuje WYLACZNIE to, co gosc moze zobaczyc.
*
* ZERO SIE NIE POKAZUJE. Licznik "0 kursow" na stronie zachecajacej do nauki
* chwali sie pustka. Filtrowanie tutaj, a nie w komponencie: siatka
* zostawialaby po ukrytym liczniku pusta kolumne.
*
* Zdawalnosci z makiety tu nie ma: policzona z malej liczby podejsc jest
* przypadkowa, a przy zerze zdanych bylaby zniechecajaca. Wroci, gdy bedzie
* co uśredniać.
*/
const visibleStats = [
{ value: data.stats.hours, label: 'godzin materiału' },
{ value: data.stats.students, label: 'kursantów' },
{ value: data.stats.completed, label: 'ukończonych szkoleń' },
{ value: data.stats.courses, label: 'kursów online' },
].filter((s) => s.value > 0)
/** Kursy platne do sekcji cennika. Brak platnych ukrywa cala sekcje. */
const paidCourses = data.courses.filter((c) => !c.free)
const filters: { id: PriceFilter; label: string }[] = [
{ id: 'all', label: 'Wszystkie' },
{ id: 'free', label: 'Darmowe' },
{ id: 'paid', label: 'Płatne' },
]
return (
<AcademyLayout
bleed
footer={{ text: data.footerText, termsUrl: data.termsUrl, privacyUrl: data.privacyUrl }}
>
<>
{/* ================= HERO: dwie kolumny, biale tlo ================= */}
<Band tone="white">
<div className="grid items-center gap-10 py-12 sm:py-16 lg:grid-cols-[1.1fr_1fr] lg:gap-14">
<div>
{/* Etykieta nad naglowkiem. Pusta wartosc nie zostawia odstepu. */}
{data.heroEyebrow && (
<p className="hx-eyebrow text-blue-600">{data.heroEyebrow}</p>
)}
<h1 className="mt-3 text-3xl font-bold leading-tight tracking-tight text-navy-700 sm:text-4xl lg:text-5xl">
{data.heroTitle}
</h1>
{data.heroText && (
<p className="mt-4 max-w-xl text-base leading-relaxed text-slate-600 sm:text-lg">
{data.heroText}
</p>
)}
<div className="mt-7 flex flex-wrap gap-3">
<Link to={student ? STUDENT_ROUTES.dashboard : STUDENT_ROUTES.register}>
<Button size="lg" icon={student ? 'graduation-cap' : 'user-plus'}>
{student ? 'Moje kursy' : 'Zacznij za darmo'}
</Button>
</Link>
{/* Drugi przycisk z makiety: droga do webinarow, nie do kursow.
Gosc, ktorego nie interesuje kurs, ma dokad pojsc. */}
<Link to="/webinary">
<Button size="lg" variant="secondary" icon="presentation">
Program webinarów
</Button>
</Link>
</div>
{visibleStats.length > 0 && (
<dl className="mt-9 flex flex-wrap gap-x-10 gap-y-5">
{visibleStats.map((s) => (
<Stat key={s.label} value={s.value} label={s.label} />
))}
</dl>
)}
</div>
{/*
Panel wizualny z makiety. Na wask
im ekranie kolumny ukladaja sie
jedna pod druga, a panel schodzi pod tresc: `order` zostawiamy
domyslny, bo tresc jest wazniejsza od ozdoby.
*/}
<div
aria-hidden="true"
className="relative hidden h-72 overflow-hidden rounded-xl bg-gradient-hydro shadow-lg lg:block lg:h-96"
>
{/* Motyw wodny systemu HydroNexis: kregi rozchodzace sie od kropli. */}
<span className="absolute left-1/2 top-1/2 h-40 w-40 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/20" />
<span className="absolute left-1/2 top-1/2 h-64 w-64 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/15" />
<span className="absolute left-1/2 top-1/2 h-[22rem] w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/10" />
<span className="absolute left-1/2 top-1/2 flex h-20 w-20 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 backdrop-blur">
<Icon name="droplet" size={34} className="text-white" />
</span>
</div>
</div>
</Band>
{/* ================= KURSY: tlo szare ================= */}
<Band tone="muted" id="kursy">
<div className="py-12 sm:py-14">
<div className="flex flex-wrap items-end justify-between gap-4">
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">Kursy online</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="flex rounded-md border border-slate-200 bg-white p-0.5">
{filters.map((f) => (
<button
key={f.id}
type="button"
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={[
'min-h-9 rounded px-3 text-sm font-medium transition-colors duration-fast',
filter === f.id
? 'bg-blue-500 text-white'
: 'text-slate-600 hover:bg-slate-50',
].join(' ')}
>
{f.label}
</button>
))}
</div>
{data.categories.length > 0 && (
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
aria-label="Filtruj po kategorii"
className="min-h-11 rounded-md border border-slate-200 bg-white px-3 text-base text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-3 focus:ring-blue-500/45 sm:min-h-9 sm:text-sm"
>
<option value="">Wszystkie kategorie</option>
{data.categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
</div>
</div>
{courses.length === 0 ? (
<div className="mt-6 rounded-lg border border-dashed border-slate-300 bg-white p-8 text-center">
<p className="text-sm text-slate-500">
{data.courses.length === 0
? 'Kursy pojawią się wkrótce.'
: 'Brak kursów spełniających wybrane warunki.'}
</p>
{data.courses.length === 0 && (
<AdminEmptyHint to={panelPath('/kursy/nowy')}>
Dodaj pierwszy kurs
</AdminEmptyHint>
)}
</div>
) : (
<div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{courses.map((c) => (
<CourseCard key={c.id} course={c} />
))}
</div>
)}
</div>
</Band>
{/* ================= WEBINARY: tlo biale ================= */}
<Band tone="white" id="webinary">
<div className="py-12 sm:py-14">
{data.webinars.length === 0 ? (
<AdminSectionHint
flat
title="Nadchodzące webinary"
what="Pojawią się tu webinary opublikowane, których termin jeszcze nie minął. Wydarzenia zakończone nie są pokazywane."
to={panelPath('/webinary/nowy')}
linkLabel="Zaplanuj webinar"
/>
) : (
<>
<div className="flex flex-wrap items-end justify-between gap-4">
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
Nadchodzące webinary
</h2>
<Link to="/webinary" className="text-sm font-medium text-blue-600 hover:underline">
Zobacz wszystkie
</Link>
</div>
<div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{data.webinars.map((w) => (
<HomeWebinarCard key={w.id} webinar={w} />
))}
</div>
</>
)}
</div>
</Band>
{/* ================= PROWADZACY: tlo ciemne granatowe ================= */}
{data.experts.length === 0 ? (
<Band tone="muted">
<div className="py-12 sm:py-14">
<AdminSectionHint
flat
title="Uczysz się od praktyków"
what="Osoby prowadzące szkolenia: imię, specjalność, krótki opis i zdjęcie. Nie zakładamy tu przykładowych osób, bo wymyślony prowadzący trafiłby na produkcję jako prawdziwy."
to={panelPath('/ustawienia/strona-glowna')}
/>
</div>
</Band>
) : (
<Band tone="dark">
<div className="grid items-center gap-10 py-12 sm:py-14 lg:grid-cols-[1fr_1.4fr] lg:gap-12">
<div>
<h2 className="text-2xl font-semibold text-white sm:text-3xl">
{data.expertsTitle}
</h2>
{data.expertsText && (
<p className="mt-3 text-sm leading-relaxed text-slate-300 sm:text-base">
{data.expertsText}
</p>
)}
</div>
<div className="grid gap-3.5 sm:grid-cols-2">
{data.experts.map((e) => (
<ExpertCard key={e.id} item={e} />
))}
</div>
</div>
</Band>
)}
{/* ============ OPINIE I PYTANIA: dwie kolumny, tlo biale ============ */}
<Band tone="white">
<div className="grid items-start gap-10 py-12 sm:py-14 lg:grid-cols-2 lg:gap-14">
{/* --- opinie --- */}
<div>
{data.reviews.length === 0 ? (
<AdminSectionHint
flat
title={data.reviewsTitle}
what="Opinie osób, które ukończyły szkolenie. Publikuj wyłącznie te, na które masz zgodę autora: wymyślona opinia jest nieprawdą o nieistniejącej osobie, dlatego nie zakładamy tu przykładów."
to={panelPath('/ustawienia/strona-glowna')}
/>
) : (
<>
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
{data.reviewsTitle}
</h2>
<div className="mt-6 space-y-4">
{data.reviews.map((r) => (
<figure
key={r.id}
className="rounded-lg border border-slate-100 bg-slate-25 p-5"
>
<Icon name="message-square" size={18} className="text-aqua-500" />
<blockquote className="mt-2.5 text-sm leading-relaxed text-slate-700">
{r.body}
</blockquote>
<figcaption className="mt-3.5 border-t border-slate-200 pt-3">
<p className="text-sm font-semibold text-slate-900">{r.title}</p>
{r.subtitle && <p className="text-xs text-slate-500">{r.subtitle}</p>}
</figcaption>
</figure>
))}
</div>
</>
)}
</div>
{/* --- FAQ --- */}
<div>
{data.faq.length === 0 ? (
<AdminSectionHint
flat
title={data.faqTitle}
what="Pytania i odpowiedzi w rozwijanej liście. Przy pierwszym uruchomieniu zakładamy trzy przykładowe pytania o działanie platformy."
to={panelPath('/ustawienia/strona-glowna')}
/>
) : (
<>
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">
{data.faqTitle}
</h2>
<div className="mt-6 divide-y divide-slate-100 overflow-hidden rounded-lg border border-slate-200 bg-white">
{data.faq.map((f) => (
<details key={f.id} className="group">
{/* Element details, a nie wlasny stan: dziala bez JavaScriptu,
obsluguje klawiature i jest czytany przez czytniki ekranu
bez dopisywania atrybutow aria. */}
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-sm font-medium text-slate-900 hover:bg-slate-25 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-blue-500/45">
<span className="min-w-0">
{f.title}
{f.sample && <SampleBadge to={panelPath('/ustawienia/strona-glowna')} />}
</span>
<Icon
name="chevron-down"
size={18}
className="shrink-0 text-slate-400 transition-transform duration-fast group-open:rotate-180"
/>
</summary>
{f.body && (
<div className="px-5 pb-4 text-sm leading-relaxed text-slate-600">
{f.body}
</div>
)}
</details>
))}
</div>
</>
)}
</div>
</div>
</Band>
{/* ================= CENNIK: tlo szare ================= */}
{paidCourses.length > 0 && (
<Band tone="muted" id="cennik">
<div className="py-12 sm:py-14">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">Cennik</h2>
<p className="mt-1.5 text-sm text-slate-600">
Płacisz za konkretny kurs, jednorazowo. Dostęp jest bezterminowy.
</p>
</div>
<Link to="/cennik" className="text-sm font-medium text-blue-600 hover:underline">
Pełny cennik
</Link>
</div>
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200 bg-white">
{paidCourses.slice(0, 5).map((c) => (
<Link
key={c.id}
to={`/kurs/${c.slug}`}
className="flex items-center justify-between gap-4 border-b border-slate-100 px-5 py-4 last:border-0 hover:bg-slate-25 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-blue-500/45"
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-slate-900">
{c.title}
</span>
{c.category && (
<span className="mt-0.5 block text-xs text-slate-500">{c.category}</span>
)}
</span>
<span className="hx-data shrink-0 text-sm font-semibold text-slate-900">
{formatPrice(c.priceGross, c.currency)}
</span>
</Link>
))}
</div>
</div>
</Band>
)}
</>
</AcademyLayout>
)
}
/**
* Pas sekcji: tlo na pelna szerokosc okna, tresc w kontenerze.
*
* Naprzemienne tla sa tym, co w makiecie oddziela sekcje od siebie. Bez nich
* strona jest plaska i nie widac, gdzie konczy sie jedna, a zaczyna druga.
* Kolor musi isc na pelna szerokosc, inaczej pas urywa sie na 1200 px
* i wyglada jak przypadkowy prostokat.
*/
function Band({
tone,
id,
children,
}: {
tone: 'white' | 'muted' | 'dark'
id?: string
children: React.ReactNode
}) {
const background =
tone === 'dark' ? 'bg-gradient-depth' : tone === 'muted' ? 'bg-slate-50' : 'bg-white'
return (
<section id={id} className={background}>
<div className="mx-auto w-full max-w-container px-4">{children}</div>
</section>
)
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<div>
<dt className="sr-only">{label}</dt>
<dd>
<span className="hx-data block text-2xl font-bold text-navy-700 sm:text-3xl">
{new Intl.NumberFormat('pl-PL').format(value)}
</span>
<span className="mt-0.5 block text-xs text-slate-500">{label}</span>
</dd>
</div>
)
}
/** Kafelek prowadzacego na ciemnym tle, wedlug makiety. */
function ExpertCard({ item }: { item: SectionItem }) {
const photo = mediaUrl(item.mediaId)
const initials = item.title
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((p) => p.charAt(0))
.join('')
.toUpperCase()
return (
<article className="flex items-center gap-3.5 rounded-lg border border-white/10 bg-white/[0.06] px-4 py-3.5">
{photo ? (
<img
src={photo}
alt=""
loading="lazy"
className="h-11 w-11 shrink-0 rounded-full object-cover"
/>
) : (
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-hydro text-sm font-semibold text-white">
{initials || '?'}
</span>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">{item.title}</p>
{item.subtitle && <p className="mt-0.5 truncate text-xs text-aqua-300">{item.subtitle}</p>}
{item.body && (
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-slate-400">{item.body}</p>
)}
</div>
</article>
)
}
+309
View File
@@ -0,0 +1,309 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Link, NavLink, useLocation } from 'react-router-dom'
import { BrandMark } from '../components/BrandMark'
import { Icon } from '../components/Icon'
import { Button } from '../components/ui'
import { useInstanceFavicon, usePublicConfig } from '../lib/usePublicConfig'
import { useAuth } from '../auth/AuthContext'
import { PANEL_HOME, STUDENT_ROUTES } from '../lib/routes'
import { useStudentAuth } from './StudentAuthContext'
/**
* Rama publicznej czesci Akademii.
*
* Osobna od `PublicLayout`, bo tamta jest waska (`max-w-2xl`) i zaprojektowana
* pod jeden formularz zapisu otwierany z linku w mailu. Strona glowna Akademii
* pokazuje siatke kursow i sekcje obok siebie, wiec potrzebuje pelnej
* szerokosci panelu (`max-w-container`, 1200px z tailwind.config.js).
*
* Szerokosc ustawia sie TUTAJ, raz, a nie na kazdej stronie z osobna: cztery
* ekrany panelu mialy kiedys wlasne `max-w` i tresc skakala na boki przy
* przechodzeniu miedzy nimi.
*/
export function AcademyLayout({
children,
footer,
bleed = false,
}: {
children: ReactNode
/** Tresc stopki z ustawien. Bez niej idzie stopka domyslna. */
footer?: { text?: string; termsUrl?: string; privacyUrl?: string }
/**
* Strona sama zarzadza szerokoscia i tlem sekcji.
*
* Domyslnie layout opakowuje tresc w kontener `max-w-container` z odstepami,
* co pasuje do zwyklej podstrony. Strona glowna ma jednak naprzemienne tla
* sekcji, ktore musza siegac krawedzi okna: w kontenerze pas koloru urywalby
* sie na 1200 px i wygladal jak przypadkowy prostokat. Przy `bleed` layout
* oddaje `children` bez opakowania, a kazda sekcja trzyma wlasny kontener.
*/
bleed?: boolean
}) {
useInstanceFavicon()
/*
* Naglowek czyta OBA konteksty, bo sa niezalezne: ta sama osoba moze byc
* zalogowana jednoczesnie jako kursant i jako administrator, i wtedy widzi
* oba odnosniki. Sesja panelu odtwarza sie tylko na trasach panelu, wiec
* `user` bywa tu pusty mimo waznego tokenu: odnosnik do panelu pojawia sie
* po wejsciu do niego, a nie znika na dobre.
*/
const { student } = useStudentAuth()
const { user } = useAuth()
const isStaff = user?.role === 'ADMIN' || user?.role === 'MODERATOR'
/*
* Menu na waskim ekranie.
*
* Przy 320 px trzy pozycje nawigacji i przycisk logowania nie mieszcza sie
* w jednym pasku: przewijany pasek nawigacji rozpychal dokument o kilka
* pikseli, czyli lamal regule "320 px bez przewijania w poziomie".
* Ponizej `sm` nawigacja chowa sie pod przycisk, a widoczne zostaja logo
* i wejscie do konta, czyli to, po co uzytkownik najczesciej siega.
*/
const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
// zmiana strony zamyka menu, zeby nie zostawalo otwarte nad trescia
useEffect(() => {
setMenuOpen(false)
}, [location.pathname])
/** Pozycja w menu wysuwanym: pelna szerokosc, wysokosc dotykowa 44 px. */
const mobileLinkClass = ({ isActive }: { isActive: boolean }) =>
[
'flex min-h-[44px] items-center rounded-md px-3 text-sm font-medium transition-colors duration-fast',
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-700 hover:bg-slate-50',
].join(' ')
const linkClass = ({ isActive }: { isActive: boolean }) =>
[
'rounded-md px-3 py-2 text-sm font-medium transition-colors duration-fast ease-standard',
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
].join(' ')
return (
<div className="flex min-h-screen flex-col bg-slate-25">
<header className="sticky top-0 z-30 border-b border-slate-100 bg-white/95 backdrop-blur">
<div className="mx-auto flex w-full max-w-container items-center gap-2 px-4 py-3 sm:gap-3">
{/* Przelacznik menu, wylacznie na waskim ekranie. */}
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-label={menuOpen ? 'Zamknij menu' : 'Otwórz menu'}
aria-expanded={menuOpen}
className="-ml-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-slate-600 transition-colors duration-fast hover:bg-slate-50 sm:hidden"
>
<Icon name={menuOpen ? 'x' : 'menu'} size={20} />
</button>
<Link to="/" className="min-w-0 shrink rounded-md" aria-label="Strona główna">
<BrandMark />
</Link>
{/* Nawigacja pozioma od `sm` w gore. Ponizej chowa sie pod przycisk. */}
<nav className="ml-2 hidden min-w-0 flex-1 items-center gap-1 sm:flex">
<NavLink to="/kursy" className={linkClass}>
Kursy
</NavLink>
<NavLink to="/webinary" className={linkClass}>
Webinary
</NavLink>
<NavLink to="/cennik" className={linkClass}>
Cennik
</NavLink>
</nav>
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
{/*
Administrator i moderator dostaja odnosnik do panelu. Zwykly
kursant go nie widzi: prowadzilby do ekranu, na ktory i tak nie
ma wstepu.
*/}
{isStaff && (
<Link
to={PANEL_HOME}
className="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-slate-600 transition-colors duration-fast hover:bg-slate-50 hover:text-slate-900 sm:px-3"
>
<Icon name="settings" size={16} />
<span className="hidden sm:inline">Panel</span>
</Link>
)}
{student ? (
<Link
to={STUDENT_ROUTES.dashboard}
className="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-slate-700 transition-colors duration-fast hover:bg-slate-50 sm:px-3"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-50 text-xs font-semibold text-blue-600">
{initials(student.firstName, student.lastName)}
</span>
{/* Imie chowa sie na waskim ekranie: inicjaly wystarcza, zeby
bylo widac, ze ktos jest zalogowany. */}
<span className="hidden sm:inline">{student.firstName || 'Moje konto'}</span>
</Link>
) : (
!isStaff && (
<Link to={STUDENT_ROUTES.login}>
<Button size="sm" icon="log-in">
Zaloguj się
</Button>
</Link>
)
)}
</div>
</div>
{/* Menu wysuwane. Tylko ponizej `sm`, bo wyzej nawigacja jest w pasku. */}
{menuOpen && (
<nav className="border-t border-slate-100 bg-white px-4 py-2 sm:hidden">
<NavLink to="/kursy" className={mobileLinkClass}>
Kursy
</NavLink>
<NavLink to="/webinary" className={mobileLinkClass}>
Webinary
</NavLink>
<NavLink to="/cennik" className={mobileLinkClass}>
Cennik
</NavLink>
</nav>
)}
</header>
{bleed ? (
<main className="flex-1">{children}</main>
) : (
<main className="mx-auto w-full max-w-container flex-1 px-4 py-6 sm:py-10">{children}</main>
)}
<AcademyFooter
text={footer?.text}
termsUrl={footer?.termsUrl}
privacyUrl={footer?.privacyUrl}
/>
</div>
)
}
function initials(firstName: string | null, lastName: string | null) {
const a = (firstName || '').trim().charAt(0)
const b = (lastName || '').trim().charAt(0)
return (a + b).toUpperCase() || '?'
}
/**
* Stopka Akademii.
*
* WIDOCZNA ZAWSZE, TAKZE PRZY PUSTYCH USTAWIENIACH.
* ---------------------------------------------------------------------------
* Poprzednia wersja renderowala sie poprawnie, ale przy pustej konfiguracji
* kurczyla sie do paska wysokosci 45 px z jednym zdaniem: technicznie byla,
* wizualnie jej nie bylo. Stopka jest zamknieciem strony, wiec ma wygladac
* jak zamkniecie takze na swiezej instancji.
*
* Uklad wedlug makiety (wariant 1a): ciemne tlo, trzy kolumny, pasek praw
* autorskich na dole. Wariant 1b ma stopke jednolinijkowa na jasnym tle;
* wybrano 1a, bo miesci odnosniki serwisowe i dane spolki, ktore i tak
* musza gdzies byc, a jednolinijkowa wersja wypycha je do tresci strony.
*
* KAZDY ODNOSNIK JEST OPCJONALNY.
* ---------------------------------------------------------------------------
* Pusty adres regulaminu UKRYWA odnosnik, zamiast prowadzic pod pusta
* sciezke: odnosnik donikad jest gorszy niz jego brak. Znika sam odnosnik,
* nie cala stopka.
*/
export function AcademyFooter({
text,
termsUrl,
privacyUrl,
}: {
text?: string
termsUrl?: string
privacyUrl?: string
}) {
const { brandName } = usePublicConfig()
const year = new Date().getFullYear()
return (
<footer className="mt-auto bg-navy-800 text-slate-300">
<div className="mx-auto w-full max-w-container px-4 py-10 pb-safe sm:py-12">
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-[1.5fr_1fr_1fr]">
{/* --- marka i zdanie o instancji --- */}
<div>
<BrandMark inverse />
<p className="mt-3.5 max-w-sm text-sm leading-relaxed text-slate-400">
{text || `Platforma szkoleniowa: kursy online i webinary na żywo. Jedno konto, certyfikaty, dostęp z dowolnego urządzenia.`}
</p>
</div>
{/* --- serwis --- */}
<div>
<p className="text-sm font-semibold text-white">Serwis</p>
<nav className="mt-3 flex flex-col gap-2 text-sm">
<Link to="/kursy" className="text-slate-400 transition-colors duration-fast hover:text-white">
Kursy online
</Link>
<Link to="/webinary" className="text-slate-400 transition-colors duration-fast hover:text-white">
Webinary
</Link>
<Link to="/cennik" className="text-slate-400 transition-colors duration-fast hover:text-white">
Cennik
</Link>
<Link
to={STUDENT_ROUTES.login}
className="text-slate-400 transition-colors duration-fast hover:text-white"
>
Zaloguj się
</Link>
</nav>
</div>
{/* --- dokumenty --- */}
<div>
<p className="text-sm font-semibold text-white">Dokumenty</p>
<nav className="mt-3 flex flex-col gap-2 text-sm">
{/* Pusty adres ukrywa sam odnosnik, nie cala kolumne. */}
{termsUrl ? (
<a
href={termsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 transition-colors duration-fast hover:text-white"
>
Regulamin
</a>
) : (
<span className="text-slate-500">Regulamin</span>
)}
{privacyUrl ? (
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 transition-colors duration-fast hover:text-white"
>
Polityka prywatności
</a>
) : (
<span className="text-slate-500">Polityka prywatności</span>
)}
<Link
to="/certyfikat"
className="text-slate-400 transition-colors duration-fast hover:text-white"
>
Weryfikacja certyfikatu
</Link>
</nav>
</div>
</div>
<div className="mt-9 flex flex-col gap-2 border-t border-white/10 pt-5 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between">
<span>
&copy; {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone.
</span>
<span className="hx-data">Obsługiwane przez platformę webinarową</span>
</div>
</div>
</footer>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { academyClient, academyErrorMessage } from '../api/academyClient'
import { Alert, Button, Card, Field, Input } from '../components/ui'
import { PublicLayout } from '../pages/public/PublicLayout'
import { PANEL_HOME, STUDENT_ROUTES } from '../lib/routes'
import { safeInternalPath } from '../lib/safePath'
import { useStudentAuth } from './StudentAuthContext'
/**
* Wybor sciezki po zalogowaniu, gdy adres ma konto w obu swiatach.
*
* Nie decydujemy za uzytkownika: administrator, ktory zapisal sie na webinar,
* ma dostac sie zarowno do panelu, jak i do wlasnego pulpitu kursanta.
*
* OBA TOKENY SA JUZ WYDANE.
* ---------------------------------------------------------------------------
* Wspolne logowanie sprawdzilo haslo osobno w obu swiatach i wydalo token tam,
* gdzie pasowalo. Wejscie w ktorykolwiek z odnosnikow nie wymaga wiec drugiego
* logowania. Nie jest to obejscie rozdzielnosci hasel: gdyby haslo pasowalo
* tylko w jednym miejscu, backend nie przyslalby drugiego tokenu i tego ekranu
* w ogole by nie bylo.
*/
export function AcademyChoicePage() {
const { student } = useStudentAuth()
const location = useLocation()
// cel powrotu przekazany przez formularz logowania, gdy uzytkownik przyszedl
// z konkretnej strony (np. kliknal zapis na kurs)
const requested = safeInternalPath((location.state as { from?: string } | null)?.from, '')
return (
<PublicLayout>
<Card title="Gdzie chcesz wejść?">
<p className="mb-4 text-sm leading-relaxed text-slate-600">
Adres <strong className="text-slate-900">{student?.email}</strong> ma konto uczestnika
i konto w panelu administracyjnym. Wybierz, dokąd przejść.
</p>
<div className="space-y-3">
<Link
to={requested || STUDENT_ROUTES.dashboard}
className="flex min-h-[56px] items-center gap-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 transition-colors duration-fast ease-standard hover:bg-blue-100"
>
<span className="min-w-0">
<span className="block text-sm font-semibold text-blue-800">Moje konto</span>
<span className="block text-xs text-blue-700">
Kursy, webinary, certyfikaty, dane i zgody
</span>
</span>
</Link>
<Link
to={PANEL_HOME}
className="flex min-h-[56px] items-center gap-3 rounded-md border border-slate-200 bg-white px-4 py-3 transition-colors duration-fast ease-standard hover:bg-slate-50"
>
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-900">
Panel administracyjny
</span>
<span className="block text-xs text-slate-500">
Zarządzanie webinarami, kursami i ustawieniami
</span>
</span>
</Link>
</div>
</Card>
</PublicLayout>
)
}
/** Prosba o link do zmiany hasla. */
export function AcademyPasswordResetPage() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
setError(null)
setPending(true)
try {
await academyClient.post('/academy/password-reset', { email })
setSent(true)
} catch (err) {
setError(academyErrorMessage(err, 'Nie udało się wysłać wiadomości'))
} finally {
setPending(false)
}
}
if (sent) {
return (
<PublicLayout>
<Card title="Sprawdź skrzynkę">
<Alert tone="ok" icon="mail-check">
Jeśli konto o tym adresie istnieje, wysłaliśmy na nie link do ustawienia nowego hasła.
Link jest ważny przez godzinę.
</Alert>
<p className="mt-4 text-sm">
<Link to={STUDENT_ROUTES.login} className="text-blue-600 hover:text-blue-700">
Wróć do logowania
</Link>
</p>
</Card>
</PublicLayout>
)
}
return (
<PublicLayout>
<Card title="Nie pamiętam hasła">
<p className="mb-4 text-sm leading-relaxed text-slate-600">
Podaj adres e-mail konta. Wyślemy na niego link do ustawienia nowego hasła.
</p>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{error && <Alert tone="alarm">{error}</Alert>}
<Field label="Adres e-mail">
<Input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Field>
<Button type="submit" disabled={pending} className="w-full" icon={pending ? undefined : 'mail'}>
{pending ? 'Wysyłanie...' : 'Wyślij link'}
</Button>
</form>
</Card>
</PublicLayout>
)
}
@@ -0,0 +1,330 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { STUDENT_ROUTES } from '../lib/routes'
import { academyClient, academyErrorMessage, academyFieldErrors } from '../api/academyClient'
import { Alert, Button, Card, Field, Input, PasswordInput, Spinner } from '../components/ui'
import { PublicLayout } from '../pages/public/PublicLayout'
type Form = {
firstName: string
lastName: string
email: string
password: string
phone: string
company: string
acceptTerms: boolean
marketingEmail: boolean
marketingSms: boolean
}
const EMPTY: Form = {
firstName: '',
lastName: '',
email: '',
password: '',
phone: '',
company: '',
acceptTerms: false,
marketingEmail: false,
marketingSms: false,
}
/**
* Zakladanie konta kursanta.
*
* Zadna zgoda nie jest zaznaczona domyslnie, takze ta wymagana: zaznaczenie
* zgody za uzytkownika jest niedopuszczalne. Ta sama regula obowiazuje przy
* zapisach na webinar.
*/
export function AcademyRegisterPage() {
const [form, setForm] = useState<Form>(EMPTY)
const [errors, setErrors] = useState<Record<string, string>>({})
const [generalError, setGeneralError] = useState<string | null>(null)
const [done, setDone] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const update = <K extends keyof Form>(key: K, value: Form[K]) => {
setForm((prev) => ({ ...prev, [key]: value }))
setErrors((prev) => ({ ...prev, [key]: '' }))
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
setGeneralError(null)
setErrors({})
if (!form.acceptTerms) {
setErrors({ acceptTerms: 'Akceptacja regulaminu jest wymagana' })
return
}
setPending(true)
try {
const response = await academyClient.post<{ message: string }>('/academy/register', form)
setDone(response.data.message)
} catch (err) {
setErrors(academyFieldErrors(err))
setGeneralError(academyErrorMessage(err, 'Nie udało się założyć konta'))
} finally {
setPending(false)
}
}
/*
* Ekran po wyslaniu jest identyczny niezaleznie od tego, czy konto powstalo,
* czy adres byl juz zajety. Rozne ekrany pozwalalyby sprawdzac, kto ma
* u nas konto, dokladnie tak samo jak przy zapisach na webinar.
*/
if (done) {
return (
<PublicLayout>
<Card title="Sprawdź skrzynkę">
<Alert tone="ok" icon="mail-check">
{done}
</Alert>
<p className="mt-4 text-sm">
<Link to={STUDENT_ROUTES.login} className="text-blue-600 hover:text-blue-700">
Przejdź do logowania
</Link>
</p>
</Card>
</PublicLayout>
)
}
return (
<PublicLayout>
<Card title="Załóż konto w Akademii">
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{generalError && <Alert tone="alarm">{generalError}</Alert>}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Imię" error={errors.firstName}>
<Input
autoComplete="given-name"
value={form.firstName}
onChange={(e) => update('firstName', e.target.value)}
required
/>
</Field>
<Field label="Nazwisko" error={errors.lastName}>
<Input
autoComplete="family-name"
value={form.lastName}
onChange={(e) => update('lastName', e.target.value)}
required
/>
</Field>
</div>
<Field
label="Adres e-mail"
error={errors.email}
hint="Na ten adres wyślemy link potwierdzający. Bez potwierdzenia nie ma dostępu do kursów."
>
<Input
type="email"
autoComplete="email"
value={form.email}
onChange={(e) => update('email', e.target.value)}
required
/>
</Field>
<Field label="Hasło" error={errors.password} hint="Minimum 8 znaków.">
<PasswordInput
autoComplete="new-password"
value={form.password}
onChange={(e) => update('password', e.target.value)}
required
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Telefon" error={errors.phone} hint="Opcjonalnie.">
<Input
type="tel"
autoComplete="tel"
value={form.phone}
onChange={(e) => update('phone', e.target.value)}
/>
</Field>
<Field label="Firma" error={errors.company} hint="Opcjonalnie.">
<Input
autoComplete="organization"
value={form.company}
onChange={(e) => update('company', e.target.value)}
/>
</Field>
</div>
<div className="space-y-2 border-t border-slate-100 pt-4">
<Consent
checked={form.acceptTerms}
onChange={(v) => update('acceptTerms', v)}
error={errors.acceptTerms}
required
>
Akceptuję regulamin Akademii.
</Consent>
<Consent
checked={form.marketingEmail}
onChange={(v) => update('marketingEmail', v)}
>
Wyrażam zgodę na otrzymywanie informacji handlowych drogą elektroniczną na podany
adres e-mail.
</Consent>
<Consent checked={form.marketingSms} onChange={(v) => update('marketingSms', v)}>
Wyrażam zgodę na otrzymywanie informacji handlowych w formie wiadomości SMS na podany
numer telefonu.
</Consent>
</div>
<Button type="submit" disabled={pending} className="w-full" icon={pending ? undefined : 'user-plus'}>
{pending ? 'Zakładanie konta...' : 'Załóż konto'}
</Button>
</form>
<p className="mt-5 border-t border-slate-100 pt-4 text-sm text-slate-600">
Masz już konto?{' '}
<Link to={STUDENT_ROUTES.login} className="text-blue-600 hover:text-blue-700">
Zaloguj się
</Link>
</p>
</Card>
</PublicLayout>
)
}
function Consent({
checked,
onChange,
children,
error,
required = false,
}: {
checked: boolean
onChange: (value: boolean) => void
children: React.ReactNode
error?: string
required?: boolean
}) {
return (
<div>
<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">
{required && <strong className="text-slate-900">Wymagane: </strong>}
{children}
</span>
</label>
{error && <p className="mt-1 text-sm text-alarm-600">{error}</p>}
</div>
)
}
/** Potwierdzenie adresu linkiem z maila. */
export function AcademyVerifyPage() {
const { token } = useParams<{ token: string }>()
const [state, setState] = useState<'pending' | 'ok' | 'error'>('pending')
const [message, setMessage] = useState('')
useEffect(() => {
if (!token) return
academyClient
.post(`/academy/verify/${token}`)
.then(() => setState('ok'))
.catch((err) => {
setMessage(academyErrorMessage(err, 'Nie udało się potwierdzić adresu'))
setState('error')
})
}, [token])
return (
<PublicLayout>
<Card title="Potwierdzenie adresu">
{state === 'pending' && <Spinner label="Potwierdzanie..." />}
{state === 'ok' && (
<>
<Alert tone="ok" icon="check-circle">
Adres e-mail został potwierdzony. Masz teraz pełny dostęp do materiałów.
</Alert>
<p className="mt-4 text-sm">
<Link to={STUDENT_ROUTES.login} className="text-blue-600 hover:text-blue-700">
Przejdź do logowania
</Link>
</p>
</>
)}
{state === 'error' && <Alert tone="alarm">{message}</Alert>}
</Card>
</PublicLayout>
)
}
/** Ustawienie nowego hasla z linku w mailu. */
export function AcademyNewPasswordPage() {
const { token } = useParams<{ token: string }>()
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [done, setDone] = useState(false)
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
setError(null)
setPending(true)
try {
await academyClient.post('/academy/password-reset/confirm', { token, password })
setDone(true)
} catch (err) {
setError(academyErrorMessage(err, 'Nie udało się zmienić hasła'))
} finally {
setPending(false)
}
}
if (done) {
return (
<PublicLayout>
<Card title="Hasło zmienione">
<Alert tone="ok" icon="check-circle">
Nowe hasło zostało zapisane. Możesz się teraz zalogować.
</Alert>
<div className="mt-4">
<Button onClick={() => navigate(STUDENT_ROUTES.login)} icon="log-in">
Przejdź do logowania
</Button>
</div>
</Card>
</PublicLayout>
)
}
return (
<PublicLayout>
<Card title="Ustaw nowe hasło">
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{error && <Alert tone="alarm">{error}</Alert>}
<Field label="Nowe hasło" hint="Minimum 8 znaków.">
<PasswordInput
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
<Button type="submit" disabled={pending} className="w-full" icon={pending ? undefined : 'key'}>
{pending ? 'Zapisywanie...' : 'Zapisz nowe hasło'}
</Button>
</form>
</Card>
</PublicLayout>
)
}
+142
View File
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { Icon } from '../components/Icon'
/**
* Czy zalogowany uzytkownik ma role panelu.
*
* Sesja panelu odtwarza sie z zapisanego tokenu niezaleznie od trasy, wiec
* `user` jest wypelniony takze na stronach publicznych.
*/
export function useIsStaff(): boolean {
const { user } = useAuth()
return user?.role === 'ADMIN' || user?.role === 'MODERATOR'
}
/**
* Placeholder pustej sekcji, widoczny WYLACZNIE dla rol panelu.
*
* PO CO ISTNIEJE.
* ---------------------------------------------------------------------------
* Sekcja bez tresci znika ze strony bez sladu. Dla goscia to poprawne: pusty
* naglowek "Opinie kursantow" nad niczym wyglada gorzej niz brak sekcji.
* Dla administratora jest odwrotnie: **nie ma jak zauwazyc, ze cos jest do
* uzupelnienia**, bo strona nie rozni sie od kompletnej. Swieza instancja
* wygladala przez to na zepsuta, a nie na czekajaca na tresc.
*
* Placeholder mowi, czego brakuje, i prowadzi jednym klikiem tam, gdzie sie
* to uzupelnia. Gosc nie widzi go w zadnej postaci: nie renderujemy go wcale,
* a nie ukrywamy stylem, wiec nie ma go takze w zrodle strony.
*
* DYSKRETNY, NIE ALARMUJACY.
* ---------------------------------------------------------------------------
* To nie jest blad, tylko brakujaca tresc. Przerywana ramka i szarosc mowia
* "tu czegos nie ma", a nie "cos sie zepsulo": administrator ogladajacy wlasna
* strone glowna nie moze odniesc wrazenia, ze instancja nie dziala.
*/
export function AdminSectionHint({
title,
what,
to,
linkLabel = 'Uzupełnij w panelu',
flat = false,
}: {
/** Naglowek sekcji, zeby bylo wiadomo, ktorej dotyczy. */
title: string
/** Zdanie o tym, co sie tu pojawi po uzupelnieniu. */
what: string
/** Adres w panelu, gdzie sie to uzupelnia. */
to: string
linkLabel?: string
/**
* Bez wlasnego marginesu gornego.
*
* Strona glowna uklada sekcje w pasach z wlasnymi odstepami, wiec dodatkowy
* `mt-14` odsuwalby placeholder od naglowka pasa.
*/
flat?: boolean
}) {
// hook wolany bezwarunkowo: React wymaga tej samej liczby wywolan przy
// kazdym renderze, wiec warunek moze byc dopiero po nim
const isStaff = useIsStaff()
if (!isStaff) {
return null
}
return (
<section className={flat ? '' : 'mt-14'}>
<h2 className="text-2xl font-semibold text-slate-400 sm:text-3xl">{title}</h2>
<div className="mt-4 rounded-lg border border-dashed border-slate-300 bg-slate-25 p-6">
<div className="flex items-start gap-3">
<Icon name="circle-alert" size={18} className="mt-0.5 shrink-0 text-slate-400" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-700">
Ta sekcja jest pusta i nie widzą jej odwiedzający.
</p>
<p className="mt-1 text-sm leading-relaxed text-slate-500">{what}</p>
<Link
to={to}
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 hover:underline"
>
{linkLabel}
<Icon name="chevron-right" size={15} />
</Link>
</div>
</div>
<p className="mt-4 border-t border-slate-200 pt-3 text-xs text-slate-400">
Widzisz to, bo jesteś zalogowany w panelu. Odwiedzający nie widzą tego miejsca.
</p>
</div>
</section>
)
}
/**
* Podpowiedz przy pustej liscie, widoczna wylacznie dla rol panelu.
*
* Inaczej niz `AdminSectionHint`: sekcja JEST widoczna dla goscia i ma wlasny
* komunikat ("Kursy pojawia sie wkrotce"). Administrator dostaje pod nim
* dodatkowo droge do utworzenia pierwszej pozycji, zamiast szukac jej w menu.
*/
export function AdminEmptyHint({ to, children }: { to: string; children: ReactNode }) {
const isStaff = useIsStaff()
if (!isStaff) {
return null
}
return (
<Link
to={to}
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 hover:underline"
>
{children}
<Icon name="chevron-right" size={15} />
</Link>
)
}
/**
* Znacznik tresci przykladowej, widoczny wylacznie dla rol panelu.
*
* Tresc startowa zalozona przy pierwszym uruchomieniu jest **prawdziwa**, ale
* nie jest decyzja administratora: ma zostac przeczytana i zastapiona wlasna.
* Bez tego oznaczenia nie da sie jej odroznic od tresci wpisanej recznie,
* a przykladowe pytania FAQ zostawaly by na produkcji na zawsze.
*/
export function SampleBadge({ to }: { to: string }) {
const isStaff = useIsStaff()
if (!isStaff) {
return null
}
return (
<Link
to={to}
title="Treść przykładowa, założona przy pierwszym uruchomieniu. Zmień ją na własną."
className="ml-2 inline-flex shrink-0 items-center gap-1 rounded bg-warn-50 px-1.5 py-0.5 align-middle text-2xs font-semibold uppercase tracking-caps text-warn-600 hover:bg-warn-50/70"
>
przykład
</Link>
)
}
+265
View File
@@ -0,0 +1,265 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query'
import { academyClient, academyErrorMessage } from '../api/academyClient'
import type { EnrollResponse, PublicCourse } from '../api/types'
import { Icon } from '../components/Icon'
import { Alert, Button, ErrorState, Field, Input, Spinner } from '../components/ui'
import { useToasts } from '../components/Toasts'
import { AcademyLayout } from './AcademyLayout'
import { CourseCover, formatPrice } from './AcademyBits'
import { useStudentAuth } from './StudentAuthContext'
/**
* Podsumowanie zamowienia przed przejsciem na bramke.
*
* Osobny ekran, a nie przycisk "kup" prowadzacy wprost do Autopay: kursant ma
* przed zaplata zobaczyc, za co placi i ile, oraz swiadomie zaakceptowac
* regulamin. Przejscie z kafelka wprost na bramke oznaczaloby platnosc bez
* ani jednego ekranu, na ktorym da sie sprawdzic kwote.
*/
export function CheckoutPage() {
const { slug = '' } = useParams()
const { student } = useStudentAuth()
const navigate = useNavigate()
const toast = useToasts()
const [acceptTerms, setAcceptTerms] = useState(false)
const [wantsInvoice, setWantsInvoice] = useState(false)
const [invoice, setInvoice] = useState({ company: '', taxId: '', address: '' })
const [termsError, setTermsError] = useState<string | null>(null)
const { data, isLoading, isError } = useQuery({
queryKey: ['academy', 'course', slug, student?.id ?? null],
queryFn: async () =>
(await academyClient.get<PublicCourse>(`/public/academy/courses/${slug}`)).data,
enabled: Boolean(slug),
})
const buy = useMutation({
mutationFn: async () => {
const response = await academyClient.post<EnrollResponse>(
`/academy/courses/${data?.id}/enroll`,
// dane faktury ida tylko wtedy, gdy kursant o nia poprosil
wantsInvoice ? invoice : {},
)
return response.data
},
onSuccess: (result) => {
if (result.enrolled) {
toast.success('Zapisano na kurs')
navigate(`/kurs/${slug}/nauka`)
return
}
if (result.paymentUrl) {
/*
* Adres kontynuacji z przedtransakcji. To opuszczenie naszej strony,
* wiec zwykle przypisanie adresu, a nie nawigacja Reactem.
*/
window.location.href = result.paymentUrl
return
}
toast.error('Bramka płatności nie zwróciła adresu. Spróbuj ponownie.')
},
onError: (error) =>
toast.error(academyErrorMessage(error, 'Nie udało się rozpocząć płatności')),
})
if (isLoading) {
return (
<AcademyLayout>
<Spinner label="Wczytywanie zamówienia..." />
</AcademyLayout>
)
}
if (isError || !data) {
return (
<AcademyLayout>
<ErrorState message="Nie znaleziono kursu." />
</AcademyLayout>
)
}
// kurs darmowy nie ma czego kupowac: odsylamy na strone kursu
if (data.free) {
return (
<AcademyLayout>
<Alert tone="info" icon="circle-alert">
Ten kurs jest bezpłatny, nie wymaga płatności.
</Alert>
<div className="mt-4">
<Link to={`/kurs/${slug}`}>
<Button icon="arrow-left">Wróć do kursu</Button>
</Link>
</div>
</AcademyLayout>
)
}
const submit = (event: React.FormEvent) => {
event.preventDefault()
if (!acceptTerms) {
setTermsError('Akceptacja regulaminu jest wymagana')
return
}
setTermsError(null)
buy.mutate()
}
return (
<AcademyLayout>
<nav className="mb-4">
<Link
to={`/kurs/${slug}`}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600"
>
<Icon name="arrow-left" size={16} />
Wróć do kursu
</Link>
</nav>
<h1 className="text-2xl font-semibold text-slate-900">Podsumowanie zamówienia</h1>
<form onSubmit={submit} className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 space-y-5">
{/* --- zamawiany kurs --- */}
<section className="rounded-lg border border-slate-100 bg-white shadow-sm">
<h2 className="border-b border-slate-100 px-5 py-3 text-sm font-semibold text-slate-900">
Zamawiany kurs
</h2>
<div className="flex gap-4 p-5">
<CourseCover
mediaId={data.coverMediaId}
title={data.title}
className="h-20 w-28 shrink-0 rounded-md"
/>
<div className="min-w-0">
<p className="font-medium text-slate-900">{data.title}</p>
{data.subtitle && (
<p className="mt-0.5 line-clamp-2 text-sm text-slate-600">{data.subtitle}</p>
)}
<p className="mt-2 text-sm text-slate-500">
{data.lessonCount} lekcji
{data.certificateEnabled && ' · certyfikat po ukończeniu'}
</p>
</div>
</div>
</section>
{/* --- dane kupujacego --- */}
<section className="rounded-lg border border-slate-100 bg-white shadow-sm">
<h2 className="border-b border-slate-100 px-5 py-3 text-sm font-semibold text-slate-900">
Dane kupującego
</h2>
<div className="p-5 text-sm text-slate-700">
<p className="font-medium">
{student?.firstName} {student?.lastName}
</p>
<p className="text-slate-600">{student?.email}</p>
<p className="mt-3 text-xs text-slate-500">
Dostęp do kursu zostanie przypisany do tego konta.
</p>
</div>
</section>
{/* --- faktura --- */}
<section className="rounded-lg border border-slate-100 bg-white shadow-sm">
<h2 className="border-b border-slate-100 px-5 py-3 text-sm font-semibold text-slate-900">
Faktura
</h2>
<div className="p-5">
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm text-slate-700">
<input
type="checkbox"
checked={wantsInvoice}
onChange={(e) => setWantsInvoice(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-3 focus:ring-blue-500/45"
/>
Chcę otrzymać fakturę
</label>
{wantsInvoice && (
<div className="mt-4 space-y-4">
<Field label="Nazwa firmy">
<Input
value={invoice.company}
onChange={(e) => setInvoice({ ...invoice, company: e.target.value })}
autoComplete="organization"
/>
</Field>
<Field label="NIP">
<Input
value={invoice.taxId}
onChange={(e) => setInvoice({ ...invoice, taxId: e.target.value })}
inputMode="numeric"
/>
</Field>
<Field label="Adres">
<Input
value={invoice.address}
onChange={(e) => setInvoice({ ...invoice, address: e.target.value })}
autoComplete="street-address"
/>
</Field>
</div>
)}
</div>
</section>
</div>
{/* --- do zaplaty --- */}
<aside className="lg:sticky lg:top-20 lg:self-start">
<div className="rounded-lg border border-slate-100 bg-white p-5 shadow-sm">
<h2 className="text-sm font-semibold text-slate-900">Do zapłaty</h2>
<dl className="mt-4 space-y-2 text-sm">
<div className="flex justify-between text-slate-600">
<dt>Kurs</dt>
<dd className="hx-data">{formatPrice(data.priceGross, data.currency)}</dd>
</div>
<div className="flex justify-between border-t border-slate-100 pt-2 text-base font-semibold text-slate-900">
<dt>Razem brutto</dt>
<dd className="hx-data">{formatPrice(data.priceGross, data.currency)}</dd>
</div>
</dl>
<div className="mt-5">
<label className="flex cursor-pointer items-start gap-3 text-sm text-slate-700">
<input
type="checkbox"
checked={acceptTerms}
onChange={(e) => {
setAcceptTerms(e.target.checked)
if (e.target.checked) setTermsError(null)
}}
aria-invalid={Boolean(termsError)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-blue-500 focus:ring-3 focus:ring-blue-500/45"
/>
<span className="leading-relaxed">
Akceptuję regulamin i przyjmuję do wiadomości, że dostęp otrzymam
po zaksięgowaniu płatności.
</span>
</label>
{termsError && <p className="mt-1.5 text-xs text-alarm-600">{termsError}</p>}
</div>
<Button
type="submit"
size="lg"
icon="credit-card"
className="mt-5 w-full"
disabled={buy.isPending}
>
{buy.isPending ? 'Przechodzę do płatności...' : 'Zapłać'}
</Button>
<p className="mt-3 text-center text-xs leading-relaxed text-slate-500">
Płatność obsługuje Autopay. Za chwilę przejdziesz na stronę operatora.
</p>
</div>
</aside>
</form>
</AcademyLayout>
)
}
+352
View File
@@ -0,0 +1,352 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { publicClient } from '../api/publicClient'
import type { PublicCourseCard, UpcomingWebinar } from '../api/types'
import { Icon } from '../components/Icon'
import { ErrorState, Spinner } from '../components/ui'
import { formatDateTimeParts } from '../lib/dates'
import { useDocumentMeta } from '../lib/useDocumentMeta'
import { AcademyLayout } from './AcademyLayout'
import { PanelMovedNotice } from './PanelMovedNotice'
import { CourseCard, formatPrice, lessonWord } from './AcademyBits'
import { AdminEmptyHint } from './AdminHint'
import { panelPath } from '../lib/routes'
type PriceFilter = 'all' | 'free' | 'paid'
/** Lista wszystkich opublikowanych kursow, z filtrem ceny i kategorii. */
export function CourseListPage() {
const [filter, setFilter] = useState<PriceFilter>('all')
const [category, setCategory] = useState('')
const [query, setQuery] = useState('')
useDocumentMeta({ title: 'Kursy online' })
const { data, isLoading, isError } = useQuery({
queryKey: ['academy', 'courses'],
queryFn: async () =>
(await publicClient.get<PublicCourseCard[]>('/public/academy/courses')).data,
})
const categories = useMemo(() => {
const set = new Set((data ?? []).map((c) => c.category).filter(Boolean) as string[])
return [...set].sort((a, b) => a.localeCompare(b, 'pl'))
}, [data])
const courses = useMemo(() => {
const needle = query.trim().toLowerCase()
return (data ?? []).filter((c) => {
if (filter === 'free' && !c.free) return false
if (filter === 'paid' && c.free) return false
if (category && c.category !== category) return false
if (needle && !`${c.title} ${c.subtitle ?? ''}`.toLowerCase().includes(needle)) return false
return true
})
}, [data, filter, category, query])
const filters: { id: PriceFilter; label: string }[] = [
{ id: 'all', label: 'Wszystkie' },
{ id: 'free', label: 'Darmowe' },
{ id: 'paid', label: 'Płatne' },
]
return (
<AcademyLayout>
{/*
Adres /kursy nalezal wczesniej do panelu i CELOWO nie ma przekierowania:
pod jednym adresem nie moga stac dwie rozne strony. Pasek widzi wylacznie
osoba z rola panelu, czyli ta, ktorej zakladka wlasnie przestala dzialac.
*/}
<PanelMovedNotice />
<h1 className="text-2xl font-semibold text-slate-900">Kursy online</h1>
<p className="mt-1.5 text-sm text-slate-600">
Ucz się we własnym tempie, z dostępem z dowolnego urządzenia.
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<div className="flex rounded-md border border-slate-200 bg-white p-0.5">
{filters.map((f) => (
<button
key={f.id}
type="button"
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={[
'min-h-9 rounded px-3 text-sm font-medium transition-colors duration-fast',
filter === f.id ? 'bg-blue-500 text-white' : 'text-slate-600 hover:bg-slate-50',
].join(' ')}
>
{f.label}
</button>
))}
</div>
{categories.length > 0 && (
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
aria-label="Filtruj po kategorii"
className="min-h-11 rounded-md border border-slate-200 bg-white px-3 text-base text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-3 focus:ring-blue-500/45 sm:min-h-9 sm:text-sm"
>
<option value="">Wszystkie kategorie</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
<div className="relative min-w-[200px] flex-1">
<Icon
name="search"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Szukaj kursu"
aria-label="Szukaj kursu"
className="min-h-11 w-full rounded-md border border-slate-200 bg-white pl-9 pr-3 text-base text-slate-700 placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-3 focus:ring-blue-500/45 sm:min-h-9 sm:text-sm"
/>
</div>
</div>
{isLoading && (
<div className="mt-8">
<Spinner label="Wczytywanie kursów..." />
</div>
)}
{isError && (
<div className="mt-8">
<ErrorState message="Nie udało się wczytać listy kursów." />
</div>
)}
{data && courses.length === 0 && (
<div className="mt-8 rounded-lg border border-dashed border-slate-200 bg-white p-8 text-center">
<p className="text-sm text-slate-500">
{data.length === 0
? 'Kursy pojawią się wkrótce.'
: 'Brak kursów spełniających wybrane warunki.'}
</p>
{/* Komunikat jest poprawny dla goscia, ale osobie, ktora ma kurs
dodac, nie mowi nic. Odnosnik widzi wylacznie rola panelu. */}
{data.length === 0 && (
<AdminEmptyHint to={panelPath('/kursy/nowy')}>Dodaj pierwszy kurs</AdminEmptyHint>
)}
</div>
)}
{courses.length > 0 && (
<div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{courses.map((c) => (
<CourseCard key={c.id} course={c} />
))}
</div>
)}
</AcademyLayout>
)
}
/** Lista nadchodzacych webinarow. */
export function WebinarListPage() {
useDocumentMeta({ title: 'Nadchodzące webinary' })
const { data, isLoading, isError } = useQuery({
queryKey: ['academy', 'webinars'],
queryFn: async () =>
(await publicClient.get<UpcomingWebinar[]>('/public/academy/webinars')).data,
})
return (
<AcademyLayout>
{/* jak wyzej: /webinary nalezalo wczesniej do panelu */}
<PanelMovedNotice />
<h1 className="text-2xl font-semibold text-slate-900">Nadchodzące webinary</h1>
<p className="mt-1.5 text-sm text-slate-600">
Spotkania na żywo z ekspertami. Udział jest bezpłatny po zapisaniu się.
</p>
{isLoading && (
<div className="mt-8">
<Spinner label="Wczytywanie webinarów..." />
</div>
)}
{isError && (
<div className="mt-8">
<ErrorState message="Nie udało się wczytać listy webinarów." />
</div>
)}
{data && data.length === 0 && (
<div className="mt-8 rounded-lg border border-dashed border-slate-200 bg-white p-8 text-center">
<p className="text-sm text-slate-500">
Nie ma teraz zaplanowanych webinarów. Zajrzyj tu za jakiś czas.
</p>
{/* Lista pokazuje wylacznie wydarzenia z terminem w przyszlosci,
wiec pustka moze znaczyc takze "wszystkie sie juz odbyly". */}
<AdminEmptyHint to={panelPath('/webinary/nowy')}>Zaplanuj webinar</AdminEmptyHint>
</div>
)}
{data && data.length > 0 && (
<div className="mt-6 space-y-3">
{data.map((w) => {
const when = formatDateTimeParts(w.startsAt, w.timezone)
return (
<article
key={w.id}
className="flex flex-col gap-4 rounded-lg border border-slate-100 bg-white p-4 shadow-sm sm:flex-row sm:items-center"
>
<div className="flex shrink-0 flex-col items-center justify-center rounded-md bg-blue-50 px-4 py-3 text-center sm:w-24">
<span className="hx-data text-sm font-bold text-blue-700">{when.date}</span>
<span className="hx-data text-xs text-blue-600">{when.time}</span>
</div>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-slate-900">{w.title}</h2>
{w.description && (
<p className="mt-1 line-clamp-2 text-sm text-slate-600">{w.description}</p>
)}
<p className="mt-1 text-xs text-slate-400">Strefa czasowa: {w.timezone}</p>
</div>
<Link
to={`/w/${w.slug}`}
className="shrink-0 rounded-md bg-blue-500 px-4 py-2.5 text-center text-sm font-medium text-white transition-colors duration-fast hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-blue-500/45"
>
Zapisz się
</Link>
</article>
)
})}
</div>
)}
</AcademyLayout>
)
}
/**
* Cennik.
*
* Pokazuje ceny FAKTYCZNYCH kursow, a nie planow abonamentowych. Makieta
* proponowala trzy pakiety miesieczne, ale platforma rozlicza sie za kurs:
* jedno zamowienie, jedna platnosc, dostep bezterminowy. Cennik obiecujacy
* abonament, ktorego nie da sie kupic, wprowadzalby w blad.
*/
export function PricingPage() {
useDocumentMeta({ title: 'Cennik' })
const { data, isLoading, isError } = useQuery({
queryKey: ['academy', 'courses'],
queryFn: async () =>
(await publicClient.get<PublicCourseCard[]>('/public/academy/courses')).data,
})
const free = (data ?? []).filter((c) => c.free)
const paid = (data ?? []).filter((c) => !c.free)
return (
<AcademyLayout>
<h1 className="text-2xl font-semibold text-slate-900">Cennik</h1>
<p className="mt-1.5 text-sm text-slate-600">
Płacisz za konkretny kurs, jednorazowo. Dostęp do kupionego kursu jest bezterminowy.
</p>
{isLoading && (
<div className="mt-8">
<Spinner label="Wczytywanie cennika..." />
</div>
)}
{isError && (
<div className="mt-8">
<ErrorState message="Nie udało się wczytać cennika." />
</div>
)}
{data && (
<>
{free.length > 0 && (
<section className="mt-8">
<h2 className="flex items-center gap-2 text-lg font-semibold text-slate-900">
<Icon name="check-circle" size={20} className="text-ok-500" />
Kursy bezpłatne
</h2>
<p className="mt-1 text-sm text-slate-600">
Wystarczy założyć konto. Certyfikaty i zapis postępów działają tak samo.
</p>
<div className="mt-4 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{free.map((c) => (
<PriceRow key={c.id} course={c} />
))}
</div>
</section>
)}
{paid.length > 0 && (
<section className="mt-10">
<h2 className="flex items-center gap-2 text-lg font-semibold text-slate-900">
<Icon name="credit-card" size={20} className="text-blue-500" />
Kursy płatne
</h2>
<div className="mt-4 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{paid.map((c) => (
<PriceRow key={c.id} course={c} />
))}
</div>
</section>
)}
{data.length === 0 && (
<p className="mt-8 rounded-lg border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">
Oferta kursów jest w przygotowaniu.
</p>
)}
<section className="mt-10 rounded-lg border border-slate-100 bg-white p-5 shadow-sm">
<h2 className="text-sm font-semibold text-slate-900">Szkolenia dla zespołu</h2>
<p className="mt-1.5 text-sm leading-relaxed text-slate-600">
Prowadzimy także szkolenia zamknięte i zapewniamy konta dla całych zespołów.
Napisz do nas, przygotujemy wycenę.
</p>
</section>
</>
)}
</AcademyLayout>
)
}
function PriceRow({ course }: { course: PublicCourseCard }) {
return (
<Link
to={`/kurs/${course.slug}`}
className="flex items-center gap-4 border-b border-slate-50 px-5 py-4 last:border-b-0 hover:bg-slate-25 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-blue-500/45"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">{course.title}</p>
<p className="mt-0.5 text-xs text-slate-500">
{course.lessonCount} {lessonWord(course.lessonCount)}
{course.category ? ` · ${course.category}` : ''}
{course.certificateEnabled ? ' · certyfikat' : ''}
</p>
</div>
<span
className={`hx-data shrink-0 text-sm font-semibold ${
course.free ? 'text-ok-600' : 'text-slate-900'
}`}
>
{formatPrice(course.priceGross, course.currency)}
</span>
<Icon name="chevron-right" size={16} className="shrink-0 text-slate-300" />
</Link>
)
}
+446
View File
@@ -0,0 +1,446 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { academyClient, academyErrorMessage } from '../api/academyClient'
import { publicClient } from '../api/publicClient'
import type { EnrollResponse, PreviewLesson, PublicCourse } from '../api/types'
import { Icon } from '../components/Icon'
import { Modal } from '../components/Modal'
import { Alert, Badge, Button, ErrorState, Spinner } from '../components/ui'
import { useToasts } from '../components/Toasts'
import { useDocumentMeta } from '../lib/useDocumentMeta'
import { AcademyLayout } from './AcademyLayout'
import {
CourseCover,
LESSON_TYPE_ICON,
LESSON_TYPE_LABEL,
LEVEL_LABEL,
formatCourseDuration,
formatPrice,
lessonWord,
} from './AcademyBits'
import { useStudentAuth } from './StudentAuthContext'
import { STUDENT_ROUTES } from '../lib/routes'
/**
* Strona kursu, dostepna bez logowania.
*
* Przycisk akcji zalezy od stanu, ale stan rozstrzyga BACKEND (pole `action`).
* Skladanie go w przegladarce z ceny, zapisu i zamowienia oznaczaloby druga
* implementacje reguly zapisu, rozjezdzajaca sie z ta po stronie serwera.
*/
export function CoursePage() {
const { slug = '' } = useParams()
const { student } = useStudentAuth()
const navigate = useNavigate()
const queryClient = useQueryClient()
const toast = useToasts()
const [preview, setPreview] = useState<PreviewLesson | null>(null)
/*
* Klucz zapytania zawiera identyfikator kursanta: bez tego po zalogowaniu
* strona pokazywalaby przycisk z pamieci podrecznej goscia, czyli "Zaloguj
* sie, zeby sie zapisac" osobie juz zalogowanej.
*/
const { data, isLoading, isError } = useQuery({
queryKey: ['academy', 'course', slug, student?.id ?? null],
queryFn: async () => {
// zalogowany pyta klientem z tokenem, zeby backend znal jego zapisy
const client = student ? academyClient : publicClient
return (await client.get<PublicCourse>(`/public/academy/courses/${slug}`)).data
},
enabled: Boolean(slug),
})
useDocumentMeta({
title: data?.title ?? 'Kurs',
description: data?.subtitle || data?.description || undefined,
})
const enroll = useMutation({
mutationFn: async () => {
const response = await academyClient.post<EnrollResponse>(
`/academy/courses/${data?.id}/enroll`,
)
return response.data
},
onSuccess: (result) => {
if (result.enrolled) {
toast.success('Zapisano na kurs')
queryClient.invalidateQueries({ queryKey: ['academy'] })
navigate(`/kurs/${slug}/nauka`)
return
}
if (result.paymentUrl) {
// przejscie na bramke: to opuszczenie naszej strony, wiec zamiast
// Link uzywamy przypisania adresu
window.location.href = result.paymentUrl
}
},
onError: (error) => toast.error(academyErrorMessage(error, 'Nie udało się zapisać na kurs')),
})
const openPreview = useMutation({
mutationFn: async (lessonId: number) => {
const response = await publicClient.get<PreviewLesson>(
`/public/academy/courses/${slug}/lessons/${lessonId}/preview`,
)
return response.data
},
onSuccess: (lesson) => setPreview(lesson),
onError: () => toast.error('Nie udało się otworzyć zapowiedzi lekcji'),
})
if (isLoading) {
return (
<AcademyLayout>
<Spinner label="Wczytywanie kursu..." />
</AcademyLayout>
)
}
if (isError || !data) {
return (
<AcademyLayout>
<ErrorState message="Nie znaleziono kursu." />
<div className="mt-4">
<Link to="/kursy">
<Button variant="secondary" icon="arrow-left">
Wróć do listy kursów
</Button>
</Link>
</div>
</AcademyLayout>
)
}
const duration = formatCourseDuration(data.durationMinutes)
return (
<AcademyLayout>
<nav className="mb-4">
<Link
to="/kursy"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600"
>
<Icon name="arrow-left" size={16} />
Wszystkie kursy
</Link>
</nav>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_320px]">
{/* --- kolumna glowna --- */}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{data.category && <Badge tone="info">{data.category}</Badge>}
{data.level && <Badge tone="neutral">{LEVEL_LABEL[data.level]}</Badge>}
{data.free ? <Badge tone="ok">Bezpłatny</Badge> : <Badge tone="brand">Płatny</Badge>}
</div>
<h1 className="mt-3 text-3xl font-bold leading-tight text-slate-900">{data.title}</h1>
{data.subtitle && (
<p className="mt-2 text-base leading-relaxed text-slate-600">{data.subtitle}</p>
)}
<dl className="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-sm text-slate-600">
<div className="flex items-center gap-1.5">
<Icon name="book-open" size={16} className="text-slate-400" />
<span className="hx-data">{data.lessonCount}</span> {lessonWord(data.lessonCount)}
</div>
{duration && (
<div className="flex items-center gap-1.5">
<Icon name="clock" size={16} className="text-slate-400" />
<span className="hx-data">{duration}</span>
</div>
)}
<div className="flex items-center gap-1.5">
<Icon name="users" size={16} className="text-slate-400" />
<span className="hx-data">{data.enrolledCount}</span> zapisanych
</div>
{data.certificateEnabled && (
<div className="flex items-center gap-1.5 text-blue-600">
<Icon name="award" size={16} />
Certyfikat po ukończeniu
</div>
)}
</dl>
{data.description && (
<p className="mt-6 text-base leading-relaxed text-slate-700">{data.description}</p>
)}
{/* Tresc jest oczyszczona przy zapisie na backendzie (HtmlSanitizer),
wiec nie czyscimy jej drugi raz w przegladarce. */}
{data.contentHtml && (
<div
className="prose prose-slate mt-6 max-w-none overflow-x-auto prose-img:max-w-full"
dangerouslySetInnerHTML={{ __html: data.contentHtml }}
/>
)}
{/* --- program --- */}
<section className="mt-10">
<h2 className="text-xl font-semibold text-slate-900">Program kursu</h2>
<p className="mt-1 text-sm text-slate-500">
Lekcje oznaczone jako zapowiedź możesz obejrzeć bez zapisu.
</p>
<div className="mt-5 space-y-4">
{data.modules.map((module, index) => (
<section
key={module.id}
className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm"
>
<header className="flex items-baseline gap-3 border-b border-slate-100 px-4 py-3">
<span className="hx-data text-xs font-semibold text-slate-400">
{String(index + 1).padStart(2, '0')}
</span>
<h3 className="text-sm font-semibold text-slate-900">{module.title}</h3>
<span className="ml-auto text-xs text-slate-500">
{module.lessons.length} {lessonWord(module.lessons.length)}
</span>
</header>
<ul className="divide-y divide-slate-50">
{module.lessons.map((lesson) => (
<li
key={lesson.id}
className="flex items-center gap-3 px-4 py-3 text-sm"
>
<Icon
name={LESSON_TYPE_ICON[lesson.type]}
size={16}
className="shrink-0 text-slate-400"
/>
<div className="min-w-0 flex-1">
<p className="text-slate-800">{lesson.title}</p>
<p className="text-xs text-slate-500">
{LESSON_TYPE_LABEL[lesson.type]}
{lesson.durationMinutes ? ` · ${lesson.durationMinutes} min` : ''}
{!lesson.required && ' · materiał dodatkowy'}
</p>
</div>
{lesson.preview ? (
<Button
size="sm"
variant="ghost"
icon="eye"
onClick={() => openPreview.mutate(lesson.id)}
disabled={openPreview.isPending}
>
Zapowiedź
</Button>
) : (
/*
Sama ikona klodki nic nie mowi czytnikowi ekranu:
komponent Icon ma na stale aria-hidden. Etykieta
idzie wiec tekstem, ukrytym wzrokowo na waskim
ekranie, ale zawsze obecnym w drzewie dostepnosci.
*/
<span className="flex shrink-0 items-center gap-1.5 text-xs text-slate-400">
<Icon name="lock" size={16} />
<span className="sr-only sm:not-sr-only">Po zapisaniu</span>
</span>
)}
</li>
))}
</ul>
</section>
))}
{data.modules.length === 0 && (
<p className="rounded-lg border border-dashed border-slate-200 bg-white p-6 text-center text-sm text-slate-500">
Program kursu zostanie opublikowany wkrótce.
</p>
)}
</div>
</section>
</div>
{/* --- karta akcji --- */}
<aside className="lg:sticky lg:top-20 lg:self-start">
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
<CourseCover mediaId={data.coverMediaId} title={data.title} className="h-40 w-full" />
<div className="p-5">
<p className="text-2xl font-bold text-slate-900">
{data.free ? 'Bezpłatny' : formatPrice(data.priceGross, data.currency)}
</p>
{!data.free && (
<p className="mt-0.5 text-xs text-slate-500">Jednorazowa opłata, dostęp bez limitu czasu</p>
)}
<div className="mt-5">
<ActionButton
course={data}
slug={slug}
pending={enroll.isPending}
onEnroll={() => enroll.mutate()}
/>
</div>
{data.certificateEnabled && (
<p className="mt-4 flex items-start gap-2 text-xs leading-relaxed text-slate-600">
<Icon name="award" size={14} className="mt-0.5 shrink-0 text-blue-500" />
Po ukończeniu kursu otrzymasz certyfikat
{data.certificateHours ? ` na ${data.certificateHours} godz.` : ''}.
</p>
)}
</div>
</div>
</aside>
</div>
{/* Zapowiedz lekcji w oknie: bez opuszczania strony kursu. */}
{preview && (
<Modal title={preview.title} onClose={() => setPreview(null)}>
<PreviewContent lesson={preview} />
</Modal>
)}
</AcademyLayout>
)
}
/**
* Przycisk akcji.
*
* Kazdy stan ma wlasny komunikat mowiacy, co sie stanie po klknieciu:
* przycisk "Dalej" bez wyjasnienia zmusza do zgadywania, czy wlasnie placi,
* czy zapisuje sie za darmo.
*/
function ActionButton({
course,
slug,
pending,
onEnroll,
}: {
course: PublicCourse
slug: string
pending: boolean
onEnroll: () => void
}) {
switch (course.action) {
case 'LOGIN_REQUIRED':
return (
<>
{/* Powrot na te sama strone po zalogowaniu: adres idzie w stanie
trasy, a nie w parametrze, zeby nie dalo sie podstawic obcego
adresu przekierowania. */}
<Link to={STUDENT_ROUTES.login} state={{ from: `/kurs/${slug}` }}>
<Button size="lg" icon="log-in" className="w-full">
Zaloguj się, żeby się zapisać
</Button>
</Link>
<p className="mt-3 text-center text-xs text-slate-500">
Nie masz konta?{' '}
<Link
to={STUDENT_ROUTES.register}
state={{ from: `/kurs/${slug}` }}
className="font-medium text-blue-600 hover:underline"
>
Załóż je za darmo
</Link>
</p>
</>
)
case 'ENROLL_FREE':
return (
<Button size="lg" icon="check" className="w-full" onClick={onEnroll} disabled={pending}>
{pending ? 'Zapisywanie...' : 'Zapisz się na kurs'}
</Button>
)
case 'BUY':
return (
<Link to={`/kurs/${slug}/zakup`}>
<Button size="lg" icon="credit-card" className="w-full">
Kup dostęp
</Button>
</Link>
)
case 'AWAITING_PAYMENT':
return (
<>
<Alert tone="info" icon="clock">
Czekamy na potwierdzenie płatności. Dostęp otrzymasz automatycznie, gdy operator
potwierdzi wpłatę.
</Alert>
{course.pendingOrderNumber && (
<p className="mt-3 text-center text-xs text-slate-500">
Numer zamówienia:{' '}
<span className="hx-data font-medium text-slate-700">
{course.pendingOrderNumber}
</span>
</p>
)}
<Link to={`/kurs/${slug}/platnosc`} className="mt-3 block">
<Button variant="secondary" className="w-full">
Sprawdź status płatności
</Button>
</Link>
</>
)
case 'CONTINUE':
return (
<Link to={`/kurs/${slug}/nauka`}>
<Button size="lg" icon="play" className="w-full">
Przejdź do kursu
</Button>
</Link>
)
case 'UNAVAILABLE':
default:
return (
<Alert tone="warn" icon="lock">
Zapisy na ten kurs są obecnie zamknięte.
</Alert>
)
}
}
/** Tresc zapowiedzi lekcji: nagranie, tekst albo plik. */
function PreviewContent({ lesson }: { lesson: PreviewLesson }) {
if (lesson.type === 'VIDEO') {
if (lesson.externalVideoUrl) {
return (
<div className="aspect-video w-full overflow-hidden rounded-md bg-slate-900">
<iframe
src={lesson.externalVideoUrl}
title={lesson.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture"
allowFullScreen
className="h-full w-full border-0"
/>
</div>
)
}
if (lesson.mediaId) {
return (
<video
src={`/api/public/academy/lessons/media/${lesson.mediaId}`}
controls
controlsList="nodownload"
className="w-full rounded-md bg-slate-900"
>
Twoja przeglądarka nie odtwarza nagrań.
</video>
)
}
}
if (lesson.contentHtml) {
return (
<div
className="prose prose-slate max-w-none overflow-x-auto prose-img:max-w-full"
dangerouslySetInnerHTML={{ __html: lesson.contentHtml }}
/>
)
}
return <p className="text-sm text-slate-500">Ta lekcja nie ma zapowiedzi do wyświetlenia.</p>
}
+553
View File
@@ -0,0 +1,553 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { academyClient, academyErrorMessage, getStudentToken } from '../api/academyClient'
import type { ProgressResult, StudyCourse, StudyLessonContent } from '../api/types'
import { Icon } from '../components/Icon'
import { Alert, Button, ErrorState, Spinner } from '../components/ui'
import { useToasts } from '../components/Toasts'
import { formatDateTimeParts } from '../lib/dates'
import { useDocumentMeta } from '../lib/useDocumentMeta'
import { AcademyLayout } from './AcademyLayout'
import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, ProgressBar } from './AcademyBits'
/**
* Odtwarzacz kursu.
*
* Uklad: lewa kolumna z programem i postepem, prawa z zawartoscia biezacej
* lekcji. Na telefonie program zwija sie nad tresc, bo przy dwoch kolumnach
* na 320px zadna nie bylaby czytelna.
*
* Biezaca lekcja jest w adresie (`?lekcja=`), a nie w stanie komponentu:
* dzieki temu da sie ja odswiezyc, wrocic do niej przyciskiem wstecz
* i podeslac sobie linkiem na inne urzadzenie.
*/
export function CoursePlayerPage() {
const { slug = '' } = useParams()
const [params, setParams] = useSearchParams()
const queryClient = useQueryClient()
const toast = useToasts()
const [menuOpen, setMenuOpen] = useState(false)
const course = useQuery({
queryKey: ['academy', 'study', slug],
queryFn: async () => (await academyClient.get<StudyCourse>(`/academy/courses/${slug}/study`)).data,
enabled: Boolean(slug),
})
// brak lekcji w adresie: otwieramy te, na ktorej kursant skonczyl
const lessonId = useMemo(() => {
const fromUrl = Number(params.get('lekcja'))
if (Number.isFinite(fromUrl) && fromUrl > 0) {
return fromUrl
}
return course.data?.resumeLessonId ?? null
}, [params, course.data])
const lesson = useQuery({
queryKey: ['academy', 'lesson', lessonId],
queryFn: async () =>
(await academyClient.get<StudyLessonContent>(`/academy/lessons/${lessonId}`)).data,
enabled: Boolean(lessonId),
})
useDocumentMeta({
title: lesson.data
? `${lesson.data.title} - ${course.data?.title ?? ''}`
: (course.data?.title ?? 'Kurs'),
})
const saveProgress = useMutation({
mutationFn: async (payload: { watchedSeconds?: number; completed?: boolean }) => {
const response = await academyClient.put<ProgressResult>(
`/academy/lessons/${lessonId}/progress`,
payload,
)
return response.data
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['academy', 'study', slug] })
queryClient.invalidateQueries({ queryKey: ['academy', 'lesson', lessonId] })
if (result.courseCompleted) {
toast.success('Gratulacje, kurs ukończony!')
}
},
onError: (error) => toast.error(academyErrorMessage(error, 'Nie udało się zapisać postępu')),
})
const selectLesson = (id: number) => {
setParams({ lekcja: String(id) })
setMenuOpen(false)
// przewiniecie na gore: przy dlugiej lekcji nowa zaczynalaby sie w polowie
window.scrollTo({ top: 0, behavior: 'smooth' })
}
if (course.isLoading) {
return (
<AcademyLayout>
<Spinner label="Wczytywanie kursu..." />
</AcademyLayout>
)
}
if (course.isError || !course.data) {
return (
<AcademyLayout>
<ErrorState message="Nie masz dostępu do tego kursu albo dostęp wygasł." />
<div className="mt-4">
<Link to={`/kurs/${slug}`}>
<Button variant="secondary" icon="arrow-left">
Wróć do strony kursu
</Button>
</Link>
</div>
</AcademyLayout>
)
}
const data = course.data
const allLessons = data.modules.flatMap((m) => m.lessons)
const currentIndex = allLessons.findIndex((l) => l.id === lessonId)
const previous = currentIndex > 0 ? allLessons[currentIndex - 1] : null
const next = currentIndex >= 0 && currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
return (
<AcademyLayout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<Link
to={`/kurs/${slug}`}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600"
>
<Icon name="arrow-left" size={16} />
Strona kursu
</Link>
<h1 className="mt-1 truncate text-xl font-semibold text-slate-900">{data.title}</h1>
</div>
{data.status === 'COMPLETED' && (
<span className="flex items-center gap-1.5 rounded-full bg-ok-50 px-3 py-1.5 text-sm font-medium text-ok-600">
<Icon name="check-circle" size={16} />
Kurs ukończony
</span>
)}
</div>
<div className="grid gap-6 lg:grid-cols-[300px_minmax(0,1fr)]">
{/* --- program --- */}
<aside className="lg:sticky lg:top-20 lg:self-start">
<div className="rounded-lg border border-slate-100 bg-white shadow-sm">
<div className="border-b border-slate-100 p-4">
<ProgressBar
percent={data.progressPercent}
label={`Ukończono ${data.completedRequired} z ${data.totalRequired}`}
/>
</div>
{/* Na telefonie program jest zwiniety: lista kilkudziesieciu lekcji
nad trescia oznaczalaby przewijanie przez caly program przy
kazdym wejsciu. */}
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
className="flex min-h-11 w-full items-center justify-between gap-2 px-4 py-3 text-sm font-medium text-slate-700 lg:hidden"
>
Program kursu
<Icon name={menuOpen ? 'chevron-up' : 'chevron-down'} size={18} />
</button>
<div className={`${menuOpen ? 'block' : 'hidden'} lg:block`}>
<nav className="max-h-[60vh] overflow-y-auto p-2">
{data.modules.map((module, index) => (
<section key={module.id} className="mb-2 last:mb-0">
<h2 className="flex items-baseline gap-2 px-2 py-1.5 text-xs font-semibold uppercase tracking-caps text-slate-500">
<span className="hx-data">{String(index + 1).padStart(2, '0')}</span>
<span className="min-w-0 truncate normal-case tracking-normal text-slate-700">
{module.title}
</span>
</h2>
<ul>
{module.lessons.map((l) => {
const active = l.id === lessonId
const done = l.status === 'COMPLETED'
return (
<li key={l.id}>
<button
type="button"
onClick={() => selectLesson(l.id)}
aria-current={active ? 'true' : undefined}
className={[
'flex min-h-11 w-full items-center gap-2.5 rounded-md px-2 py-2 text-left text-sm transition-colors duration-fast',
active
? 'bg-blue-50 font-medium text-blue-700'
: 'text-slate-700 hover:bg-slate-50',
].join(' ')}
>
<Icon
name={done ? 'check-circle' : LESSON_TYPE_ICON[l.type]}
size={16}
className={`shrink-0 ${done ? 'text-ok-500' : 'text-slate-400'}`}
/>
<span className="min-w-0 flex-1 truncate">{l.title}</span>
{!l.required && (
<span className="shrink-0 text-2xs text-slate-400">dodatkowa</span>
)}
</button>
</li>
)
})}
</ul>
</section>
))}
</nav>
</div>
</div>
</aside>
{/* --- tresc lekcji --- */}
<div className="min-w-0">
{lesson.isLoading && <Spinner label="Wczytywanie lekcji..." />}
{lesson.isError && <ErrorState message="Nie udało się wczytać lekcji." />}
{!lessonId && !lesson.isLoading && (
<p className="rounded-lg border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">
Ten kurs nie ma jeszcze żadnych lekcji.
</p>
)}
{lesson.data && (
<article className="rounded-lg border border-slate-100 bg-white shadow-sm">
<header className="border-b border-slate-100 px-5 py-4">
<p className="flex items-center gap-1.5 text-xs text-slate-500">
<Icon name={LESSON_TYPE_ICON[lesson.data.type]} size={14} />
{LESSON_TYPE_LABEL[lesson.data.type]}
{lesson.data.durationMinutes ? ` · ${lesson.data.durationMinutes} min` : ''}
</p>
<h2 className="mt-1 text-lg font-semibold text-slate-900">{lesson.data.title}</h2>
</header>
<div className="p-5">
<LessonBody
lesson={lesson.data}
videoCompletePercent={data.videoCompletePercent}
onWatched={(seconds) => saveProgress.mutate({ watchedSeconds: seconds })}
/>
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-5 py-4">
<div className="flex gap-2">
{previous && (
<Button
variant="secondary"
size="sm"
icon="chevron-left"
onClick={() => selectLesson(previous.id)}
>
Poprzednia
</Button>
)}
{next && (
<Button size="sm" variant="secondary" onClick={() => selectLesson(next.id)}>
Następna
<Icon name="chevron-right" size={16} />
</Button>
)}
</div>
<CompleteButton
lesson={lesson.data}
pending={saveProgress.isPending}
onComplete={() => saveProgress.mutate({ completed: true })}
/>
</footer>
</article>
)}
</div>
</div>
</AcademyLayout>
)
}
/**
* Przycisk oznaczenia lekcji jako ukonczonej.
*
* Lekcja wideo domyka sie sama po przekroczeniu progu, wiec ma tu wylacznie
* informacje o stanie. Lekcja ze sprawdzianem zalicza sie zdaniem testu:
* przycisk pozwalalby ominac warunek zaliczenia kursu.
*/
function CompleteButton({
lesson,
pending,
onComplete,
}: {
lesson: StudyLessonContent
pending: boolean
onComplete: () => void
}) {
if (lesson.status === 'COMPLETED') {
return (
<span className="flex items-center gap-1.5 text-sm font-medium text-ok-600">
<Icon name="check-circle" size={18} />
Ukończona
</span>
)
}
if (lesson.type === 'QUIZ') {
return <span className="text-sm text-slate-500">Zaliczysz ją po zdaniu sprawdzianu</span>
}
if (lesson.type === 'VIDEO') {
return <span className="text-sm text-slate-500">Zaliczy się po obejrzeniu nagrania</span>
}
return (
<Button size="sm" icon="check" onClick={onComplete} disabled={pending}>
{pending ? 'Zapisywanie...' : 'Oznacz jako ukończoną'}
</Button>
)
}
/** Zawartosc lekcji, zaleznie od jej rodzaju. */
function LessonBody({
lesson,
videoCompletePercent,
onWatched,
}: {
lesson: StudyLessonContent
videoCompletePercent: number
onWatched: (seconds: number) => void
}) {
switch (lesson.type) {
case 'VIDEO':
return (
<LessonVideo
lesson={lesson}
videoCompletePercent={videoCompletePercent}
onWatched={onWatched}
/>
)
case 'FILE':
return (
<div className="text-center">
<Icon name="file-text" size={40} className="mx-auto text-slate-300" />
<p className="mt-3 text-sm text-slate-600">
Ta lekcja to plik do pobrania. Po pobraniu oznacz ją jako ukończoną.
</p>
{lesson.mediaId && (
<a
href={mediaSrc(lesson.mediaId)}
className="mt-4 inline-block"
download
>
<Button icon="download">Pobierz materiał</Button>
</a>
)}
{lesson.contentHtml && (
<div
className="prose prose-slate mx-auto mt-6 max-w-none text-left"
dangerouslySetInnerHTML={{ __html: lesson.contentHtml }}
/>
)}
</div>
)
case 'WEBINAR':
return (
<div>
{lesson.webinarTitle && (
<div className="rounded-md border border-slate-100 bg-slate-25 p-4">
<p className="text-sm font-medium text-slate-900">{lesson.webinarTitle}</p>
{lesson.webinarStartsAt && (
<p className="hx-data mt-1 text-sm text-slate-600">
{formatDateTimeParts(lesson.webinarStartsAt, 'Europe/Warsaw').date}{' '}
{formatDateTimeParts(lesson.webinarStartsAt, 'Europe/Warsaw').time}
</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{lesson.webinarSlug && (
<a href={`/w/${lesson.webinarSlug}`} target="_blank" rel="noopener noreferrer">
<Button size="sm" icon="presentation">
Strona webinaru
</Button>
</a>
)}
{/* Nagranie pokazuje sie dopiero, gdy jest gotowe i nie jest
prywatne: rozstrzyga to backend. */}
{lesson.webinarRecordingId && (
<a
href={`/api/public/recordings/${lesson.webinarRecordingId}`}
target="_blank"
rel="noopener noreferrer"
>
<Button size="sm" variant="secondary" icon="play">
Obejrzyj nagranie
</Button>
</a>
)}
</div>
</div>
)}
{lesson.contentHtml && (
<div
className="prose prose-slate mt-5 max-w-none"
dangerouslySetInnerHTML={{ __html: lesson.contentHtml }}
/>
)}
</div>
)
case 'QUIZ':
return (
<div className="text-center">
<Icon name="list-checks" size={40} className="mx-auto text-slate-300" />
<p className="mt-3 text-sm text-slate-600">
Ta lekcja kończy się sprawdzianem. Po jego zdaniu lekcja zostanie zaliczona.
</p>
<Link to={`/kurs/lekcja/${lesson.id}/sprawdzian`} className="mt-4 inline-block">
<Button icon="list-checks">Przejdź do sprawdzianu</Button>
</Link>
</div>
)
case 'TEXT':
default:
return lesson.contentHtml ? (
<div
className="prose prose-slate max-w-none overflow-x-auto prose-img:max-w-full"
dangerouslySetInnerHTML={{ __html: lesson.contentHtml }}
/>
) : (
<p className="text-sm text-slate-500">Ta lekcja nie ma jeszcze treści.</p>
)
}
}
/**
* Adres pliku lekcji wraz z tokenem.
*
* Element `video` i zwykly odnosnik pobieraja plik wlasnym zadaniem, do
* ktorego przegladarka nie pozwala dolozyc naglowka `Authorization`. Token
* idzie wiec parametrem, dokladnie tak samo jak przy nagraniach webinaru.
*/
function mediaSrc(mediaId: number) {
const token = getStudentToken()
const query = token ? `?token=${encodeURIComponent(token)}` : ''
return `/api/academy/lessons/media/${mediaId}${query}`
}
/**
* Nagranie lekcji.
*
* Postep idzie na serwer co kilkanascie sekund, a nie przy kazdym zdarzeniu
* `timeupdate`: przegladarka wywoluje je kilka razy na sekunde, wiec zapis przy
* kazdym oznaczalby setki zadan na jedna lekcje.
*
* O ukonczeniu decyduje SERWER na podstawie przeslanych sekund. Tutaj tylko
* podpowiadamy, ile zostalo: gdyby to przegladarka wysylala "ukonczone",
* wystarczyloby jedno podstawione zadanie, zeby zaliczyc kurs bez ogladania.
*/
function LessonVideo({
lesson,
videoCompletePercent,
onWatched,
}: {
lesson: StudyLessonContent
videoCompletePercent: number
onWatched: (seconds: number) => void
}) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const lastSent = useRef(0)
const maxWatched = useRef(lesson.watchedSeconds)
// przy zmianie lekcji licznik zaczyna sie od nowa
useEffect(() => {
maxWatched.current = lesson.watchedSeconds
lastSent.current = 0
}, [lesson.id, lesson.watchedSeconds])
// wznowienie od miejsca, w ktorym kursant skonczyl
useEffect(() => {
const el = videoRef.current
if (el && lesson.watchedSeconds > 0 && el.currentTime < 1) {
el.currentTime = lesson.watchedSeconds
}
}, [lesson.id, lesson.watchedSeconds])
if (lesson.externalVideoUrl) {
/*
* Wideo zewnetrzne idzie w ramce dostawcy, wiec nie mamy dostepu do jego
* osi czasu: postepu nie da sie zmierzyc i lekcja domyka sie przyciskiem.
*/
return (
<>
<div className="aspect-video w-full overflow-hidden rounded-md bg-slate-900">
<iframe
src={lesson.externalVideoUrl}
title={lesson.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture"
allowFullScreen
className="h-full w-full border-0"
/>
</div>
<Alert tone="info" icon="circle-alert">
Nagranie jest osadzone z zewnętrznego serwisu, więc postęp nie liczy się
automatycznie. Po obejrzeniu oznacz lekcję jako ukończoną.
</Alert>
</>
)
}
if (!lesson.mediaId) {
return <p className="text-sm text-slate-500">Nagranie nie zostało jeszcze dodane.</p>
}
const handleTimeUpdate = () => {
const el = videoRef.current
if (!el) return
const seconds = Math.floor(el.currentTime)
if (seconds > maxWatched.current) {
maxWatched.current = seconds
}
// zapis nie czesciej niz co 15 sekund obejrzanego materialu
if (maxWatched.current - lastSent.current >= 15) {
lastSent.current = maxWatched.current
onWatched(maxWatched.current)
}
}
const handleEnded = () => {
const el = videoRef.current
if (el) {
const seconds = Math.floor(el.duration || el.currentTime)
maxWatched.current = Math.max(maxWatched.current, seconds)
}
onWatched(maxWatched.current)
}
return (
<>
<video
ref={videoRef}
src={mediaSrc(lesson.mediaId)}
controls
controlsList="nodownload"
onTimeUpdate={handleTimeUpdate}
onEnded={handleEnded}
onPause={() => onWatched(maxWatched.current)}
className="w-full rounded-md bg-slate-900"
>
Twoja przeglądarka nie odtwarza nagrań.
</video>
{lesson.status !== 'COMPLETED' && (
<p className="mt-3 text-xs text-slate-500">
Lekcja zaliczy się po obejrzeniu {videoCompletePercent}% nagrania.
</p>
)}
</>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { UpcomingWebinar } from '../api/types'
import { Icon } from '../components/Icon'
import { formatDateTimeParts } from '../lib/dates'
/**
* Karta nadchodzacego webinaru z polem adresu, wedlug makiety.
*
* DLACZEGO NIE ZAPISUJE OD RAZU.
* ---------------------------------------------------------------------------
* Makieta pokazuje pole adresu i przycisk "Zapisz sie" wprost na stronie
* glownej. Zapis wymaga jednak wiecej niz adresu:
*
* - `registrations.first_name` i `last_name` maja NOT NULL w bazie, a endpoint
* oznacza je `@NotBlank`, wiec zapis z samym adresem zostanie odrzucony,
* - zgoda na regulamin musi byc zapisana **z wersja tresci**, bo stanowi slad
* rozliczalny; sam checkbox przy polu tego nie zalatwia, trzeba jeszcze
* pokazac tresc, na ktora uzytkownik sie zgadza.
*
* Zmieszczenie imienia, nazwiska i zgody w karcie zamienialoby ja w formularz
* wyzszy niz sama karta i powielaloby regule zapisu, ktora strona webinaru juz
* realizuje poprawnie: limit miejsc pod blokada wiersza, wersjonowanie zgod
* i wszystkie stany zamkniete.
*
* Dlatego karta **przenosi adres na strone webinaru** zamiast udawac, ze
* zapisuje. Przycisk mowi wprost "Przejdz do zapisu", a nie "Zapisz sie":
* przycisk, ktory nie robi tego, co obiecuje, jest gorszy niz jedno klikniecie
* wiecej. Adres nie przepada, wiec uzytkownik nie pisze go drugi raz.
*/
export function HomeWebinarCard({ webinar }: { webinar: UpcomingWebinar }) {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const when = formatDateTimeParts(webinar.startsAt, webinar.timezone)
const go = (event: React.FormEvent) => {
event.preventDefault()
const trimmed = email.trim()
/*
* Adres idzie parametrem zapytania, a nie stanem trasy: dzieki temu link
* daje sie odswiezyc i podeslac. Nie buduje sie z niego zadnej sciezki,
* wiec nie ma tu ryzyka przekierowania na obcy adres.
*/
navigate(
trimmed
? `/w/${webinar.slug}?email=${encodeURIComponent(trimmed)}`
: `/w/${webinar.slug}`,
)
}
return (
<article className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5 shadow-sm transition-shadow duration-fast hover:shadow-md">
<div className="flex items-center justify-between gap-3">
<span className="hx-data text-sm font-semibold text-blue-600">
{when.date} <span className="text-slate-400">·</span> {when.time}
</span>
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-aqua-50 px-2.5 py-1 text-2xs font-semibold uppercase tracking-caps text-aqua-700">
<span className="h-1.5 w-1.5 rounded-full bg-aqua-500" />
Na żywo
</span>
</div>
<h3 className="text-base font-semibold leading-snug text-slate-900">{webinar.title}</h3>
{webinar.description && (
<p className="line-clamp-2 text-sm leading-relaxed text-slate-500">{webinar.description}</p>
)}
{/* mt-auto: przyciski wyrownuja sie na dole niezaleznie od dlugosci tytulu */}
<form onSubmit={go} className="mt-auto flex gap-2 pt-1">
<label className="sr-only" htmlFor={`email-${webinar.id}`}>
Twój adres e-mail, zapis na webinar {webinar.title}
</label>
<input
id={`email-${webinar.id}`}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Twój adres e-mail"
autoComplete="email"
className="min-h-11 min-w-0 flex-1 rounded-md border border-slate-200 px-3 text-base text-slate-800 transition-colors duration-fast placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-3 focus:ring-blue-500/45 sm:min-h-10 sm:text-sm"
/>
<button
type="submit"
className="min-h-11 shrink-0 rounded-md bg-navy-700 px-4 text-sm font-semibold text-white transition-colors duration-fast hover:bg-navy-800 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-blue-500/45 sm:min-h-10"
>
Przejdź do zapisu
</button>
</form>
<p className="flex items-center gap-1.5 text-2xs leading-relaxed text-slate-400">
<Icon name="circle-alert" size={12} className="shrink-0" />
Na następnym ekranie podasz imię i nazwisko oraz zaakceptujesz regulamin.
</p>
</article>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { Icon } from '../components/Icon'
import { PANEL_HOME, PANEL_MOVED_DISMISSED_KEY } from '../lib/routes'
/**
* Pasek o przeniesieniu panelu pod /panel.
*
* PO CO ISTNIEJE.
* ---------------------------------------------------------------------------
* Adresy `/kursy` i `/webinary` naleza teraz do publicznych list, a nie do
* panelu, i **celowo nie maja przekierowania**: pod jednym adresem nie moga
* stac dwie rozne strony. Administrator z zapisana zakladka trafia wiec na
* oferte i bez tego paska nie mialby jak sie domyslic, dokad poszedl panel.
*
* Pasek jest zlagodzeniem swiadomej straty, a nie naprawa: strona pod spodem
* jest poprawna i ma taka zostac.
*
* KOMU SIE POKAZUJE.
* ---------------------------------------------------------------------------
* Wylacznie ADMIN i MODERATOR, czyli osobom, ktore w ogole maja gdzie pojsc.
* Gosc i zwykly kursant nie zobacza niczego: dla nich to sa po prostu adresy
* list, a informacja o panelu bylaby szumem i ujawnialaby, ze panel istnieje
* pod takim adresem.
*
* Sesja panelu odtwarza sie z zapisanego tokenu, wiec `user` jest wypelniony
* takze poza panelem: dokladnie po to zniesiono ograniczenie odtwarzania
* sesji do tras `/panel`.
*
* ZNIKA NA STALE PO ZAMKNIECIU.
* ---------------------------------------------------------------------------
* Zamkniecie zapisuje sie w localStorage, bo pasek ma pomoc raz, a nie wracac
* przy kazdym wejsciu na liste kursow. Brak localStorage (tryb prywatny,
* zablokowane cookies) nie moze wywrocic strony, wiec odczyt i zapis ida
* w try/catch, a przy bledzie pasek po prostu zostaje widoczny.
*/
export function PanelMovedNotice() {
const { user } = useAuth()
const [dismissed, setDismissed] = useState(readDismissed)
const isStaff = user?.role === 'ADMIN' || user?.role === 'MODERATOR'
if (!isStaff || dismissed) {
return null
}
const dismiss = () => {
setDismissed(true)
try {
localStorage.setItem(PANEL_MOVED_DISMISSED_KEY, '1')
} catch {
// tryb prywatny albo zablokowane cookies: pasek zniknie do przeladowania
}
}
return (
<div
role="status"
className="mb-5 flex items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 sm:items-center"
>
<Icon name="circle-alert" size={18} className="mt-0.5 shrink-0 text-blue-600 sm:mt-0" />
<p className="min-w-0 flex-1 text-sm leading-relaxed text-blue-900">
Ten adres pokazuje teraz publiczną ofertę. Panel administracyjny znajdziesz pod{' '}
<Link to={PANEL_HOME} className="font-semibold underline underline-offset-2">
/panel
</Link>
. Warto poprawić zapisaną zakładkę.
</p>
<button
type="button"
onClick={dismiss}
aria-label="Zamknij informację o przeniesieniu panelu"
className="-mr-1 flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors duration-fast ease-standard hover:bg-blue-100"
>
<Icon name="x" size={18} />
</button>
</div>
)
}
/** Czy uzytkownik zamknal juz ten pasek. Brak localStorage znaczy "nie". */
function readDismissed(): boolean {
try {
return localStorage.getItem(PANEL_MOVED_DISMISSED_KEY) === '1'
} catch {
return false
}
}
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { academyClient } from '../api/academyClient'
import type { PublicCourse } from '../api/types'
import { Icon } from '../components/Icon'
import { Button, Spinner } from '../components/ui'
import { AcademyLayout } from './AcademyLayout'
import { useStudentAuth } from './StudentAuthContext'
import { STUDENT_ROUTES } from '../lib/routes'
/** Co ile sekund pytamy o stan zamowienia. */
const POLL_SECONDS = 5
/** Po ilu minutach przestajemy odpytywac i prosimy o odswiezenie recznie. */
const GIVE_UP_MINUTES = 10
/**
* Strona powrotu z bramki.
*
* POWROT Z BRAMKI NICZEGO NIE POTWIERDZA. To zwykle przekierowanie
* przegladarki, ktore da sie wywolac recznie wpisujac adres, wiec ta strona
* NIE nadaje dostepu i nie mowi "zaplacono". Dostep nadaje wylacznie komunikat
* ITN ze statusem SUCCESS, przyjety i zweryfikowany po stronie serwera.
*
* Dlatego strona pyta serwer o stan zamowienia co kilka sekund i czeka, az
* backend sam potwierdzi platnosc.
*/
export function PaymentReturnPage() {
const { slug = '' } = useParams()
const { student } = useStudentAuth()
const [elapsed, setElapsed] = useState(0)
const stopped = elapsed >= GIVE_UP_MINUTES * 60
const { data, isLoading } = useQuery({
queryKey: ['academy', 'course', slug, student?.id ?? null],
queryFn: async () =>
(await academyClient.get<PublicCourse>(`/public/academy/courses/${slug}`)).data,
enabled: Boolean(slug),
/*
* Odpytywanie zatrzymuje sie samo po nadaniu dostepu i po uplywie limitu:
* zakladka zostawiona otwarta na noc nie ma odpytywac serwera bez konca.
*/
refetchInterval: (query) => {
const course = query.state.data as PublicCourse | undefined
if (course?.action === 'CONTINUE' || stopped) {
return false
}
return POLL_SECONDS * 1000
},
})
useEffect(() => {
if (data?.action === 'CONTINUE' || stopped) {
return
}
const timer = window.setInterval(() => setElapsed((v) => v + 1), 1000)
return () => window.clearInterval(timer)
}, [data?.action, stopped])
if (isLoading) {
return (
<AcademyLayout>
<Spinner label="Sprawdzanie stanu płatności..." />
</AcademyLayout>
)
}
const paid = data?.action === 'CONTINUE'
return (
<AcademyLayout>
<div className="mx-auto max-w-xl">
<section className="rounded-lg border border-slate-100 bg-white p-8 text-center shadow-sm">
{paid ? (
<>
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-ok-50">
<Icon name="check-circle" size={30} className="text-ok-500" />
</span>
<h1 className="mt-4 text-xl font-semibold text-slate-900">Płatność potwierdzona</h1>
<p className="mt-2 text-sm leading-relaxed text-slate-600">
Dostęp do kursu <strong className="text-slate-900">{data?.title}</strong> został
nadany. Możesz zacząć naukę.
</p>
<Link to={`/kurs/${slug}/nauka`} className="mt-6 inline-block">
<Button size="lg" icon="play">
Przejdź do kursu
</Button>
</Link>
</>
) : (
<>
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-blue-50">
<Icon
name="clock"
size={30}
className={stopped ? 'text-slate-400' : 'animate-pulse text-blue-500'}
/>
</span>
<h1 className="mt-4 text-xl font-semibold text-slate-900">
Czekamy na potwierdzenie płatności
</h1>
<p className="mt-2 text-sm leading-relaxed text-slate-600">
Operator płatności potwierdza transakcję. Zwykle trwa to kilkanaście sekund, ale
przy przelewie tradycyjnym może potrwać dłużej. Dostęp nadamy automatycznie i
wyślemy wiadomość na adres{' '}
<span className="font-medium text-slate-900">{student?.email}</span>.
</p>
{data?.pendingOrderNumber && (
<p className="mt-4 text-sm text-slate-500">
Numer zamówienia:{' '}
<span className="hx-data font-medium text-slate-700">
{data.pendingOrderNumber}
</span>
</p>
)}
{stopped ? (
<p className="mt-5 rounded-md bg-warn-50 p-3 text-sm leading-relaxed text-warn-600">
Potwierdzenie nie przyszło w ciągu {GIVE_UP_MINUTES} minut. Nie zamykaj tej
strony bez potrzeby: sprawdź stan zamówienia w swoim koncie albo skontaktuj się
z nami, podając numer zamówienia.
</p>
) : (
<p className="mt-5 text-xs text-slate-400">
Sprawdzamy co {POLL_SECONDS} sekund. Nie musisz nic robić.
</p>
)}
<div className="mt-6 flex flex-wrap justify-center gap-3">
<Link to={STUDENT_ROUTES.dashboard}>
<Button variant="secondary" icon="graduation-cap">
Moje konto
</Button>
</Link>
<Link to={`/kurs/${slug}`}>
<Button variant="ghost">Strona kursu</Button>
</Link>
</div>
</>
)}
</section>
</div>
</AcademyLayout>
)
}
+581
View File
@@ -0,0 +1,581 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { academyClient, academyErrorMessage } from '../api/academyClient'
import type {
QuizAttempt,
QuizAttemptResult,
QuizAttemptSummary,
StudentQuizView,
} from '../api/types'
import { Icon } from '../components/Icon'
import { Alert, Button, Card, ErrorState, Spinner } from '../components/ui'
import { formatLocal } from '../lib/dates'
import { AcademyLayout } from './AcademyLayout'
import { STUDENT_ROUTES } from '../lib/routes'
/**
* Sprawdzian kursanta.
*
* WSZYSTKIE PYTANIA NA JEDNEJ LISCIE, NIE PO JEDNYM NA EKRANIE.
* ---------------------------------------------------------------------------
* Zestaw jest krotki (kilka do kilkunastu pytan), a lista daje dwie rzeczy,
* ktorych kreator nie daje za darmo: powrot do wczesniejszego pytania jest
* zwyklym przewinieciem, a licznik czasu i postep sa widoczne obok calosci,
* nie tylko obok biezacego pytania. Kreator wymagalby wlasnych przyciskow
* "wstecz", ktore konkurowalyby ze strzalka przegladarki, a przy limicie czasu
* kursant nie widzialby, ile pytan zostalo.
*
* ODPOWIEDZI ZAPISUJE SERWER, NIE PRZEGLADARKA.
* ---------------------------------------------------------------------------
* Kazde zaznaczenie idzie od razu PUT-em na `attempts/current/answers`.
* Stan podejscia nie zyje w pamieci karty: odswiezenie strony, padnieta
* przegladarka albo przesiadka na telefon zastaja ten sam zestaw pytan
* i te same zaznaczenia, bo `attempts/current` zwraca `selectedAnswerId`
* przy kazdym pytaniu. Trzymanie odpowiedzi lokalnie oznaczaloby, ze awaria
* kasuje caly test, a limit czasu nie mialby z czego policzyc wyniku.
*/
export function QuizPage() {
const { lessonId } = useParams()
const queryClient = useQueryClient()
const [attempt, setAttempt] = useState<QuizAttempt | null>(null)
const [result, setResult] = useState<QuizAttemptResult | null>(null)
const [error, setError] = useState<string | null>(null)
const quizKey = ['academy', 'quiz', lessonId]
const { data: quiz, isLoading, isError } = useQuery({
queryKey: quizKey,
queryFn: async () =>
(await academyClient.get<StudentQuizView>(`/academy/lessons/${lessonId}/quiz`)).data,
enabled: Boolean(lessonId),
})
const { data: history } = useQuery({
queryKey: [...quizKey, 'history'],
queryFn: async () =>
(await academyClient.get<QuizAttemptSummary[]>(`/academy/lessons/${lessonId}/quiz/attempts`))
.data,
enabled: Boolean(lessonId),
})
/*
* Podjecie trwajacego podejscia przy wejsciu na strone.
*
* 404 znaczy po prostu "nic nie trwa" i jest normalnym stanem, wiec nie
* pokazujemy bledu: kursant zobaczy ekran wejsciowy.
*/
useEffect(() => {
if (!lessonId) return
let cancelled = false
academyClient
.get<QuizAttempt>(`/academy/lessons/${lessonId}/quiz/attempts/current`)
.then((r) => {
if (!cancelled) setAttempt(r.data)
})
.catch(() => {
/* brak trwajacego podejscia: ekran wejsciowy */
})
return () => {
cancelled = true
}
}, [lessonId])
const start = useMutation({
mutationFn: async () =>
(await academyClient.post<QuizAttempt>(`/academy/lessons/${lessonId}/quiz/attempts`)).data,
onSuccess: (data) => {
setResult(null)
setAttempt(data)
},
onError: (e) => setError(academyErrorMessage(e, 'Nie udało się rozpocząć sprawdzianu')),
})
/** Podmiana zaznaczenia w stanie lokalnym. */
const applySelection = (questionId: number, answerId: number | null) =>
setAttempt((prev) =>
prev
? {
...prev,
questions: prev.questions.map((q) =>
q.id === questionId ? { ...q, selectedAnswerId: answerId } : q,
),
}
: prev,
)
/*
* Zaznaczenie widac OD RAZU, zapis idzie w tle.
*
* Pole radio jest w pelni kontrolowane przez `checked`, wiec bez tego
* klikniecie wracalo do poprzedniego stanu na czas przelotu do serwera:
* przy wolniejszym laczu wygladalo to, jakby odpowiedzi nie dalo sie
* wybrac. Zrodlem prawdy pozostaje serwer: gdy zapis sie nie uda,
* cofamy zaznaczenie do wartosci sprzed klikniecia i mowimy o tym wprost.
*/
const answer = useMutation({
mutationFn: async (payload: { questionId: number; answerId: number; previous: number | null }) => {
await academyClient.put(`/academy/lessons/${lessonId}/quiz/attempts/current/answers`, {
questionId: payload.questionId,
answerId: payload.answerId,
})
return payload
},
onMutate: (payload) => applySelection(payload.questionId, payload.answerId),
onError: (e, payload) => {
applySelection(payload.questionId, payload.previous)
setError(academyErrorMessage(e, 'Nie udało się zapisać odpowiedzi. Spróbuj ponownie.'))
},
})
const finish = useMutation({
mutationFn: async () =>
(
await academyClient.post<QuizAttemptResult>(
`/academy/lessons/${lessonId}/quiz/attempts/current/finish`,
)
).data,
onSuccess: (data) => {
setAttempt(null)
setResult(data)
// postep kursu i lista podejsc mogly sie zmienic
queryClient.invalidateQueries({ queryKey: ['academy'] })
},
onError: (e) => setError(academyErrorMessage(e, 'Nie udało się zakończyć sprawdzianu')),
})
if (isLoading) {
return (
<AcademyLayout>
<Spinner label="Wczytywanie sprawdzianu..." />
</AcademyLayout>
)
}
if (isError || !quiz) {
return (
<AcademyLayout>
<ErrorState message="Nie udało się wczytać sprawdzianu." />
</AcademyLayout>
)
}
return (
<AcademyLayout>
{error && (
<div className="mb-4">
<Alert tone="alarm">{error}</Alert>
</div>
)}
{result ? (
<QuizResult result={result} onRetry={() => start.mutate()} quiz={quiz} />
) : attempt ? (
<AttemptForm
attempt={attempt}
onAnswer={(questionId, answerId, previous) =>
answer.mutate({ questionId, answerId, previous })
}
onFinish={() => finish.mutate()}
finishing={finish.isPending}
/>
) : (
<QuizIntro
quiz={quiz}
history={history ?? []}
onStart={() => start.mutate()}
starting={start.isPending}
/>
)}
</AcademyLayout>
)
}
/** Ekran wejsciowy: zasady, historia, przycisk rozpoczecia. */
function QuizIntro({
quiz,
history,
onStart,
starting,
}: {
quiz: StudentQuizView
history: QuizAttemptSummary[]
onStart: () => void
starting: boolean
}) {
const finished = history.filter((h) => h.finishedAt)
return (
<div className="mx-auto max-w-2xl">
<Card title={quiz.title}>
{quiz.description && (
<p className="mb-4 text-sm leading-relaxed text-slate-600">{quiz.description}</p>
)}
<dl className="grid gap-3 sm:grid-cols-2">
<Rule icon="list-checks" label="Liczba pytań" value={`${quiz.questionCount}`} />
<Rule icon="check-circle" label="Próg zaliczenia" value={`${quiz.passThreshold}%`} />
<Rule
icon="clock"
label="Limit czasu"
value={quiz.timeLimitMinutes ? `${quiz.timeLimitMinutes} min` : 'bez limitu'}
/>
<Rule
icon="history"
label="Podejścia"
value={
quiz.maxAttempts
? `${quiz.attemptsUsed} z ${quiz.maxAttempts}`
: `${quiz.attemptsUsed}, bez limitu`
}
/>
</dl>
{quiz.alreadyPassed && (
<div className="mt-5">
<Alert tone="ok" icon="check-circle">
Ten sprawdzian jest już zaliczony. Możesz podejść ponownie, ale wynik nie zmieni
zaliczenia lekcji.
</Alert>
</div>
)}
{/*
Powod blokady pochodzi z backendu: to on zna limit podejsc i odstep
miedzy nimi. Front go pokazuje, a nie wylicza po swojemu.
*/}
{!quiz.canStart && quiz.blockedReason && (
<div className="mt-5">
<Alert tone="warn">
{quiz.blockedReason}
{quiz.retryAvailableAt && (
<span className="mt-1 block text-xs">
Kolejne podejście od: {formatLocal(quiz.retryAvailableAt)}
</span>
)}
</Alert>
</div>
)}
<div className="mt-6">
<Button
size="lg"
icon="play"
disabled={!quiz.canStart || starting}
onClick={onStart}
className="w-full sm:w-auto"
>
{starting ? 'Przygotowywanie...' : 'Rozpocznij sprawdzian'}
</Button>
</div>
</Card>
{finished.length > 0 && (
<div className="mt-5">
<Card title="Poprzednie podejścia">
<ul className="divide-y divide-slate-100">
{finished.map((h) => (
<li key={h.attemptId} className="flex items-center justify-between gap-4 py-3">
<span className="hx-data text-sm text-slate-600">
{formatLocal(h.finishedAt as string)}
</span>
<span className="flex items-center gap-2">
<span className="hx-data text-sm font-semibold text-slate-900">
{h.scorePercent}%
</span>
<span
className={[
'rounded px-2 py-0.5 text-2xs font-semibold uppercase tracking-caps',
h.passed ? 'bg-ok-50 text-ok-600' : 'bg-alarm-50 text-alarm-600',
].join(' ')}
>
{h.passed ? 'zdany' : 'niezdany'}
</span>
</span>
</li>
))}
</ul>
</Card>
</div>
)}
</div>
)
}
function Rule({ icon, label, value }: { icon: 'list-checks' | 'check-circle' | 'clock' | 'history'; label: string; value: string }) {
return (
<div className="flex items-center gap-2.5 rounded-md border border-slate-100 bg-slate-25 px-3 py-2.5">
<Icon name={icon} size={17} className="shrink-0 text-blue-500" />
<div className="min-w-0">
<dt className="text-xs text-slate-500">{label}</dt>
<dd className="hx-data text-sm font-semibold text-slate-900">{value}</dd>
</div>
</div>
)
}
/** Podejscie w toku: lista pytan, licznik czasu, zakonczenie. */
function AttemptForm({
attempt,
onAnswer,
onFinish,
finishing,
}: {
attempt: QuizAttempt
onAnswer: (questionId: number, answerId: number, previous: number | null) => void
onFinish: () => void
finishing: boolean
}) {
const answered = attempt.questions.filter((q) => q.selectedAnswerId != null).length
const total = attempt.questions.length
return (
<div className="mx-auto max-w-2xl">
{/*
Pasek stanu zostaje na wierzchu przy przewijaniu: przy dlugiej liscie
kursant musi widziec pozostaly czas bez wracania na gore.
*/}
<div className="sticky top-[57px] z-20 -mx-4 mb-5 border-b border-slate-100 bg-white/95 px-4 py-3 backdrop-blur sm:top-[61px]">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="hx-data text-sm text-slate-600">
Odpowiedzi: <strong className="text-slate-900">{answered}</strong> z {total}
</span>
{attempt.deadline && <Countdown deadline={attempt.deadline} onExpire={onFinish} />}
</div>
</div>
<ol className="space-y-4">
{attempt.questions.map((q, index) => (
<li key={q.id}>
<fieldset className="rounded-lg border border-slate-200 bg-white p-5">
<legend className="sr-only">Pytanie {index + 1}</legend>
<p className="text-sm font-semibold leading-relaxed text-slate-900">
<span className="hx-data mr-2 text-slate-400">{index + 1}.</span>
{q.content}
</p>
<div className="mt-4 space-y-2">
{q.answers.map((a) => {
const checked = q.selectedAnswerId === a.id
return (
<label
key={a.id}
className={[
'flex min-h-[44px] cursor-pointer items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors duration-fast',
checked
? 'border-blue-500 bg-blue-50 text-blue-900'
: 'border-slate-200 text-slate-700 hover:bg-slate-25',
].join(' ')}
>
<input
type="radio"
name={`q-${q.id}`}
checked={checked}
onChange={() => onAnswer(q.id, a.id, q.selectedAnswerId)}
className="h-4 w-4 shrink-0 accent-blue-500"
/>
<span className="min-w-0">{a.content}</span>
</label>
)
})}
</div>
</fieldset>
</li>
))}
</ol>
<div className="mt-6 flex flex-col gap-3 border-t border-slate-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-500">
{answered < total
? `Pominięte pytania liczą się jako błędne (${total - answered} bez odpowiedzi).`
: 'Odpowiedziałeś na wszystkie pytania.'}
</p>
<Button icon="check" onClick={onFinish} disabled={finishing} className="sm:w-auto">
{finishing ? 'Kończenie...' : 'Zakończ sprawdzian'}
</Button>
</div>
</div>
)
}
/**
* Licznik czasu.
*
* ZEGAR PRZEGLADARKI SLUZY WYLACZNIE DO POKAZANIA.
* Rozstrzyga serwer, ktory liczy czas od `started_at`: gdy limit minie,
* podejscie i tak zostanie zamkniete z odpowiedziami zapisanymi do tej pory,
* niezaleznie od tego, co przysle przegladarka. Wywolanie `onExpire` jest
* wiec wygoda, a nie regula.
*/
function Countdown({ deadline, onExpire }: { deadline: string; onExpire: () => void }) {
const target = useMemo(() => new Date(deadline).getTime(), [deadline])
const [left, setLeft] = useState(() => target - Date.now())
useEffect(() => {
const id = window.setInterval(() => {
const next = target - Date.now()
setLeft(next)
if (next <= 0) {
window.clearInterval(id)
onExpire()
}
}, 1000)
return () => window.clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
const seconds = Math.max(0, Math.floor(left / 1000))
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
const ss = String(seconds % 60).padStart(2, '0')
const low = seconds <= 60
return (
<span
role="timer"
aria-live="off"
className={[
'hx-data inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-semibold',
low ? 'bg-alarm-50 text-alarm-600' : 'bg-slate-50 text-slate-700',
].join(' ')}
>
<Icon name="clock" size={15} />
{mm}:{ss}
</span>
)
}
/** Wynik po zakonczeniu: procent, zaliczenie, przeglad odpowiedzi. */
function QuizResult({
result,
quiz,
onRetry,
}: {
result: QuizAttemptResult
quiz: StudentQuizView
onRetry: () => void
}) {
return (
<div className="mx-auto max-w-2xl">
<Card title={result.passed ? 'Sprawdzian zaliczony' : 'Sprawdzian niezaliczony'}>
<div className="flex flex-wrap items-center gap-4">
<span
className={[
'hx-data flex h-20 w-20 shrink-0 items-center justify-center rounded-full text-2xl font-bold',
result.passed ? 'bg-ok-50 text-ok-600' : 'bg-alarm-50 text-alarm-600',
].join(' ')}
>
{result.scorePercent}%
</span>
<div className="min-w-0">
<p className="text-sm text-slate-700">
Poprawne odpowiedzi: <strong>{result.correctCount}</strong> z {result.questionCount}
</p>
<p className="mt-0.5 text-sm text-slate-500">Próg zaliczenia: {result.threshold}%</p>
{result.closedByTimeout && (
<p className="mt-1 text-sm text-warn-600">
Podejście zamknął upływ czasu. Policzone zostały odpowiedzi zapisane do tej pory.
</p>
)}
</div>
</div>
{/* Zaliczenie calego kursu: informacja o certyfikacie i droga do niego. */}
{result.passed && result.courseCompleted && (
<div className="mt-5">
<Alert tone="ok" icon="award">
<strong>Kurs ukończony.</strong> Certyfikat został wystawiony i wysłany na Twój adres
e-mail. Znajdziesz go też na swoim pulpicie.
</Alert>
<Link to={STUDENT_ROUTES.dashboard} className="mt-3 inline-block">
<Button icon="award">Przejdź do certyfikatu</Button>
</Link>
</div>
)}
{!result.passed && (
<div className="mt-5">
<Alert tone="warn">
{quiz.maxAttempts && quiz.attemptsUsed >= quiz.maxAttempts
? 'Wykorzystałeś wszystkie podejścia. Skontaktuj się z organizatorem.'
: 'Możesz podejść ponownie.'}
{quiz.retryAvailableAt && (
<span className="mt-1 block text-xs">
Kolejne podejście od: {formatLocal(quiz.retryAvailableAt)}
</span>
)}
</Alert>
{(!quiz.maxAttempts || quiz.attemptsUsed < quiz.maxAttempts) && (
<Button icon="history" onClick={onRetry} className="mt-3">
Podejdź ponownie
</Button>
)}
</div>
)}
</Card>
{/*
Przeglad odpowiedzi. Pusty, gdy quiz ma wylaczone pokazywanie klucza:
przy quizie powtarzanym pierwsze podejscie sluzyloby inaczej do zebrania
odpowiedzi, a drugie do ich przepisania. Decyzje podejmuje backend,
front tylko pokazuje to, co dostal.
*/}
{result.review.length > 0 && (
<div className="mt-5">
<Card title="Twoje odpowiedzi">
<ol className="space-y-4">
{result.review.map((item, index) => (
<li key={item.questionId} className="border-b border-slate-100 pb-4 last:border-0 last:pb-0">
<p className="flex items-start gap-2 text-sm font-medium text-slate-900">
<Icon
name={item.correct ? 'check-circle' : 'x-circle'}
size={17}
className={item.correct ? 'mt-0.5 shrink-0 text-ok-500' : 'mt-0.5 shrink-0 text-alarm-500'}
/>
<span>
<span className="hx-data mr-1.5 text-slate-400">{index + 1}.</span>
{item.content}
</span>
</p>
<ul className="mt-2.5 space-y-1.5 pl-7">
{item.answers.map((a) => {
const chosen = a.id === item.selectedAnswerId
return (
<li
key={a.id}
className={[
'flex items-center gap-2 rounded px-2 py-1 text-sm',
a.correct
? 'bg-ok-50 font-medium text-ok-600'
: chosen
? 'bg-alarm-50 text-alarm-600'
: 'text-slate-600',
].join(' ')}
>
{a.correct && <Icon name="check" size={14} className="shrink-0" />}
{!a.correct && chosen && <Icon name="x" size={14} className="shrink-0" />}
<span className="min-w-0">{a.content}</span>
{chosen && (
<span className="ml-auto shrink-0 text-2xs uppercase tracking-caps">
Twoja odpowiedź
</span>
)}
</li>
)
})}
</ul>
{item.explanation && (
<p className="mt-2 pl-7 text-sm leading-relaxed text-slate-500">
{item.explanation}
</p>
)}
</li>
))}
</ol>
</Card>
</div>
)}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { Navigate, useLocation } from 'react-router-dom'
import type { ReactNode } from 'react'
import { Spinner } from '../components/ui'
import { STUDENT_ROUTES } from '../lib/routes'
import { useStudentAuth } from './StudentAuthContext'
/**
* Trasa kursanta. Niezalogowany trafia na logowanie Akademii.
*
* Zabezpieczenie interfejsu, nie zastepuje regul po stronie backendu.
* Backend i tak odrzuci zadanie bez tokenu kursanta, a token panelu nie
* otworzy /api/academy/**.
*/
export function RequireStudent({ children }: { children: ReactNode }) {
const { student, loading } = useStudentAuth()
const location = useLocation()
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Sprawdzanie sesji..." />
</div>
)
}
if (!student) {
// sciezka razem z parametrami: odtwarzacz trzyma biezaca lekcje w ?lekcja=
return <Navigate to={STUDENT_ROUTES.login} replace state={{ from: location.pathname + location.search }} />
}
return <>{children}</>
}
+108
View File
@@ -0,0 +1,108 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
academyClient,
getStudentToken,
setStudentToken,
setStudentUnauthorizedHandler,
} from '../api/academyClient'
import type { Student, StudentLoginResponse } from '../api/types'
type StudentAuthState = {
student: Student | null
/** Trwa odtwarzanie sesji z zapisanego tokenu. */
loading: boolean
/**
* Loguje kursanta. Zwraca informacje, czy ten sam adres ma takze konto
* w panelu: front pokazuje wtedy wybor, dokad wejsc.
*/
login: (email: string, password: string) => Promise<{ alsoStaff: boolean }>
/**
* Przyjmuje sesje uzyskana gdzie indziej.
*
* Wspolny formularz logowania wola /auth/login/unified i dostaje stamtad
* gotowy token kursanta, wiec nie ma po co logowac sie drugi raz.
*/
adoptSession: (token: string, student: Student) => void
logout: () => void
/** Odswieza dane po zmianie profilu albo potwierdzeniu adresu. */
refresh: () => Promise<void>
}
const StudentAuthContext = createContext<StudentAuthState | null>(null)
/**
* Sesja kursanta, niezalezna od sesji panelu.
*
* Dwa konteksty zyja obok siebie, kazdy z wlasnym tokenem i wlasnym kluczem
* w localStorage. Ta sama osoba moze byc zalogowana jednoczesnie jako
* administrator i jako kursant, i jedno nie wyrzuca drugiego.
*/
export function StudentAuthProvider({ children }: { children: ReactNode }) {
const [student, setStudent] = useState<Student | null>(null)
const [loading, setLoading] = useState(true)
const queryClient = useQueryClient()
const logout = useCallback(() => {
setStudentToken(null)
setStudent(null)
queryClient.removeQueries({ queryKey: ['academy'] })
}, [queryClient])
useEffect(() => {
setStudentUnauthorizedHandler(() => {
setStudent(null)
queryClient.removeQueries({ queryKey: ['academy'] })
})
return () => setStudentUnauthorizedHandler(null)
}, [queryClient])
// odtworzenie sesji po odswiezeniu strony
useEffect(() => {
if (!getStudentToken()) {
setLoading(false)
return
}
academyClient
.get<Student>('/academy/me')
.then((response) => setStudent(response.data))
.catch(() => setStudentToken(null))
.finally(() => setLoading(false))
}, [])
const login = useCallback(async (email: string, password: string) => {
const response = await academyClient.post<StudentLoginResponse>('/academy/login', {
email,
password,
})
setStudentToken(response.data.token)
setStudent(response.data.student)
return { alsoStaff: response.data.alsoStaff }
}, [])
const adoptSession = useCallback((token: string, next: Student) => {
setStudentToken(token)
setStudent(next)
}, [])
const refresh = useCallback(async () => {
const response = await academyClient.get<Student>('/academy/me')
setStudent(response.data)
}, [])
const value = useMemo<StudentAuthState>(
() => ({ student, loading, login, adoptSession, logout, refresh }),
[student, loading, login, adoptSession, logout, refresh],
)
return <StudentAuthContext.Provider value={value}>{children}</StudentAuthContext.Provider>
}
export function useStudentAuth(): StudentAuthState {
const context = useContext(StudentAuthContext)
if (!context) {
throw new Error('useStudentAuth musi być użyte wewnątrz StudentAuthProvider')
}
return context
}
@@ -0,0 +1,601 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { academyClient, academyErrorMessage } from '../api/academyClient'
import type { MyCourse, StudentDashboard, StudentParticipation } from '../api/types'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Input,
Spinner,
} from '../components/ui'
import { Icon } from '../components/Icon'
import { BrandMark } from '../components/BrandMark'
import { formatWithZone } from '../lib/dates'
import { CourseCover, ProgressBar } from './AcademyBits'
import { useStudentAuth } from './StudentAuthContext'
import { STUDENT_ROUTES } from '../lib/routes'
type Tab = 'kursy' | 'webinary' | 'certyfikaty' | 'dane'
/**
* Pulpit kursanta.
*
* Zakladki przelaczaja widocznosc, nie trasy: dane pobierane sa raz, jednym
* zapytaniem, bo i tak pokazujemy je wszystkie naraz.
*/
export function StudentDashboardPage() {
const { student, logout } = useStudentAuth()
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('kursy')
const dashboard = useQuery({
queryKey: ['academy', 'dashboard'],
queryFn: async () => (await academyClient.get<StudentDashboard>('/academy/dashboard')).data,
})
const handleLogout = () => {
logout()
navigate(STUDENT_ROUTES.login, { replace: true })
}
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 max-w-container flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<BrandMark />
<div className="flex items-center gap-3">
<span className="hidden text-sm text-slate-600 sm:inline">
{student?.firstName} {student?.lastName}
</span>
<Button variant="ghost" icon="log-out" onClick={handleLogout}>
Wyloguj
</Button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-container flex-1 px-4 py-6 sm:px-6">
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Moje konto</h1>
<p className="text-sm text-slate-500">
Twoje kursy, webinary, certyfikaty i dane w jednym miejscu.
</p>
</div>
{student && !student.emailVerified && <VerifyNotice />}
<nav
role="tablist"
aria-label="Sekcje konta"
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
>
{(
[
['kursy', 'Moje kursy', 'graduation-cap'],
['webinary', 'Moje webinary', 'presentation'],
['certyfikaty', 'Certyfikaty', 'file-text'],
['dane', 'Moje dane i zgody', 'users'],
] as const
).map(([value, label, icon]) => (
<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}
</button>
))}
</nav>
{dashboard.isPending && <Spinner />}
{dashboard.isError && (
<ErrorState message={academyErrorMessage(dashboard.error, 'Nie udało się wczytać danych')} />
)}
{dashboard.data && (
<>
{tab === 'kursy' && <CoursesTab items={dashboard.data.courses} />}
{tab === 'webinary' && <WebinarsTab items={dashboard.data.webinars} />}
{tab === 'certyfikaty' && <CertificatesTab items={dashboard.data.certificates} />}
{tab === 'dane' && <ProfileTab data={dashboard.data} />}
</>
)}
</div>
</main>
</div>
)
}
/**
* Kursy kursanta: w trakcie i ukonczone.
*
* Przycisk prowadzi do lekcji, na ktorej kursant skonczyl, a nie na poczatek
* kursu: wracajac po tygodniu nie musi szukac, gdzie przerwal.
*/
function CoursesTab({ items }: { items: MyCourse[] }) {
if (items.length === 0) {
return (
<EmptyState
icon="graduation-cap"
title="Nie masz jeszcze żadnych kursów"
description="Wybierz kurs z oferty i zacznij naukę."
/>
)
}
const inProgress = items.filter((c) => c.status !== 'COMPLETED')
const completed = items.filter((c) => c.status === 'COMPLETED')
return (
<div className="space-y-8">
{inProgress.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-slate-900">W trakcie</h2>
<div className="mt-3 space-y-3">
{inProgress.map((c) => (
<MyCourseRow key={c.courseId} course={c} />
))}
</div>
</section>
)}
{completed.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-slate-900">Ukończone</h2>
<div className="mt-3 space-y-3">
{completed.map((c) => (
<MyCourseRow key={c.courseId} course={c} />
))}
</div>
</section>
)}
</div>
)
}
function MyCourseRow({ course }: { course: MyCourse }) {
const done = course.status === 'COMPLETED'
return (
<article className="flex flex-col gap-4 rounded-lg border border-slate-100 bg-white p-4 shadow-sm sm:flex-row sm:items-center">
<CourseCover
mediaId={course.coverMediaId}
title={course.title}
className="h-24 w-full shrink-0 rounded-md sm:h-16 sm:w-24"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900">{course.title}</h3>
{done && <Badge tone="ok">Ukończony</Badge>}
{/* Dostep wygasly pokazujemy wprost: inaczej kursant klika przycisk
i dostaje blad, nie wiedzac dlaczego. */}
{!course.accessActive && <Badge tone="warn">Dostęp wygasł</Badge>}
</div>
<div className="mt-2.5 max-w-md">
<ProgressBar
percent={course.progressPercent}
label={`Ukończono ${course.completedRequired} z ${course.totalRequired}`}
/>
</div>
{!done && course.resumeLessonTitle && (
<p className="mt-2 truncate text-xs text-slate-500">
Następna lekcja: {course.resumeLessonTitle}
</p>
)}
</div>
<div className="shrink-0">
{course.accessActive ? (
<Link
to={
course.resumeLessonId
? `/kurs/${course.slug}/nauka?lekcja=${course.resumeLessonId}`
: `/kurs/${course.slug}/nauka`
}
>
<Button size="sm" icon={done ? 'book-open' : 'play'}>
{done ? 'Otwórz kurs' : 'Kontynuuj'}
</Button>
</Link>
) : (
<Link to={`/kurs/${course.slug}`}>
<Button size="sm" variant="secondary">
Szczegóły
</Button>
</Link>
)}
</div>
</article>
)
}
/**
* Przypomnienie o niepotwierdzonym adresie.
*
* Logowanie dziala bez potwierdzenia, ale dostep do kursow nie, wiec kursant
* musi wiedziec, czego mu brakuje i jak to naprawic.
*/
function VerifyNotice() {
const [sent, setSent] = useState(false)
const resend = useMutation({
mutationFn: () => academyClient.post('/academy/resend-verification'),
onSuccess: () => setSent(true),
})
return (
<Alert tone="warn">
<p className="font-medium">Adres e-mail nie został jeszcze potwierdzony.</p>
<p className="mt-1">
Bez potwierdzenia nie masz dostępu do materiałów kursów.{' '}
{sent ? (
<strong>Wysłaliśmy nową wiadomość, sprawdź skrzynkę.</strong>
) : (
<button
type="button"
onClick={() => resend.mutate()}
disabled={resend.isPending}
className="font-medium underline underline-offset-2"
>
{resend.isPending ? 'Wysyłanie...' : 'Wyślij link ponownie'}
</button>
)}
</p>
</Alert>
)
}
function WebinarsTab({ items }: { items: StudentParticipation[] }) {
if (items.length === 0) {
return (
<EmptyState
icon="presentation"
title="Nie masz jeszcze żadnych webinarów"
description="Gdy zapiszesz się na webinar tym samym adresem e-mail, pojawi się tutaj."
/>
)
}
const upcoming = items.filter((i) => i.upcoming)
const past = items.filter((i) => !i.upcoming)
return (
<div className="space-y-4">
{upcoming.length > 0 && (
<Card title="Nadchodzące">
<ul className="space-y-3">
{upcoming.map((item) => (
<WebinarRow key={item.webinarId} item={item} />
))}
</ul>
</Card>
)}
{past.length > 0 && (
<Card title="Zakończone">
<ul className="space-y-3">
{past.map((item) => (
<WebinarRow key={item.webinarId} item={item} />
))}
</ul>
</Card>
)}
</div>
)
}
function WebinarRow({ item }: { item: StudentParticipation }) {
const cancelled = item.status === 'CANCELLED'
return (
<li className="rounded-md border border-slate-100 p-3">
<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.title}</p>
<p className="hx-data mt-0.5 text-xs text-slate-500">
{formatWithZone(item.startsAt, item.timezone)}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{cancelled && <Badge tone="neutral">rezygnacja</Badge>}
{!cancelled && item.attended && <Badge tone="ok">obecność</Badge>}
{!cancelled && item.upcoming && <Badge tone="info">zapisany</Badge>}
</div>
</div>
{/*
Link wejscia jest ten sam, ktory kursant dostal w mailu. Konto to
wygoda, a nie nowy warunek dostepu: wejscie tokenem dziala dalej
takze dla osob bez konta.
*/}
{item.accessToken && (
<div className="mt-2 flex flex-wrap gap-3 text-sm">
{item.upcoming && (
<a
href={`/spotkanie/${item.accessToken}`}
className="text-blue-600 hover:text-blue-700"
>
Wejdź na webinar
</a>
)}
<Link to={`/zapis/${item.accessToken}`} className="text-blue-600 hover:text-blue-700">
Szczegóły zapisu
</Link>
{item.hasRecording && (
<span className="inline-flex items-center gap-1 text-slate-500">
<Icon name="video" size={14} /> nagranie dostępne
</span>
)}
{item.materialCount > 0 && (
<span className="inline-flex items-center gap-1 text-slate-500">
<Icon name="file-text" size={14} /> materiały: {item.materialCount}
</span>
)}
</div>
)}
</li>
)
}
function CertificatesTab({ items }: { items: StudentDashboard['certificates'] }) {
if (items.length === 0) {
return (
<EmptyState
icon="award"
title="Nie masz jeszcze certyfikatów"
description="Certyfikat pojawi się tutaj po ukończeniu kursu albo po webinarze, na którym odnotowano Twoją obecność."
/>
)
}
return (
<Card title="Moje certyfikaty">
{/*
Jedna lista dla kursow i webinarow. Kursant nie mysli kategoriami
"to bylo wydarzenie, a to kurs": ma jedno miejsce ze wszystkimi swoimi
dokumentami. Zrodlo rozroznia odznaka.
*/}
<ul className="space-y-3">
{items.map((item) => (
<li
key={item.id}
className="flex flex-wrap items-start 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">{item.subjectTitle}</p>
<Badge tone={item.forCourse ? 'brand' : 'info'}>
{item.forCourse ? 'kurs' : 'webinar'}
</Badge>
</div>
<p className="hx-data mt-0.5 text-xs text-slate-500">
nr {item.number} · {formatWithZone(item.issuedAt, 'Europe/Warsaw')}
</p>
</div>
<div className="flex items-center gap-3">
{item.revoked ? (
<Badge tone="alarm">unieważniony</Badge>
) : (
<>
{/*
Adres pobrania sklada backend, bo tylko on zna token zapisu.
Wczesniej link byl budowany tutaj, bez tokenu, i pobranie
konczylo sie 404 mimo zalogowania.
*/}
{item.downloadUrl && (
<a
href={item.downloadUrl}
className="text-sm text-blue-600 hover:text-blue-700"
>
Pobierz PDF
</a>
)}
<a
href={`/certyfikat/${item.number}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500 hover:text-slate-700"
>
Weryfikacja
</a>
</>
)}
</div>
</li>
))}
</ul>
</Card>
)
}
function ProfileTab({ data }: { data: StudentDashboard }) {
const queryClient = useQueryClient()
const { refresh } = useStudentAuth()
const [form, setForm] = useState({
firstName: data.student.firstName,
lastName: data.student.lastName,
phone: data.student.phone ?? '',
company: data.student.company ?? '',
})
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const marketingEmail = data.consents.find((c) => c.type === 'MARKETING_EMAIL')?.granted ?? false
const marketingSms = data.consents.find((c) => c.type === 'MARKETING_SMS')?.granted ?? false
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['academy', 'dashboard'] })
const saveProfile = useMutation({
mutationFn: () => academyClient.put('/academy/me', form),
onSuccess: async () => {
setSaved(true)
setError(null)
await refresh()
invalidate()
},
onError: (err) => setError(academyErrorMessage(err, 'Nie udało się zapisać zmian')),
})
const saveConsents = useMutation({
mutationFn: (next: { marketingEmail: boolean; marketingSms: boolean }) =>
academyClient.put('/academy/consents', next),
onSuccess: invalidate,
})
const unsubscribe = useMutation({
mutationFn: () => academyClient.post('/academy/unsubscribe'),
onSuccess: invalidate,
})
return (
<div className="space-y-4">
<Card title="Moje dane">
<form
onSubmit={(e) => {
e.preventDefault()
setSaved(false)
saveProfile.mutate()
}}
noValidate
className="space-y-4"
>
{error && <Alert tone="alarm">{error}</Alert>}
{saved && <Alert tone="ok">Zmiany zostały zapisane.</Alert>}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Imię">
<Input
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
required
/>
</Field>
<Field label="Nazwisko">
<Input
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
required
/>
</Field>
</div>
<Field label="Adres e-mail" hint="Adresu nie da się zmienić samodzielnie: jest tożsamością konta.">
<Input value={data.student.email} disabled />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Telefon">
<Input
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</Field>
<Field label="Firma">
<Input
value={form.company}
onChange={(e) => setForm({ ...form, company: e.target.value })}
/>
</Field>
</div>
<Button type="submit" disabled={saveProfile.isPending} icon="save">
{saveProfile.isPending ? 'Zapisywanie...' : 'Zapisz zmiany'}
</Button>
</form>
</Card>
<Card
title="Zgody"
description="Zmiana zapisuje się od razu. Historia zgód jest zachowywana."
>
<div className="space-y-2">
<ConsentToggle
label="Informacje handlowe drogą elektroniczną (e-mail)"
checked={marketingEmail}
disabled={saveConsents.isPending}
onChange={(v) => saveConsents.mutate({ marketingEmail: v, marketingSms })}
/>
<ConsentToggle
label="Informacje handlowe w formie wiadomości SMS"
checked={marketingSms}
disabled={saveConsents.isPending}
onChange={(v) => saveConsents.mutate({ marketingEmail, marketingSms: v })}
/>
</div>
<div className="mt-4 border-t border-slate-100 pt-4">
{data.student.unsubscribed ? (
<Alert tone="info">
Jesteś wypisany z komunikacji marketingowej. Nadal otrzymujesz wiadomości dotyczące
webinarów, na które się zapisałeś: potwierdzenia, przypomnienia i podsumowania.
</Alert>
) : (
<>
<p className="mb-2 text-sm text-slate-600">
Możesz wypisać się ze wszystkich wiadomości marketingowych. Powiadomienia
o webinarach, na które się zapisałeś, będą wysyłane dalej.
</p>
<Button
variant="secondary"
icon="user-off"
disabled={unsubscribe.isPending}
onClick={() => {
if (window.confirm('Czy na pewno chcesz wypisać się z komunikacji marketingowej?')) {
unsubscribe.mutate()
}
}}
>
{unsubscribe.isPending ? 'Zapisywanie...' : 'Wypisz się z marketingu'}
</Button>
</>
)}
</div>
</Card>
</div>
)
}
function ConsentToggle({
label,
checked,
onChange,
disabled,
}: {
label: string
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
}) {
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}
disabled={disabled}
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">{label}</span>
</label>
)
}
+104
View File
@@ -0,0 +1,104 @@
import axios, { AxiosError } from 'axios'
import type { ApiError } from './types'
/**
* Klient Akademii, czyli konta kursanta.
*
* DLACZEGO OSOBNA INSTANCJA I OSOBNY KLUCZ W localStorage
* ---------------------------------------------------------------------------
* Token kursanta i token panelu to dwa rozne swiaty uprawnien. Wspolny klucz
* oznaczalby, ze zalogowanie sie do Akademii wyrzuca administratora z panelu
* w drugiej karcie, a wylogowanie z panelu konczy sesje kursanta. Ta sama
* osoba moze miec oba konta na jeden adres, wiec to nie jest przypadek
* teoretyczny.
*
* Backend i tak nie uzna jednego tokenu za drugi: rozstrzyga o tym pole `typ`
* w tokenie. Rozdzielenie po stronie przegladarki jest wygoda, nie bariera.
*/
const TOKEN_STORAGE_KEY = 'akademia.token'
let tokenInMemory: string | null = readStoredToken()
/** Wolane przy 401, ustawiane przez StudentAuthProvider. */
let onUnauthorized: (() => void) | null = null
function readStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_STORAGE_KEY)
} catch {
// tryb prywatny albo zablokowane cookies
return null
}
}
export function getStudentToken(): string | null {
return tokenInMemory
}
export function setStudentToken(token: string | null) {
tokenInMemory = token
try {
if (token) {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
} else {
localStorage.removeItem(TOKEN_STORAGE_KEY)
}
} catch {
// brak localStorage nie moze psuc dzialania aplikacji
}
}
export function setStudentUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}
export const academyClient = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api',
})
academyClient.interceptors.request.use((config) => {
if (tokenInMemory) {
config.headers.Authorization = `Bearer ${tokenInMemory}`
}
return config
})
academyClient.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response?.status === 401) {
const url = error.config?.url ?? ''
// Przy nieudanym logowaniu i rejestracji nie wylogowujemy: blad
// pokazuje formularz, a sesji jeszcze nie ma.
const isAuthAttempt = url.includes('/academy/login') || url.includes('/academy/register')
if (!isAuthAttempt) {
setStudentToken(null)
onUnauthorized?.()
}
}
return Promise.reject(error)
},
)
/** Komunikat bledu z backendu, pokazywany wiernie. */
export function academyErrorMessage(error: unknown, fallback = 'Wystąpił nieoczekiwany błąd'): string {
if (axios.isAxiosError<ApiError>(error)) {
const data = error.response?.data
if (data?.message) {
const fields = data.fields ? Object.values(data.fields) : []
return fields.length > 0 ? `${data.message}: ${fields.join(', ')}` : data.message
}
if (error.code === 'ERR_NETWORK') {
return 'Brak połączenia z serwerem. Spróbuj ponownie za chwilę.'
}
}
return fallback
}
/** Bledy walidacji per pole, do podswietlenia w formularzu. */
export function academyFieldErrors(error: unknown): Record<string, string> {
if (axios.isAxiosError<ApiError>(error)) {
return error.response?.data?.fields ?? {}
}
return {}
}
+94
View File
@@ -0,0 +1,94 @@
import axios, { AxiosError } from 'axios'
import type { ApiError } from './types'
const TOKEN_STORAGE_KEY = 'webinar.token'
/**
* Token trzymany w pamieci aplikacji, zeby kazde zadanie nie siegalo do localStorage.
* localStorage sluzy wylacznie do przetrwania odswiezenia strony.
*/
let tokenInMemory: string | null = readStoredToken()
/** Wolane przy 401, ustawiane przez AuthProvider. */
let onUnauthorized: (() => void) | null = null
function readStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_STORAGE_KEY)
} catch {
// tryb prywatny albo zablokowane cookies
return null
}
}
export function getToken(): string | null {
return tokenInMemory
}
export function setToken(token: string | null) {
tokenInMemory = token
try {
if (token) {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
} else {
localStorage.removeItem(TOKEN_STORAGE_KEY)
}
} catch {
// brak localStorage nie moze psuc dzialania aplikacji
}
}
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}
export const client = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api',
})
// doklada naglowek Authorization do kazdego zadania
client.interceptors.request.use((config) => {
if (tokenInMemory) {
config.headers.Authorization = `Bearer ${tokenInMemory}`
}
return config
})
// 401 oznacza token wygasly, uniewazniony albo konto dezaktywowane
client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response?.status === 401) {
const isLoginAttempt = error.config?.url?.includes('/auth/login')
// przy nieudanym logowaniu nie wylogowujemy, blad pokazuje formularz
if (!isLoginAttempt) {
setToken(null)
onUnauthorized?.()
}
}
return Promise.reject(error)
},
)
/** Wyciaga czytelny komunikat z odpowiedzi backendu. */
export function errorMessage(error: unknown, fallback = 'Wystąpił nieoczekiwany błąd'): string {
if (axios.isAxiosError<ApiError>(error)) {
const data = error.response?.data
if (data?.message) {
const fields = data.fields ? Object.values(data.fields) : []
return fields.length > 0 ? `${data.message}: ${fields.join(', ')}` : data.message
}
if (error.code === 'ERR_NETWORK') {
return 'Brak połączenia z serwerem'
}
}
return fallback
}
/** Bledy walidacji per pole, do podswietlenia w formularzu. */
export function fieldErrors(error: unknown): Record<string, string> {
if (axios.isAxiosError<ApiError>(error)) {
return error.response?.data?.fields ?? {}
}
return {}
}
+47
View File
@@ -0,0 +1,47 @@
import axios from 'axios'
import type { ApiError } from './types'
/**
* Klient dla czesci publicznej.
*
* Osobna instancja bez zadnego interceptora autoryzacji. Strona zapisu jest
* anonimowa, wiec token panelu nigdy nie moze trafic do tych zadan, a odpowiedz
* 401 nie moze wylogowywac administratora pracujacego w drugiej karcie.
*/
export const publicClient = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api',
})
/**
* Komunikat bledu z backendu, pokazywany wiernie.
*
* Backend zna regule biznesowa lepiej niz formularz, wiec jego tresc ma
* pierwszenstwo. Wlasny tekst pojawia sie tylko wtedy, gdy odpowiedzi nie ma
* w ogole, na przyklad przy braku polaczenia.
*/
export function publicErrorMessage(error: unknown, fallback = 'Wystąpił nieoczekiwany błąd'): string {
if (axios.isAxiosError<ApiError>(error)) {
const data = error.response?.data
if (data?.message) {
const fields = data.fields ? Object.values(data.fields) : []
return fields.length > 0 ? `${data.message}: ${fields.join(', ')}` : data.message
}
if (error.code === 'ERR_NETWORK') {
return 'Brak połączenia z serwerem. Spróbuj ponownie za chwilę.'
}
}
return fallback
}
/** Bledy walidacji per pole, prosto z backendu. */
export function publicFieldErrors(error: unknown): Record<string, string> {
if (axios.isAxiosError<ApiError>(error)) {
return error.response?.data?.fields ?? {}
}
return {}
}
/** Czy backend odpowiedzial 404, czyli webinar albo zapis nie istnieje. */
export function isNotFound(error: unknown): boolean {
return axios.isAxiosError(error) && error.response?.status === 404
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { client, getToken, setToken, setUnauthorizedHandler } from '../api/client'
import type { LoginResponse, User } from '../api/types'
type AuthState = {
user: User | null
/** Trwa odtwarzanie sesji z zapisanego tokenu. */
loading: boolean
login: (email: string, password: string) => Promise<void>
/**
* Przyjmuje sesje uzyskana gdzie indziej.
*
* Wspolny formularz logowania sam wola /auth/login/unified i dostaje stamtad
* gotowy token wraz z danymi konta, wiec nie ma po co logowac sie drugi raz.
*/
adoptSession: (token: string, user: User) => void
logout: () => void
isAdmin: boolean
}
const AuthContext = createContext<AuthState | null>(null)
/**
* Odtworzenie sesji panelu decyduje OBECNOSC TOKENU, nie adres strony.
*
* Wczesniej lista wyliczala trasy, na ktorych wolno pytac /auth/me. Bylo to
* zle z dwoch powodow. Po pierwsze, kazda nowa strona publiczna domyslnie
* odpytywala backend, dopoki ktos nie dopisal jej do listy. Po drugie, po
* przeniesieniu panelu pod wlasny prefiks odwrocenie tej listy sprawilo, ze
* **naglowek strony glownej nie wiedzial o zalogowanym administratorze**
* i pokazywal mu przycisk "Zaloguj sie" zamiast odnosnika do panelu.
*
* Warunkiem jest sam token: gosc go nie ma, wiec nie placi za nic. Zapytanie
* idzie wylacznie dla kogos, kto do panelu juz sie kiedys zalogowal, a wtedy
* jest potrzebne wlasnie po to, zeby naglowek pokazal mu droge do panelu.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const queryClient = useQueryClient()
const logout = useCallback(() => {
setToken(null)
setUser(null)
// cache moze zawierac dane poprzedniego uzytkownika
queryClient.clear()
}, [queryClient])
// interceptor odpowiedzi zglasza tu kazde 401
useEffect(() => {
setUnauthorizedHandler(() => {
setUser(null)
queryClient.clear()
})
return () => setUnauthorizedHandler(null)
}, [queryClient])
/*
* Odtworzenie sesji z tokenu w localStorage po odswiezeniu strony.
* Bez tokenu nie ma czego odtwarzac i nie wysylamy zadnego zapytania.
*/
useEffect(() => {
const token = getToken()
if (!token) {
setLoading(false)
return
}
client
.get<User>('/auth/me')
.then((response) => setUser(response.data))
.catch(() => setToken(null))
.finally(() => setLoading(false))
}, [])
const login = useCallback(async (email: string, password: string) => {
const response = await client.post<LoginResponse>('/auth/login', { email, password })
setToken(response.data.token)
setUser(response.data.user)
}, [])
const adoptSession = useCallback((token: string, nextUser: User) => {
setToken(token)
setUser(nextUser)
}, [])
const value = useMemo<AuthState>(
() => ({ user, loading, login, adoptSession, logout, isAdmin: user?.role === 'ADMIN' }),
[user, loading, login, adoptSession, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthState {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth musi byc uzyte wewnatrz AuthProvider')
}
return context
}
+47
View File
@@ -0,0 +1,47 @@
import { Navigate, useLocation } from 'react-router-dom'
import type { ReactNode } from 'react'
import { useAuth } from './AuthContext'
import { Spinner } from '../components/ui'
import { PANEL_HOME, STUDENT_ROUTES } from '../lib/routes'
/** Trasa panelu. Niezalogowany trafia na logowanie. */
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Sprawdzanie sesji..." />
</div>
)
}
if (!user) {
// zapamietujemy cel, zeby wrocic po zalogowaniu
return <Navigate to={STUDENT_ROUTES.login} replace state={{ from: location.pathname + location.search }} />
}
return <>{children}</>
}
/**
* Trasa wylacznie dla ADMIN.
*
* Zabezpieczenie interfejsu, nie zastepuje regul po stronie backendu.
* Backend i tak odrzuci zadanie moderatora na /api/admin/settings.
*/
export function RequireAdmin({ children }: { children: ReactNode }) {
const { isAdmin } = useAuth()
if (!isAdmin) {
/*
* Pulpit panelu, nie korzen. Korzen nalezy teraz do goscia, wiec moderator
* wchodzacy w /panel/ustawienia wypadalby ze strony na strone marketingowa
* zamiast zostac w panelu, w ktorym ma co robic.
*/
return <Navigate to={PANEL_HOME} replace />
}
return <>{children}</>
}
+53
View File
@@ -0,0 +1,53 @@
import { usePublicConfig } from '../lib/usePublicConfig'
/**
* Znak instancji: logo z ustawien i nazwa marki.
*
* Jedno i drugie pochodzi z ustawien (`APP_LOGO_URL`, `APP_BRAND_NAME`), wiec
* kazdy klient wstawia wlasne bez zmiany kodu.
*
* BRAK LOGO POKAZUJE SAMA NAZWE, BEZ GRAFIKI ZASTEPCZEJ.
* ---------------------------------------------------------------------------
* Wczesniej przy pustym `APP_LOGO_URL` pojawiala sie kropla w kolorze marki.
* Wygladalo to jak logo klienta, choc nim nie bylo: instancja, ktora logo nie
* wgrala, prezentowala sie cudzym znakiem. Nazwa bez grafiki jest uczciwsza
* i czytelniejsza niz ikona, ktora nic nie znaczy.
*/
export function BrandMark({
size = 'md',
inverse = false,
}: {
size?: 'sm' | 'md'
inverse?: boolean
}) {
const { brandName, logoUrl } = usePublicConfig()
const text = size === 'sm' ? 'text-sm' : 'text-base'
return (
<span className="flex min-w-0 items-center gap-2.5">
{/* Bez logo nie pokazujemy niczego w jego miejscu: sama nazwa marki. */}
{logoUrl && (
// Logo bywa poziome, wiec ograniczamy tylko wysokosc, a szerokosc
// dobiera sie do proporcji. object-contain nie przycina i nie rozciaga.
<img
src={logoUrl}
alt=""
className={`${size === 'sm' ? 'h-7' : 'h-9'} w-auto max-w-[160px] shrink-0 object-contain`}
// uszkodzony adres nie moze zostawiac pustej ramki
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
<span
className={`truncate font-semibold tracking-tight ${text} ${
inverse ? 'text-white' : 'text-slate-900'
}`}
title={brandName || undefined}
>
{brandName || 'Webinary'}
</span>
</span>
)
}
+370
View File
@@ -0,0 +1,370 @@
import { useEffect, useMemo, useState } from 'react'
import { EditorContent, useEditor, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import TextAlign from '@tiptap/extension-text-align'
import type { MediaFile } from '../api/types'
import { Icon, type IconName } from './Icon'
import { MediaPicker } from './MediaPicker'
import { Input } from './ui'
/**
* Edytor wizualny tresci strony zapisu.
*
* Oddaje HTML, ktory backend **i tak czysci na bialej liscie** przy zapisie.
* Ograniczenia tutaj sa dla wygody uzytkownika, nie dla bezpieczenstwa:
* gdyby ktos wywolal endpoint z pominieciem edytora, zadne z nich by nie
* zadzialalo. Zrodlem prawdy jest HtmlSanitizer na backendzie.
*
* Wyrownanie akapitu zapisujemy **klasa**, a nie atrybutem `style`, bo
* biala lista nie przepuszcza stylow.
*/
/**
* Zamiana wyrownania ze stylu na klase.
*
* TipTap zapisuje wyrownanie jako `style="text-align:center"`, a biala lista
* backendu nie przepuszcza atrybutu `style` (to najprostsza droga do wstrzykniecia
* czegokolwiek w wyglad strony). Zamieniamy wiec styl na klase z krotkiej listy
* dozwolonej po stronie serwera. Bez tego wyrownanie znikaloby przy kazdym zapisie.
*/
function alignStyleToClass(html: string): string {
if (!html) return html
const parsed = new DOMParser().parseFromString(html, 'text/html')
parsed.querySelectorAll('[style]').forEach((el) => {
const align = (el as HTMLElement).style.textAlign
el.removeAttribute('style')
if (align && ['left', 'center', 'right', 'justify'].includes(align)) {
// domyslne wyrownanie nie potrzebuje klasy
if (align !== 'left') el.setAttribute('class', `text-${align}`)
}
})
return parsed.body.innerHTML
}
export function ContentEditor({
value,
onChange,
webinarId,
}: {
value: string
onChange: (html: string) => void
webinarId?: number | null
}) {
const [pickerOpen, setPickerOpen] = useState(false)
const editor = useEditor({
extensions: [
StarterKit.configure({
// zostawiamy tylko to, co przepuszcza backend
heading: { levels: [2, 3] },
codeBlock: false,
code: false,
}),
Link.configure({
openOnClick: false,
autolink: false,
// biala lista dopuszcza tylko te protokoly
protocols: ['http', 'https', 'mailto'],
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
}),
Image.configure({
// obrazek jest blokiem, wiec nie laduje w srodku akapitu
inline: false,
allowBase64: false,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
// zamiast atrybutu style zapisujemy klase z listy dozwolonej
alignments: ['left', 'center', 'right', 'justify'],
defaultAlignment: 'left',
}),
],
content: value || '',
editorProps: {
attributes: {
class:
'prose prose-slate max-w-none min-h-[240px] px-4 py-3 focus:outline-none '
+ 'prose-headings:font-semibold prose-img:rounded-md',
},
},
onUpdate: ({ editor: current }) => onChange(alignStyleToClass(current.getHTML())),
})
// tresc wczytana z serwera po zamontowaniu edytora
useEffect(() => {
if (!editor) return
if (value && editor.isEmpty) {
editor.commands.setContent(value, false)
}
}, [editor, value])
/**
* Obrazki bez opisu alternatywnego.
*
* Liczymy z tresci, a nie ze stanu edytora, zeby ostrzezenie bylo aktualne
* takze po wklejeniu i po cofnieciu zmiany.
*/
const missingAlt = useMemo(() => countImagesWithoutAlt(value), [value])
if (!editor) return null
function insertImage(file: MediaFile) {
// alt uzupelnia uzytkownik; na start nazwa pliku jest lepsza niz pustka
editor?.chain().focus().setImage({ src: file.url, alt: '' }).run()
setPickerOpen(false)
}
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
<Toolbar editor={editor} onInsertImage={() => setPickerOpen(true)} />
<EditorContent editor={editor} />
</div>
{missingAlt > 0 && (
<p className="flex items-start gap-2 rounded-md border border-warn-500/20 bg-warn-50 p-3 text-sm text-warn-600">
<Icon name="alert-triangle" size={16} className="mt-0.5 shrink-0" />
<span>
{missingAlt === 1
? 'Jeden obrazek nie ma opisu alternatywnego.'
: `${missingAlt} obrazki nie mają opisu alternatywnego.`}{' '}
Kliknij obrazek i uzupełnij opis. Bez niego osoby korzystające z czytnika
ekranu nie dowiedzą się, co przedstawia.
</span>
</p>
)}
<ImageAltEditor editor={editor} />
{pickerOpen && (
<div className="rounded-lg border border-slate-200 bg-slate-25 p-4">
<MediaPicker
webinarId={webinarId}
onPick={insertImage}
onClose={() => setPickerOpen(false)}
/>
</div>
)}
</div>
)
}
/** Pasek narzedzi. Kazdy przycisk odpowiada znacznikowi z bialej listy. */
function Toolbar({ editor, onInsertImage }: { editor: Editor; onInsertImage: () => void }) {
return (
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-slate-25 p-2">
<ToolButton
label="Nagłówek drugiego poziomu"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</ToolButton>
<ToolButton
label="Nagłówek trzeciego poziomu"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
H3
</ToolButton>
<Divider />
<ToolButton
label="Pogrubienie"
active={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<span className="font-bold">B</span>
</ToolButton>
<ToolButton
label="Kursywa"
active={editor.isActive('italic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<span className="italic">I</span>
</ToolButton>
<Divider />
<ToolButton
label="Lista wypunktowana"
active={editor.isActive('bulletList')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
&bull;&nbsp;&mdash;
</ToolButton>
<ToolButton
label="Lista numerowana"
active={editor.isActive('orderedList')}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
1.
</ToolButton>
<ToolButton
label="Cytat"
active={editor.isActive('blockquote')}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
&raquo;
</ToolButton>
<ToolButton
label="Linia pozioma"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
&mdash;
</ToolButton>
<Divider />
{(
[
['left', 'Wyrównaj do lewej'],
['center', 'Wyśrodkuj'],
['right', 'Wyrównaj do prawej'],
['justify', 'Wyjustuj'],
] as const
).map(([align, label]) => (
<ToolButton
key={align}
label={label}
active={editor.isActive({ textAlign: align })}
onClick={() => editor.chain().focus().setTextAlign(align).run()}
>
<AlignGlyph align={align} />
</ToolButton>
))}
<Divider />
<ToolButton
icon="link"
label="Wstaw albo usuń link"
active={editor.isActive('link')}
onClick={() => toggleLink(editor)}
/>
<ToolButton icon="upload" label="Wstaw obrazek" onClick={onInsertImage} />
</div>
)
}
function Divider() {
return <span className="mx-1 h-6 w-px bg-slate-200" aria-hidden="true" />
}
function AlignGlyph({ align }: { align: 'left' | 'center' | 'right' | 'justify' }) {
const widths = {
left: ['w-4', 'w-2.5'],
center: ['w-4', 'w-2.5'],
right: ['w-4', 'w-2.5'],
justify: ['w-4', 'w-4'],
}[align]
const position = {
left: 'items-start',
center: 'items-center',
right: 'items-end',
justify: 'items-start',
}[align]
return (
<span className={`flex flex-col gap-0.5 ${position}`} aria-hidden="true">
<span className={`h-0.5 rounded-full bg-current ${widths[0]}`} />
<span className={`h-0.5 rounded-full bg-current ${widths[1]}`} />
<span className={`h-0.5 rounded-full bg-current ${widths[0]}`} />
</span>
)
}
function ToolButton({
icon,
label,
active = false,
onClick,
children,
}: {
icon?: IconName
label: string
active?: boolean
onClick: () => void
children?: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
aria-pressed={active}
className={`flex h-11 min-w-[44px] items-center justify-center rounded-md px-2 text-sm transition-colors duration-fast ease-standard ${
active ? 'bg-blue-100 text-blue-700' : 'text-slate-600 hover:bg-slate-100'
}`}
>
{children ?? (icon ? <Icon name={icon} size={16} /> : null)}
</button>
)
}
/**
* Opis alternatywny zaznaczonego obrazka.
*
* Pokazuje sie dopiero po kliknieciu obrazka, zeby nie zajmowac miejsca,
* gdy nikt go nie potrzebuje.
*/
function ImageAltEditor({ editor }: { editor: Editor }) {
const selected = editor.isActive('image')
const alt = (editor.getAttributes('image').alt as string | undefined) ?? ''
if (!selected) return null
return (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-blue-900">
Opis alternatywny zaznaczonego obrazka
</span>
<Input
value={alt}
placeholder="np. Wykres zuzycia wody w 2025 roku"
onChange={(e) =>
editor.chain().focus().updateAttributes('image', { alt: e.target.value }).run()
}
/>
</label>
<p className="mt-1 text-2xs text-blue-900">
Czyta go czytnik ekranu i pokazuje przeglądarka, gdy obrazek się nie załaduje.
</p>
</div>
)
}
/** Wstawia albo usuwa link. Pusty adres oznacza usuniecie. */
function toggleLink(editor: Editor) {
const current = (editor.getAttributes('link').href as string | undefined) ?? ''
const next = window.prompt('Adres linku (puste usuwa link)', current)
if (next === null) return
if (!next.trim()) {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
// backend przepuszcza tylko te protokoly, wiec odrzucamy reszte od razu
if (!/^(https?:|mailto:)/i.test(next.trim())) {
window.alert('Adres musi zaczynać się od http://, https:// albo mailto:')
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: next.trim() }).run()
}
/** Liczy obrazki bez niepustego atrybutu alt. */
function countImagesWithoutAlt(html: string): number {
if (!html) return 0
const parsed = new DOMParser().parseFromString(html, 'text/html')
return [...parsed.querySelectorAll('img')].filter((img) => !img.getAttribute('alt')?.trim())
.length
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
/**
* Pasek informacyjny o plikach cookie.
*
* DLACZEGO INFORMACYJNY, A NIE Z KATEGORIAMI
* ---------------------------------------------------------------------------
* Ta aplikacja uzywa wylacznie pamieci przegladarki (localStorage) do
* utrzymania sesji zalogowanego uzytkownika - to storage NIEZBEDNY do
* dzialania, ktory wg prawa (PKE) nie wymaga zgody. Nie ma analityki, pikseli
* ani cookies marketingowych. Dlatego pasek INFORMUJE i daje wybor, ale nie ma
* czego blokowac: obie decyzje jedynie zamykaja pasek.
*
* Zgodnie z PKE (art. 399-400) przyciski "Akceptuje" i "Odrzucam" sa
* ROWNORZEDNE: taki sam rozmiar i widocznosc, odmowa jednym klikiem jak zgoda.
*
* Wybor zapamietujemy w localStorage per przegladarka. Brak localStorage
* (tryb prywatny) nie moze psuc strony: wtedy pasek po prostu pokazuje sie
* ponownie, co jest bezpiecznym zachowaniem.
*/
const STORAGE_KEY = 'cookie.decision'
function readDecision(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function storeDecision(value: string) {
try {
localStorage.setItem(STORAGE_KEY, value)
} catch {
// brak localStorage nie moze psuc dzialania strony
}
}
export function CookieBanner() {
// null = jeszcze nie wiemy (pierwsze renderowanie), '' = brak decyzji
const [decision, setDecision] = useState<string | null>(null)
useEffect(() => {
setDecision(readDecision() ?? '')
}, [])
if (decision === null || decision === 'accepted' || decision === 'rejected') {
return null
}
function decide(value: 'accepted' | 'rejected') {
storeDecision(value)
setDecision(value)
}
return (
<div
role="dialog"
aria-label="Informacja o plikach cookie"
className="fixed inset-x-0 bottom-0 z-50 border-t border-slate-200 bg-white/95 backdrop-blur pb-safe"
>
<div className="mx-auto flex w-full max-w-container flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm leading-relaxed text-slate-600">
Ta strona używa plików cookie i pamięci przeglądarki wyłącznie w celu utrzymania sesji
i poprawnego działania (cookies niezbędne). Nie używamy plików analitycznych ani
marketingowych. Szczegóły znajdziesz w{' '}
<Link to="/polityka-prywatnosci" className="text-blue-600 underline">
polityce prywatności
</Link>
.
</p>
<div className="flex shrink-0 gap-2">
{/* przyciski rownorzedne: ten sam rozmiar i waga, wymog PKE */}
<button
type="button"
onClick={() => decide('rejected')}
className="min-h-[44px] rounded-md border border-slate-300 px-4 text-sm font-semibold text-slate-700 transition duration-fast ease-standard hover:bg-slate-50"
>
Odrzucam
</button>
<button
type="button"
onClick={() => decide('accepted')}
className="min-h-[44px] rounded-md bg-blue-500 px-4 text-sm font-semibold text-white transition duration-fast ease-standard hover:bg-blue-600"
>
Akceptuję
</button>
</div>
</div>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { Icon, type IconName } from './Icon'
/**
* Zakladki wewnatrz jednego formularza.
*
* Zakladki przelaczaja **widocznosc**, a nie trasy: formularz zostaje jeden,
* z jednym przyciskiem zapisu. Dzieki temu przejscie miedzy zakladkami nie
* gubi niezapisanych zmian i nie wymaga zapisywania po kawalku.
*
* Zakladka z bledem walidacji dostaje kropke ostrzegawcza. Bez tego blad
* w polu na nieaktywnej zakladce bylby niewidoczny, a formularz sprawialby
* wrazenie, ze zapis nie dziala bez powodu.
*/
export type FormTab = {
id: string
label: string
icon: IconName
/** Klucze pol z tej zakladki, do oznaczenia bledow. */
fields?: string[]
}
export function FormTabs({
tabs,
active,
onChange,
errors = {},
}: {
tabs: FormTab[]
active: string
onChange: (id: string) => void
errors?: Record<string, string>
}) {
return (
<div
role="tablist"
aria-label="Sekcje formularza"
// przewijanie w poziomie wewnatrz paska, zeby na telefonie
// nie rozpychalo dokumentu
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
>
{tabs.map((tab) => {
const isActive = tab.id === active
const hasError = (tab.fields ?? []).some((field) => errors[field])
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.id)}
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={tab.icon} size={16} />
{tab.label}
{hasError && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-alarm-500"
aria-label="Zakładka zawiera błąd"
/>
)}
</button>
)
})}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import {
AlertTriangle,
ArrowLeft,
Award,
BookOpen,
CalendarPlus,
Check,
CreditCard,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
CircleAlert,
CircleHelp,
Clock,
Copy,
Download,
Droplet,
Eye,
EyeOff,
FileText,
GraduationCap,
Hand,
History,
Image,
Mic,
MicOff,
Key,
Link2,
Loader2,
ListChecks,
Lock,
LogIn,
LogOut,
Mail,
MailCheck,
Menu,
MessageSquare,
Pencil,
Plus,
Play,
Presentation,
Save,
Search,
Send,
Settings,
Trash2,
Upload,
UserPlus,
UserRoundX,
Users,
Volume2,
VolumeX,
Video,
VideoOff,
X,
XCircle,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
/**
* Ikony wedlug systemu HydroNexis: Lucide, 2px stroke, zaokraglone konce.
*
* Zestaw jest jawna mapa, a nie dynamicznym importem po nazwie. Dzieki temu
* bundler dolacza tylko faktycznie uzywane ikony, a literowka w nazwie
* wychodzi na etapie kompilacji, nie u uzytkownika.
*/
const ICONS = {
'alert-triangle': AlertTriangle,
'arrow-left': ArrowLeft,
award: Award,
'book-open': BookOpen,
'calendar-plus': CalendarPlus,
check: Check,
'credit-card': CreditCard,
'check-circle': CheckCircle2,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft,
'chevron-right': ChevronRight,
'chevron-up': ChevronUp,
'circle-alert': CircleAlert,
'help-circle': CircleHelp,
clock: Clock,
copy: Copy,
download: Download,
droplet: Droplet,
eye: Eye,
'eye-off': EyeOff,
'file-text': FileText,
'graduation-cap': GraduationCap,
hand: Hand,
history: History,
image: Image,
mic: Mic,
'mic-off': MicOff,
key: Key,
link: Link2,
loader: Loader2,
'list-checks': ListChecks,
lock: Lock,
'log-in': LogIn,
'log-out': LogOut,
mail: Mail,
'mail-check': MailCheck,
menu: Menu,
'message-square': MessageSquare,
pencil: Pencil,
plus: Plus,
play: Play,
presentation: Presentation,
save: Save,
search: Search,
send: Send,
settings: Settings,
trash: Trash2,
upload: Upload,
'user-plus': UserPlus,
'user-off': UserRoundX,
users: Users,
volume: Volume2,
'volume-off': VolumeX,
video: Video,
'video-off': VideoOff,
x: X,
'x-circle': XCircle,
} satisfies Record<string, LucideIcon>
export type IconName = keyof typeof ICONS
export function Icon({
name,
size = 18,
className = '',
strokeWidth = 2,
}: {
name: IconName
size?: number
className?: string
strokeWidth?: number
}) {
const Component = ICONS[name]
return (
<Component
size={size}
strokeWidth={strokeWidth}
className={`shrink-0 ${className}`}
aria-hidden="true"
/>
)
}
+695
View File
@@ -0,0 +1,695 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, IconButton } from './ui'
import { Icon } from './Icon'
/**
* Edytor obrazu: zaznaczanie wycinka, skalowanie, obrot.
*
* Uklad pracy:
* 1. Po wczytaniu widac **caly** obrazek, niezaleznie od proporcji i rozmiaru.
* Bardzo duzy plik jest tylko pomniejszany, nic nie jest przycinane samo z siebie.
* 2. Na obrazie lezy ramka kadru. Uzytkownik przesuwa ja i zmienia jej rozmiar,
* wiec moze wyciac dowolny fragment tego, co widzi. Szerokosc i wysokosc
* ustawia sie niezaleznie, bo nie kazde logo ma proporcje sugerowana przez
* system. Proporcje mozna zablokowac przelacznikiem, gdy zalezy na dokladnym
* ksztalcie.
* 3. Zapis renderuje wylacznie zawartosc ramki. Wynik zachowuje proporcje
* **zaznaczonego wycinka**, a nie proporcje sugerowana, i miesci sie
* w zadanej szerokosci.
*
* Rysowanie idzie przez requestAnimationFrame na referencjach, a nie przez stan
* Reacta. Przy przeciaganiu daje to plynny obraz i nie gubi klatek.
*/
type Point = { x: number; y: number }
/** Prostokat kadru we wspolrzednych plotna podgladu. */
type Crop = { x: number; y: number; width: number; height: number }
/** Stan widoku obrazu pod ramka kadru. */
type View = {
zoom: number
rotation: number
offset: Point
}
type DragMode =
| { kind: 'image'; from: Point }
| { kind: 'crop'; from: Point; start: Crop }
| { kind: 'resize'; handle: Handle; from: Point; start: Crop }
| null
type Handle = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'w' | 'e'
/** Najmniejszy sensowny kadr w pikselach podgladu. */
const MIN_CROP = 40
/** Wielkosc uchwytu. Duza, zeby dalo sie trafic palcem. */
const HANDLE_SIZE = 22
/**
* Rozmiar wyniku dla zadanego ksztaltu wycinka.
* `maxWidth` jest granica dla dluzszego boku, dzieki czemu wycinek pionowy
* nie urosnie w pionie bardziej niz poziomy w poziomie.
*/
function outputSizeFor(crop: Crop, maxWidth: number): { width: number; height: number } {
if (crop.width <= 0 || crop.height <= 0) return { width: maxWidth, height: maxWidth }
const ratio = crop.width / crop.height
const width = ratio >= 1 ? maxWidth : Math.round(maxWidth * ratio)
const height = ratio >= 1 ? Math.round(maxWidth / ratio) : maxWidth
return { width: Math.max(1, width), height: Math.max(1, height) }
}
/** Podpowiedz kursora dla kazdego uchwytu. */
const CURSORS: Record<Handle, string> = {
nw: 'nwse-resize',
se: 'nwse-resize',
ne: 'nesw-resize',
sw: 'nesw-resize',
n: 'ns-resize',
s: 'ns-resize',
w: 'ew-resize',
e: 'ew-resize',
}
export function ImageEditor({
file,
aspect,
outputWidth,
transparent = false,
onCancel,
onConfirm,
}: {
file: File
/** Sugerowana proporcja kadru: 1 dla ikony, szerszy prostokat dla logo poziomego.
* Jest tylko punktem wyjscia, uzytkownik moze ja zmienic. */
aspect: number
/** Najwieksza szerokosc wyniku w pikselach. Wysokosc wynika z ksztaltu wycinka. */
outputWidth: number
/** Czy tlo wyniku ma zostac przezroczyste. */
transparent?: boolean
onCancel: () => void
onConfirm: (dataUrl: string) => void
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const imageRef = useRef<HTMLImageElement | null>(null)
const viewRef = useRef<View>({ zoom: 1, rotation: 0, offset: { x: 0, y: 0 } })
const cropRef = useRef<Crop>({ x: 0, y: 0, width: 0, height: 0 })
const frameRef = useRef<number | null>(null)
const dragRef = useRef<DragMode>(null)
const pinchDistance = useRef<number | null>(null)
// czytane w trakcie przeciagania, wiec trzymamy tez w referencji
const lockRatioRef = useRef(false)
const [ready, setReady] = useState(false)
const [error, setError] = useState<string | null>(null)
/** Blokada proporcji kadru. Domyslnie wylaczona: ksztalt ma byc dowolny. */
const [lockRatio, setLockRatio] = useState(false)
const [zoomLabel, setZoomLabel] = useState(1)
const [sourceInfo, setSourceInfo] = useState<{ width: number; height: number } | null>(null)
// wymusza przerysowanie etykiet po zmianie kadru
const [, setCropTick] = useState(0)
/**
* Plotno podgladu. Wieksze niz wczesniej, bo na malym obrazku nie da sie
* precyzyjnie zaznaczyc wycinka. Kwadratowe, zeby zmiescic zarowno grafike
* pionowa, jak i pozioma, niezaleznie od proporcji docelowego kadru.
*/
const stageWidth = 560
const stageHeight = 380
/** Rysuje obraz i ramke kadru na plotnie podgladu. */
const paintStage = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
const image = imageRef.current
if (!canvas || !ctx || !image) return
const { zoom, rotation, offset } = viewRef.current
const crop = cropRef.current
ctx.clearRect(0, 0, stageWidth, stageHeight)
// obraz
ctx.save()
ctx.translate(stageWidth / 2 + offset.x, stageHeight / 2 + offset.y)
ctx.rotate((rotation * Math.PI) / 180)
ctx.scale(zoom, zoom)
ctx.drawImage(image, -image.width / 2, -image.height / 2)
ctx.restore()
// przyciemnienie poza kadrem, zeby bylo widac co zostanie wyciete
ctx.save()
ctx.fillStyle = 'rgba(15, 26, 40, 0.55)'
ctx.beginPath()
ctx.rect(0, 0, stageWidth, stageHeight)
ctx.rect(crop.x, crop.y, crop.width, crop.height)
ctx.fill('evenodd')
ctx.restore()
// ramka kadru
ctx.save()
ctx.strokeStyle = '#2C8ABD'
ctx.lineWidth = 2
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height)
// uchwyty krawedzi: krotkie kreski na srodku kazdego boku,
// sygnalizuja, ze da sie ciagnac sam jeden wymiar
ctx.lineCap = 'round'
ctx.lineWidth = 4
const midX = crop.x + crop.width / 2
const midY = crop.y + crop.height / 2
const arm = Math.min(18, crop.width / 4)
const armY = Math.min(18, crop.height / 4)
ctx.beginPath()
ctx.moveTo(midX - arm, crop.y)
ctx.lineTo(midX + arm, crop.y)
ctx.moveTo(midX - arm, crop.y + crop.height)
ctx.lineTo(midX + arm, crop.y + crop.height)
ctx.moveTo(crop.x, midY - armY)
ctx.lineTo(crop.x, midY + armY)
ctx.moveTo(crop.x + crop.width, midY - armY)
ctx.lineTo(crop.x + crop.width, midY + armY)
ctx.stroke()
// uchwyty naroznikow
ctx.fillStyle = '#FFFFFF'
ctx.strokeStyle = '#2C8ABD'
ctx.lineWidth = 2
for (const point of cornerPoints(crop)) {
ctx.beginPath()
ctx.arc(point.x, point.y, HANDLE_SIZE / 2, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
}
ctx.restore()
}, [stageWidth, stageHeight])
const requestPaint = useCallback(() => {
if (frameRef.current !== null) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
paintStage()
})
}, [paintStage])
/** Kadr wysrodkowany, mieszczacy sie w plotnie, o zadanej proporcji. */
const resetCrop = useCallback(() => {
const maxWidth = stageWidth * 0.8
const maxHeight = stageHeight * 0.8
let width = maxWidth
let height = width / aspect
if (height > maxHeight) {
height = maxHeight
width = height * aspect
}
cropRef.current = {
x: (stageWidth - width) / 2,
y: (stageHeight - height) / 2,
width,
height,
}
setCropTick((t) => t + 1)
}, [aspect, stageWidth, stageHeight])
/**
* Ustawia widok tak, zeby **caly** obrazek zmiescil sie w plotnie.
* Uzywamy min, a nie max: przy max czesc grafiki wystawalaby poza plotno
* i nie dalo by sie jej zaznaczyc.
*/
const fitWhole = useCallback(() => {
const image = imageRef.current
if (!image) return
const scale = Math.min(stageWidth / image.width, stageHeight / image.height) * 0.92
viewRef.current = { zoom: scale, rotation: 0, offset: { x: 0, y: 0 } }
setZoomLabel(scale)
requestPaint()
}, [stageWidth, stageHeight, requestPaint])
// wczytanie pliku
useEffect(() => {
const url = URL.createObjectURL(file)
const image = new Image()
image.onload = () => {
imageRef.current = image
setSourceInfo({ width: image.width, height: image.height })
setReady(true)
resetCrop()
fitWhole()
}
image.onerror = () =>
setError('Nie udało się wczytać pliku. Wybierz obraz w formacie PNG, JPG, WEBP albo SVG.')
image.src = url
return () => {
URL.revokeObjectURL(url)
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [file])
useEffect(() => {
if (ready) requestPaint()
}, [ready, requestPaint])
// referencja jest czytana w trakcie przeciagania, gdzie stan Reacta bywa nieaktualny
useEffect(() => {
lockRatioRef.current = lockRatio
}, [lockRatio])
function setZoom(next: number) {
const clamped = Math.min(8, Math.max(0.02, next))
viewRef.current = { ...viewRef.current, zoom: clamped }
setZoomLabel(clamped)
requestPaint()
}
function moveImage(dx: number, dy: number) {
const { offset } = viewRef.current
viewRef.current = { ...viewRef.current, offset: { x: offset.x + dx, y: offset.y + dy } }
requestPaint()
}
/** Wspolrzedne wskaznika przeliczone na uklad plotna. */
function stagePoint(event: React.PointerEvent<HTMLCanvasElement>): Point {
const rect = event.currentTarget.getBoundingClientRect()
return {
x: ((event.clientX - rect.left) / rect.width) * stageWidth,
y: ((event.clientY - rect.top) / rect.height) * stageHeight,
}
}
function handleAt(point: Point): Handle | null {
const crop = cropRef.current
const right = crop.x + crop.width
const bottom = crop.y + crop.height
// narozniki maja pierwszenstwo, bo leza na przecieciu dwoch krawedzi
const corners: Array<[Handle, Point]> = [
['nw', { x: crop.x, y: crop.y }],
['ne', { x: right, y: crop.y }],
['sw', { x: crop.x, y: bottom }],
['se', { x: right, y: bottom }],
]
for (const [handle, corner] of corners) {
if (Math.hypot(point.x - corner.x, point.y - corner.y) <= HANDLE_SIZE) {
return handle
}
}
// uchwyty krawedzi: zmieniaja tylko jeden wymiar
const nearX = point.x >= crop.x - HANDLE_SIZE / 2 && point.x <= right + HANDLE_SIZE / 2
const nearY = point.y >= crop.y - HANDLE_SIZE / 2 && point.y <= bottom + HANDLE_SIZE / 2
if (nearX && Math.abs(point.y - crop.y) <= HANDLE_SIZE / 2) return 'n'
if (nearX && Math.abs(point.y - bottom) <= HANDLE_SIZE / 2) return 's'
if (nearY && Math.abs(point.x - crop.x) <= HANDLE_SIZE / 2) return 'w'
if (nearY && Math.abs(point.x - right) <= HANDLE_SIZE / 2) return 'e'
return null
}
function insideCrop(point: Point): boolean {
const crop = cropRef.current
return (
point.x >= crop.x &&
point.x <= crop.x + crop.width &&
point.y >= crop.y &&
point.y <= crop.y + crop.height
)
}
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
event.currentTarget.setPointerCapture(event.pointerId)
const point = stagePoint(event)
const handle = handleAt(point)
if (handle) {
dragRef.current = { kind: 'resize', handle, from: point, start: { ...cropRef.current } }
} else if (insideCrop(point)) {
dragRef.current = { kind: 'crop', from: point, start: { ...cropRef.current } }
} else {
// poza kadrem przeciagamy sam obraz
dragRef.current = { kind: 'image', from: point }
}
}
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
const drag = dragRef.current
const point = stagePoint(event)
if (!drag) {
// podpowiedz kursora zaleznie od tego, co jest pod wskaznikiem
const overHandle = handleAt(point)
event.currentTarget.style.cursor = overHandle
? CURSORS[overHandle]
: insideCrop(point)
? 'move'
: 'grab'
return
}
if (drag.kind === 'image') {
moveImage(point.x - drag.from.x, point.y - drag.from.y)
dragRef.current = { kind: 'image', from: point }
return
}
if (drag.kind === 'crop') {
const dx = point.x - drag.from.x
const dy = point.y - drag.from.y
cropRef.current = {
...drag.start,
x: clamp(drag.start.x + dx, 0, stageWidth - drag.start.width),
y: clamp(drag.start.y + dy, 0, stageHeight - drag.start.height),
}
setCropTick((t) => t + 1)
requestPaint()
return
}
// Zmiana rozmiaru. Domyslnie kazda krawedz rusza sie niezaleznie, wiec
// proporcje kadru sa dowolne. Blokada przywraca stale proporcje.
const start = drag.start
let left = start.x
let right = start.x + start.width
let top = start.y
let bottom = start.y + start.height
const h = drag.handle
if (h.includes('w')) left = clamp(point.x, 0, right - MIN_CROP)
if (h.includes('e')) right = clamp(point.x, left + MIN_CROP, stageWidth)
if (h.includes('n')) top = clamp(point.y, 0, bottom - MIN_CROP)
if (h.includes('s')) bottom = clamp(point.y, top + MIN_CROP, stageHeight)
let next: Crop = { x: left, y: top, width: right - left, height: bottom - top }
if (lockRatioRef.current) {
// punkt stalej to naroznik przeciwlegly; przy uchwycie krawedzi
// dokladamy brakujacy wymiar, zeby ksztalt sie zgadzal
const anchor = anchorFor(h, start)
const ratio = start.width / start.height
let width = Math.max(MIN_CROP, Math.max(next.width, next.height * ratio))
let height = width / ratio
const maxWidth = point.x >= anchor.x ? stageWidth - anchor.x : anchor.x
const maxHeight = point.y >= anchor.y ? stageHeight - anchor.y : anchor.y
if (width > maxWidth) {
width = maxWidth
height = width / ratio
}
if (height > maxHeight) {
height = maxHeight
width = height * ratio
}
const x = h.includes('w') ? anchor.x - width : anchor.x
const y = h.includes('n') ? anchor.y - height : anchor.y
next = { x, y, width, height }
}
cropRef.current = next
setCropTick((t) => t + 1)
requestPaint()
}
function handlePointerUp(event: React.PointerEvent<HTMLCanvasElement>) {
dragRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
function handleTouchMove(event: React.TouchEvent) {
if (event.touches.length !== 2) return
const distance = touchDistance(event.touches)
if (pinchDistance.current !== null) {
setZoom(viewRef.current.zoom * (distance / pinchDistance.current))
}
pinchDistance.current = distance
}
function handleWheel(event: React.WheelEvent) {
setZoom(viewRef.current.zoom * (event.deltaY < 0 ? 1.08 : 0.92))
}
function rotate() {
viewRef.current = { ...viewRef.current, rotation: (viewRef.current.rotation + 90) % 360 }
requestPaint()
}
/**
* Renderuje wylacznie zawartosc ramki kadru, w docelowej rozdzielczosci.
* Przesuwamy uklad tak, zeby lewy gorny rog kadru wypadl w punkcie zero.
*/
function confirm() {
const image = imageRef.current
if (!image) return
const crop = cropRef.current
const output = document.createElement('canvas')
// Wynik ma ksztalt **zaznaczonego wycinka**, a nie proporcji sugerowanej.
const size = outputSizeFor(crop, outputWidth)
output.width = size.width
output.height = size.height
const ctx = output.getContext('2d')
if (!ctx) {
setError('Przeglądarka nie obsługuje przetwarzania obrazu')
return
}
const scale = output.width / crop.width
const { zoom, rotation, offset } = viewRef.current
if (!transparent) {
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, output.width, output.height)
}
ctx.save()
ctx.scale(scale, scale)
ctx.translate(-crop.x, -crop.y)
ctx.translate(stageWidth / 2 + offset.x, stageHeight / 2 + offset.y)
ctx.rotate((rotation * Math.PI) / 180)
ctx.scale(zoom, zoom)
ctx.drawImage(image, -image.width / 2, -image.height / 2)
ctx.restore()
onConfirm(output.toDataURL('image/png'))
}
if (error) {
return (
<div className="space-y-3">
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600">
{error}
</p>
<Button variant="secondary" type="button" onClick={onCancel}>
Zamknij
</Button>
</div>
)
}
const crop = cropRef.current
const outputSize = outputSizeFor(crop, outputWidth)
return (
<div className="space-y-4">
<div className="flex justify-center">
<div
className="relative w-full touch-none overflow-hidden rounded-lg border border-slate-200"
style={{
maxWidth: stageWidth,
aspectRatio: `${stageWidth} / ${stageHeight}`,
// szachownica pokazuje, ktore obszary kadru zostana przezroczyste
backgroundImage:
'linear-gradient(45deg, #E5EBF2 25%, transparent 25%), linear-gradient(-45deg, #E5EBF2 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #E5EBF2 75%), linear-gradient(-45deg, transparent 75%, #E5EBF2 75%)',
backgroundSize: '16px 16px',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
}}
>
<canvas
ref={canvasRef}
width={stageWidth}
height={stageHeight}
className="block h-full w-full touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onTouchMove={handleTouchMove}
onTouchEnd={() => {
pinchDistance.current = null
}}
onWheel={handleWheel}
/>
</div>
</div>
{sourceInfo && (
<p className="hx-data text-center text-2xs text-slate-400">
Oryginał: {sourceInfo.width} x {sourceInfo.height} px, wycinek:{' '}
{Math.round(crop.width)} x {Math.round(crop.height)} px, wynik: {outputSize.width} x{' '}
{outputSize.height} px
</p>
)}
<p className="text-center text-xs leading-relaxed text-slate-500">
Przeciągnij ramkę, aby wybrać wycinek. Ciągnij za narożniki albo za środek boku, żeby
zmienić szerokość i wysokość niezależnie. Przeciąganie poza ramką przesuwa obraz,
a kółko myszy albo dwa palce zmieniają powiększenie.
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Icon name="search" size={16} className="text-slate-400" />
<input
type="range"
min={0.02}
max={4}
step={0.01}
value={zoomLabel}
onChange={(e) => setZoom(Number(e.target.value))}
className="h-11 flex-1 accent-blue-500"
aria-label="Powiększenie"
/>
<span className="hx-data w-14 text-right text-xs text-slate-500">
{Math.round(zoomLabel * 100)}%
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">
<IconButton
icon="chevron-left"
label="Przesuń obraz w lewo"
variant="secondary"
type="button"
onClick={() => moveImage(-12, 0)}
/>
<IconButton
icon="chevron-right"
label="Przesuń obraz w prawo"
variant="secondary"
type="button"
onClick={() => moveImage(12, 0)}
/>
<IconButton
icon="history"
label="Obróć o 90 stopni"
variant="secondary"
type="button"
onClick={rotate}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => {
fitWhole()
resetCrop()
requestPaint()
}}
>
Cały obraz
</Button>
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => {
// caly obszar plotna; przy blokadzie proporcji najwiekszy
// prostokat o biezacym ksztalcie, ktory sie w nim miesci
const current = cropRef.current
if (lockRatio && current.height > 0) {
const ratio = current.width / current.height
let width = stageWidth
let height = width / ratio
if (height > stageHeight) {
height = stageHeight
width = height * ratio
}
cropRef.current = {
x: (stageWidth - width) / 2,
y: (stageHeight - height) / 2,
width,
height,
}
} else {
cropRef.current = { x: 0, y: 0, width: stageWidth, height: stageHeight }
}
setCropTick((t) => t + 1)
requestPaint()
}}
>
Maksymalny kadr
</Button>
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => {
resetCrop()
requestPaint()
}}
>
Sugerowane proporcje
</Button>
</div>
<label className="flex min-h-[44px] cursor-pointer items-center gap-2 text-sm text-slate-600">
<input
type="checkbox"
checked={lockRatio}
onChange={(e) => setLockRatio(e.target.checked)}
className="h-4 w-4 accent-blue-500"
/>
Zablokuj proporcje
</label>
</div>
<div className="flex justify-end gap-2 border-t border-slate-100 pt-3">
<Button variant="secondary" type="button" onClick={onCancel}>
Anuluj
</Button>
<Button type="button" icon="check" onClick={confirm} disabled={!ready}>
Zapisz wycinek
</Button>
</div>
</div>
</div>
)
}
function cornerPoints(crop: Crop): Point[] {
return [
{ x: crop.x, y: crop.y },
{ x: crop.x + crop.width, y: crop.y },
{ x: crop.x, y: crop.y + crop.height },
{ x: crop.x + crop.width, y: crop.y + crop.height },
]
}
/**
* Punkt stalej przy zmianie rozmiaru: naroznik przeciwlegly do ciagnietego.
* Uchwyt krawedzi nie ma naroznika naprzeciw, wiec za punkt stalej bierzemy
* przeciwlegla krawedz, a druga os zostawiamy bez zmiany.
*/
function anchorFor(handle: Handle, crop: Crop): Point {
const x = handle.includes('w') ? crop.x + crop.width : crop.x
const y = handle.includes('n') ? crop.y + crop.height : crop.y
return { x, y }
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function touchDistance(touches: React.TouchList): number {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.hypot(dx, dy)
}
+308
View File
@@ -0,0 +1,308 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ROLE_LABEL } from '../lib/labels'
import { useInstanceFavicon } from '../lib/usePublicConfig'
import {
NAV_AREAS,
NAV_COMMON,
areaForPath,
isVisibleFor,
} from '../lib/navigation'
import type { NavAreaId, NavItem } from '../lib/navigation'
import { BrandMark } from './BrandMark'
import { Icon } from './Icon'
/** Klucz wyboru obszaru. Wybor ma przetrwac zamkniecie przegladarki. */
const AREA_STORAGE_KEY = 'webinar.panelArea'
function readStoredArea(): NavAreaId {
try {
const stored = localStorage.getItem(AREA_STORAGE_KEY)
if (NAV_AREAS.some((area) => area.id === stored)) {
return stored as NavAreaId
}
} catch {
// tryb prywatny albo zablokowane cookies
}
return NAV_AREAS[0].id
}
/** Zapis wyboru obszaru. Brak localStorage nie moze psuc nawigacji. */
function rememberArea(id: NavAreaId) {
try {
localStorage.setItem(AREA_STORAGE_KEY, id)
} catch {
// tryb prywatny albo zablokowane cookies
}
}
export function Layout() {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false)
const [area, setArea] = useState<NavAreaId>(readStoredArea)
useInstanceFavicon()
// zmiana ekranu zamyka menu, zeby nie zostawalo otwarte nad trescia
useEffect(() => {
setMenuOpen(false)
}, [location.pathname])
/*
* Wejscie na trase drugiego obszaru przelacza obszar, zeby menu zgadzalo sie
* z zawartoscia. Bez tego otwarcie kursu z zakladki albo z linku zostawialoby
* menu webinarow nad ekranem kursu.
*
* Sciezki wspolne (Ustawienia, Kontakty) nie naleza do zadnego obszaru
* i zostawiaja wybor bez zmian: inaczej wejscie w Ustawienia przerzucaloby
* uzytkownika na obszar domyslny.
*
* ZALEZNOSCIA JEST WYLACZNIE SCIEZKA, NIGDY `area`.
* -------------------------------------------------------------------------
* Z `area` na liscie efekt wykonuje sie ponownie zaraz po recznym
* przelaczeniu i cofa je do obszaru wynikajacego z biezacej trasy. Objaw jest
* mylacy: przycisk wyglada na niedzialajacy, chociaz stan zmienia sie
* poprawnie i zaraz wraca. Wylapal to dopiero test klikajacy przelacznik
* stojac na trasie drugiego obszaru.
*/
useEffect(() => {
const detected = areaForPath(location.pathname)
if (detected) {
setArea((current) => {
if (current === detected) {
return current
}
// wybor wymuszony trasa tez ma przetrwac zamkniecie przegladarki:
// inaczej powrot po restarcie pokazywalby inny obszar niz ostatnio
rememberArea(detected)
return detected
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
const selectArea = useCallback(
(next: NavAreaId) => {
setArea(next)
rememberArea(next)
/*
* Przelaczenie obszaru przenosi na jego ekran startowy, o ile biezaca
* trasa nalezy do obszaru opuszczanego. Bez tego menu pokazywaloby
* pozycje Akademii nad otwarta lista webinarow.
*
* Sciezki wspolne zostawiamy w spokoju: ktos przelaczajacy obszar
* w Ustawieniach chce zmienic menu, a nie wyjsc z ustawien.
*/
const currentArea = areaForPath(location.pathname)
if (currentArea && currentArea !== next) {
const target = NAV_AREAS.find((a) => a.id === next)
const first = target?.items.find((item) => !item.planned)
if (first) {
navigate(first.to)
}
}
},
[location.pathname, navigate],
)
const activeArea = useMemo(
() => NAV_AREAS.find((a) => a.id === area) ?? NAV_AREAS[0],
[area],
)
const areaItems = activeArea.items.filter((item) => isVisibleFor(item, user?.role))
const commonItems = NAV_COMMON.filter((item) => isVisibleFor(item, user?.role))
const navigation = (
<>
<AreaSwitch active={activeArea.id} onSelect={selectArea} />
<div className="mt-3 space-y-1">
{areaItems.map((item) => (
<NavEntry key={item.to} item={item} />
))}
</div>
{commonItems.length > 0 && (
<div className="mt-4 space-y-1 border-t border-slate-100 pt-3">
<p className="px-3 pb-1 text-2xs font-semibold uppercase tracking-caps text-slate-400">
Instancja
</p>
{commonItems.map((item) => (
<NavEntry key={item.to} item={item} />
))}
</div>
)}
</>
)
return (
<div className="flex min-h-screen bg-slate-50">
{/* nawigacja stala od szerokosci lg */}
<aside className="hidden w-sidebar shrink-0 flex-col border-r border-slate-100 bg-white lg:flex">
<div className="flex h-topbar items-center border-b border-slate-100 px-5">
<BrandMark />
</div>
<nav className="flex-1 overflow-y-auto p-3">{navigation}</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-topbar items-center gap-3 border-b border-slate-100 bg-white px-4 sm:px-5">
{/* przelacznik menu na mniejszych ekranach */}
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
className="-ml-1 flex h-11 w-11 items-center justify-center rounded-md text-slate-600 transition duration-fast ease-standard hover:bg-slate-50 lg:hidden"
aria-label={menuOpen ? 'Zamknij menu' : 'Otwórz menu'}
aria-expanded={menuOpen}
>
<Icon name={menuOpen ? 'x' : 'menu'} size={20} />
</button>
<div className="lg:hidden">
<BrandMark size="sm" />
</div>
<div className="ml-auto flex items-center gap-2 sm:gap-3">
<div className="hidden text-right sm:block">
<p className="text-sm font-medium leading-tight text-slate-800">{user?.email}</p>
<p className="text-xs leading-tight text-slate-500">
{user ? ROLE_LABEL[user.role] : ''}
</p>
</div>
<button
type="button"
onClick={logout}
className="flex min-h-[44px] items-center gap-2 rounded-md px-3 text-sm font-medium text-slate-600 transition duration-fast ease-standard hover:bg-slate-50"
>
<Icon name="log-out" size={18} />
<span className="hidden sm:inline">Wyloguj</span>
</button>
</div>
</header>
{/* menu wysuwane, tylko na mniejszych ekranach */}
{menuOpen && (
<>
<div
className="fixed inset-0 top-topbar z-20 bg-navy-900/40 lg:hidden"
onClick={() => setMenuOpen(false)}
aria-hidden="true"
/>
{/*
Wysokosc ograniczona do tego, co zostaje pod paskiem gornym
(60px, `topbar` w tailwind.config.js). Menu z dwoma obszarami
i pozycjami wspolnymi jest dluzsze niz poprzednie, wiec bez tego
na niskim ekranie telefonu ostatnie pozycje bylyby nieosiagalne.
*/}
<nav className="fixed inset-x-0 top-topbar z-30 max-h-[calc(100vh-60px)] overflow-y-auto border-b border-slate-100 bg-white p-3 shadow-md lg:hidden">
{navigation}
<div className="mt-3 border-t border-slate-100 px-3 pb-1 pt-3 sm:hidden">
<p className="text-sm font-medium text-slate-800">{user?.email}</p>
<p className="text-xs text-slate-500">{user ? ROLE_LABEL[user.role] : ''}</p>
</div>
</nav>
</>
)}
<main className="flex-1 overflow-x-hidden p-4 pb-safe sm:p-5 lg:p-6">
<div className="mx-auto max-w-container">
<Outlet />
</div>
</main>
</div>
</div>
)
}
/**
* Przelacznik obszaru.
*
* Dwa przyciski obok siebie, a nie lista rozwijana: obszary sa dwa i oba maja
* byc widoczne naraz, zeby bylo wiadomo, ze drugi w ogole istnieje.
*/
function AreaSwitch({
active,
onSelect,
}: {
active: NavAreaId
onSelect: (id: NavAreaId) => void
}) {
return (
<div
role="tablist"
aria-label="Obszar pracy"
className="flex gap-1 rounded-md bg-slate-50 p-1"
>
{NAV_AREAS.map((area) => {
const isActive = area.id === active
return (
<button
key={area.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onSelect(area.id)}
className={`flex min-h-[40px] flex-1 items-center justify-center gap-2 rounded px-2 text-sm transition duration-fast ease-standard ${
isActive
? 'bg-white font-semibold text-blue-700 shadow-xs'
: 'font-medium text-slate-500 hover:text-slate-900'
}`}
>
<Icon name={area.icon} size={16} />
{area.label}
</button>
)
})}
</div>
)
}
const ENTRY_BASE =
'flex min-h-[44px] items-center gap-3 rounded-md px-3 text-sm transition duration-fast ease-standard'
/**
* Pozycja menu.
*
* Pozycja oznaczona `planned` nie jest linkiem: widok jeszcze nie istnieje,
* wiec klikniecie prowadziloby do pustego ekranu. Wyszarzona i nieklikalna
* mowi wprost, ze rzecz jest zaplanowana, a nie zepsuta.
*/
function NavEntry({ item }: { item: NavItem }) {
if (item.planned) {
return (
<span
aria-disabled="true"
title="Ten widok powstanie w kolejnym kroku"
className={`${ENTRY_BASE} cursor-not-allowed font-medium text-slate-300`}
>
<Icon name={item.icon} size={18} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="shrink-0 rounded bg-slate-50 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-caps text-slate-400">
wkrótce
</span>
</span>
)
}
return (
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`${ENTRY_BASE} ${
isActive
? 'bg-blue-50 font-semibold text-blue-700'
: 'font-medium text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`
}
>
<Icon name={item.icon} size={18} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
</NavLink>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from 'react'
import { Icon } from './Icon'
import { MediaPicker } from './MediaPicker'
import { Button } from './ui'
/**
* Pole ustawienia wskazujacego na plik z biblioteki mediow.
*
* DLACZEGO NIE POLE TEKSTOWE
* ------------------------------------------------------------------------
* Wartoscia jest identyfikator pliku. Zaden uzytkownik panelu nie zna
* identyfikatorow i nie ma ich skad wziac: musialby wejsc w tresc webinaru,
* wstawic obrazek, podejrzec adres i przepisac z niego liczbe. To jest
* dokladnie ten rodzaj formatu technicznego, ktorego w panelu nie ma byc.
*
* DLACZEGO WSPOLNY KOMPONENT
* ------------------------------------------------------------------------
* `MediaPicker` daje juz biblioteke, wgrywanie i miniatury. Tutaj dochodzi
* wylacznie to, czego przy wstawianiu w tresc nie ma: pokazanie **biezacego**
* wyboru i mozliwosc jego wyczyszczenia. Kazde kolejne ustawienie typu MEDIA
* dostaje to samo, bez pisania czegokolwiek od nowa.
*
* Logo i ikona instancji (`APP_LOGO_URL`, `APP_FAVICON_URL`) celowo tego nie
* uzywaja: maja wlasny edytor z kadrowaniem, a ich wartoscia jest adres, nie
* identyfikator.
*/
export function MediaField({
value,
onChange,
}: {
/** Identyfikator pliku jako tekst. Pusty ciag oznacza brak wyboru. */
value: string
onChange: (value: string) => void
}) {
const [picking, setPicking] = useState(false)
const id = value.trim()
// adres skladamy z identyfikatora, bo endpoint serwujacy jest publiczny
// i przewidywalny. Nie ma potrzeby dopytywac backendu o sam podglad.
const preview = id ? `/api/public/media/${id}` : null
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
{preview ? (
<img
src={preview}
alt="Wybrany plik"
className="h-16 w-32 rounded-md border border-slate-200 bg-slate-25 object-contain"
/>
) : (
<div className="flex h-16 w-32 items-center justify-center rounded-md border border-dashed border-slate-300 bg-slate-25 text-slate-400">
<Icon name="image" size={20} />
</div>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="secondary"
icon="image"
onClick={() => setPicking((open) => !open)}
>
{id ? 'Zmień plik' : 'Wybierz plik'}
</Button>
{id && (
<Button type="button" size="sm" variant="ghost" onClick={() => onChange('')}>
Wyczyść
</Button>
)}
</div>
</div>
{picking && (
<div className="rounded-lg border border-slate-200 bg-slate-25 p-4">
<MediaPicker
onPick={(file) => {
onChange(String(file.id))
setPicking(false)
}}
onClose={() => setPicking(false)}
/>
</div>
)}
</div>
)
}
+156
View File
@@ -0,0 +1,156 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../api/client'
import type { MediaFile, Page } from '../api/types'
import { Icon } from './Icon'
import { useToasts } from './Toasts'
import { Button, ErrorState, Spinner } from './ui'
/**
* Wybor obrazka: z dysku albo z biblioteki wczesniej wgranych.
*
* Plik zawsze idzie na `/api/admin/media`, a do tresci wstawiany jest adres
* `/api/public/media/{id}`. **Nigdy base64 w tresci**: obrazek zakodowany
* w HTML-u rosnie o jedna trzecia, laduje sie za kazdym razem od nowa i nie da
* sie go buforowac ani podmienic bez edycji tresci.
*/
export function MediaPicker({
webinarId,
onPick,
onClose,
}: {
/** Webinar, przy ktorym plik powstaje. Null przy nowym, jeszcze niezapisanym. */
webinarId?: number | null
onPick: (file: MediaFile) => void
onClose: () => void
}) {
const queryClient = useQueryClient()
const toasts = useToasts()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const library = useQuery({
queryKey: ['media', webinarId ?? null],
queryFn: async () => {
const response = await client.get<Page<MediaFile>>('/admin/media', {
params: { webinarId: webinarId ?? undefined, size: 50 },
})
return response.data
},
})
const upload = useMutation({
mutationFn: async (file: File) => {
const form = new FormData()
form.append('file', file)
if (webinarId) form.append('webinarId', String(webinarId))
const response = await client.post<MediaFile>('/admin/media', form)
return response.data
},
onSuccess: (file) => {
queryClient.invalidateQueries({ queryKey: ['media'] })
onPick(file)
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wgrać pliku')),
onSettled: () => setUploading(false),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/media/${id}`),
onSuccess: () => {
toasts.success('Plik został usunięty')
queryClient.invalidateQueries({ queryKey: ['media'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć pliku')),
})
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium text-slate-900">Wstaw obrazek</p>
<Button variant="ghost" size="sm" type="button" onClick={onClose}>
Zamknij
</Button>
</div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
setUploading(true)
upload.mutate(file)
}
// czyscimy, zeby wybor tego samego pliku ponownie wyzwolil zdarzenie
e.target.value = ''
}}
/>
<Button
type="button"
icon="upload"
variant="secondary"
onClick={() => inputRef.current?.click()}
disabled={uploading}
>
{uploading ? 'Wgrywanie...' : 'Wybierz plik z dysku'}
</Button>
<div>
<p className="mb-2 text-xs font-semibold text-slate-700">Wgrane wczesniej</p>
{library.isPending && <Spinner />}
{library.isError && (
<ErrorState message={errorMessage(library.error, 'Nie udało się wczytać biblioteki')} />
)}
{library.data && library.data.content.length === 0 && (
<p className="rounded-md border border-dashed border-slate-300 p-4 text-center text-sm text-slate-500">
Biblioteka jest pusta. Wgraj pierwszy plik z dysku.
</p>
)}
{library.data && library.data.content.length > 0 && (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{library.data.content.map((file) => (
<li key={file.id} className="group relative">
<button
type="button"
onClick={() => onPick(file)}
className="block w-full overflow-hidden rounded-md border border-slate-200 bg-slate-25 transition-colors duration-fast ease-standard hover:border-blue-400"
title={file.originalName}
>
<img
src={file.url}
alt=""
className="h-24 w-full object-contain"
loading="lazy"
/>
<span className="block truncate px-2 py-1 text-left text-2xs text-slate-500">
{file.originalName}
</span>
</button>
<button
type="button"
aria-label={`Usuń plik ${file.originalName}`}
onClick={() => {
if (window.confirm(`Usunąć plik "${file.originalName}"?`)) {
remove.mutate(file.id)
}
}}
className="absolute right-1 top-1 rounded-md bg-white/90 p-1 text-alarm-600 opacity-0 ring-1 ring-slate-200 transition-opacity duration-fast group-hover:opacity-100 focus:opacity-100"
>
<Icon name="trash" size={14} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { Icon } from './Icon'
/**
* Okno modalne panelu.
*
* JEDEN KOMPONENT NA CALA APLIKACJE
* ------------------------------------------------------------------------
* Wczesniej kazde okno budowalo naklatke od nowa i kazde robilo to inaczej:
* rozne kolory przycienienia, rozne zaokraglenie, rozne odstepy, a na telefonie
* jedne dotykaly brzegow ekranu, inne nie. Okno jest tym samym elementem
* interfejsu co karta na stronie, wiec ma wygladac tak samo, niezaleznie od
* tego, kto je otwiera.
*
* Bez biblioteki: potrzebna jest naklatka, ramka, zamkniecie i przewijanie,
* a zaleznosc dla kilku ekranow nie ma uzasadnienia.
*
* ODSTEP OD BRZEGU EKRANU
* ------------------------------------------------------------------------
* Naklatka ma **odstep na kazdym rozmiarze** (`p-4 sm:p-6`). Okno dotykajace
* krawedzi wyglada na uciete i nie widac, gdzie sie konczy; przy zaokraglonych
* rogach kolidowaloby to takze z wycieciem ekranu telefonu.
*
* PRZEWIJANIE
* ------------------------------------------------------------------------
* Przewija sie tresc **wewnatrz** okna, nie strona pod spodem: naglowek
* z tytulem i przycisk zamkniecia zostaja widoczne, a dlugi formularz importu
* nie zabiera uzytkownikowi kontekstu.
*/
export function Modal({
title,
description,
onClose,
children,
footer,
wide = false,
}: {
title: string
description?: string
onClose: () => void
children: ReactNode
/** Przyciski akcji, przyklejone na dole okna. */
footer?: ReactNode
/** Szersze okno dla tresci z tabela albo podgladem importu. */
wide?: boolean
}) {
const panelRef = useRef<HTMLDivElement>(null)
/*
* Escape zamyka okno, a tlo strony nie przewija sie pod naklatka.
* Jedno i drugie jest oczekiwane od okna modalnego, wiec siedzi tutaj,
* zamiast byc dopisywane w kazdym uzyciu z osobna.
*/
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
}
}, [onClose])
return (
<div
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"
role="dialog"
aria-modal="true"
aria-label={title}
// klikniecie w tlo zamyka, klikniecie w samo okno nie
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<div
ref={panelRef}
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col rounded-lg border border-slate-100 bg-white shadow-xl sm:max-h-[calc(100vh-3rem)] ${
wide ? 'sm:max-w-3xl' : 'sm:max-w-lg'
}`}
>
{/* naglowek i odstepy jak w komponencie Card, zeby okno nie wygladalo obco */}
<header className="flex items-start justify-between gap-3 border-b border-slate-100 px-4 py-4 sm:px-5">
<div className="min-w-0">
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
</div>
<button
type="button"
onClick={onClose}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900"
aria-label="Zamknij"
>
<Icon name="x" size={20} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">{children}</div>
{footer && (
<footer className="flex flex-wrap justify-end gap-2 border-t border-slate-100 px-4 py-4 pb-safe sm:px-5">
{footer}
</footer>
)}
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { Button } from './ui'
/**
* Domyslna liczba wierszy na stronie, wspolna dla **wszystkich** list w panelu.
*
* Trzymamy ja w jednym miejscu, zeby listy nie rozjezdzaly sie miedzy soba
* i zeby zmiana byla jedna poprawka, a nie obchodzeniem kazdego ekranu.
*/
export const PAGE_SIZE = 25
/**
* Pasek paginacji pod lista.
*
* Pokazuje zakres wierszy, a nie tylko numer strony: przy liscie zapisanych
* uzytkownik czesciej pyta "ilu ich jest" niz "ktora to strona".
* Przy jednej stronie i tak warto pokazac licznik, wiec pasek znika dopiero
* gdy nie ma czego liczyc.
*/
export function Pagination({
page,
totalPages,
totalElements,
pageSize = PAGE_SIZE,
onChange,
itemLabel = 'pozycji',
}: {
/** Numer strony liczony od zera, tak jak w Spring Data. */
page: number
totalPages: number
totalElements: number
pageSize?: number
onChange: (page: number) => void
/** Dopelniacz liczby mnogiej, np. "webinarow", "zapisanych", "kont". */
itemLabel?: string
}) {
if (totalElements === 0) return null
const from = page * pageSize + 1
const to = Math.min((page + 1) * pageSize, totalElements)
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-4 py-3 text-sm">
<span className="text-slate-500">
Widoczne <span className="hx-data">{from}</span>-<span className="hx-data">{to}</span> z{' '}
<span className="hx-data">{totalElements}</span> {itemLabel}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
icon="chevron-left"
disabled={page === 0}
onClick={() => onChange(Math.max(0, page - 1))}
>
Poprzednia
</Button>
<span className="hx-data whitespace-nowrap text-xs text-slate-500">
{page + 1} / {totalPages}
</span>
<Button
variant="secondary"
size="sm"
disabled={page + 1 >= totalPages}
onClick={() => onChange(page + 1)}
>
Następna
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,58 @@
import { Icon } from './Icon'
export type SortState = { field: string; direction: 'asc' | 'desc' }
/**
* Naglowek kolumny, po ktorej da sie sortowac.
*
* Klikniecie w te sama kolumne odwraca kierunek, klikniecie w inna ustawia ja
* z kierunkiem malejacym. Malejacy jako pierwszy, bo przy terminach i datach
* ludzie zwykle szukaja najnowszego, a nie najstarszego wpisu.
*
* `aria-sort` jest wymagane, zeby czytnik ekranu powiedzial, po czym tabela
* jest posortowana. Sama strzalka to informacja wylacznie dla patrzacych.
*/
export function SortableHeader({
field,
sort,
onSort,
className = '',
children,
}: {
field: string
sort: SortState
onSort: (next: SortState) => void
className?: string
children: React.ReactNode
}) {
const active = sort.field === field
const direction = active ? sort.direction : null
return (
<th
className={`px-4 py-3 ${className}`}
aria-sort={active ? (direction === 'asc' ? 'ascending' : 'descending') : 'none'}
>
<button
type="button"
onClick={() =>
onSort({
field,
// ta sama kolumna: odwracamy kierunek; inna: zaczynamy od malejacego
direction: active && sort.direction === 'desc' ? 'asc' : 'desc',
})
}
className={`group inline-flex items-center gap-1 rounded-md py-1 transition-colors duration-fast ease-standard ${
active ? 'text-blue-700' : 'text-slate-500 hover:text-slate-900'
}`}
>
{children}
<Icon
name={direction === 'asc' ? 'chevron-up' : 'chevron-down'}
size={14}
className={active ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}
/>
</button>
</th>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Icon } from './Icon'
type ToastKind = 'success' | 'error'
type Toast = {
id: number
kind: ToastKind
message: string
}
type ToastApi = {
success: (message: string) => void
error: (message: string) => void
}
const ToastContext = createContext<ToastApi | null>(null)
const AUTO_HIDE_MS = 6000
let nextId = 1
/** Proste powiadomienia, bez biblioteki zewnetrznej. */
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const remove = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id))
}, [])
const push = useCallback(
(kind: ToastKind, message: string) => {
const id = nextId++
setToasts((current) => [...current, { id, kind, message }])
window.setTimeout(() => remove(id), AUTO_HIDE_MS)
},
[remove],
)
const api = useMemo<ToastApi>(
() => ({
success: (message: string) => push('success', message),
error: (message: string) => push('error', message),
}),
[push],
)
return (
<ToastContext.Provider value={api}>
{children}
{/*
Na telefonie powiadomienia siedza u dolu na calej szerokosci, nad obszarem
bezpiecznym. Na wiekszych ekranach zwezaja sie do prawego dolnego rogu.
*/}
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col gap-2 p-4 pb-safe sm:inset-x-auto sm:right-4 sm:w-full sm:max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={`pointer-events-auto flex items-start gap-2.5 rounded-md border p-3 shadow-lg ${
toast.kind === 'success'
? 'border-ok-500/20 bg-ok-50 text-ok-600'
: 'border-alarm-500/20 bg-alarm-50 text-alarm-600'
}`}
>
<Icon
name={toast.kind === 'success' ? 'check-circle' : 'circle-alert'}
size={18}
className="mt-px"
/>
<p className="min-w-0 flex-1 text-sm leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => remove(toast.id)}
className="-m-1 rounded p-1 opacity-60 transition duration-fast ease-standard hover:opacity-100"
aria-label="Zamknij powiadomienie"
>
<Icon name="x" size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
)
}
export function useToasts(): ToastApi {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToasts musi byc uzyte wewnatrz ToastProvider')
}
return context
}
+320
View File
@@ -0,0 +1,320 @@
import { useState } from 'react'
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react'
import { Icon } from './Icon'
import type { IconName } from './Icon'
/**
* Elementy interfejsu wedlug systemu HydroNexis.
*
* Wylacznie Tailwind, bez bibliotek komponentow. Kolory, promienie i cienie
* pochodza z tokenow w tailwind.config.js.
*
* Wszystkie kontrolki dotykowe maja minimum 44 px wysokosci, zeby dalo sie
* ich uzywac na telefonie. To wymog, nie preferencja: wiekszosc uczestnikow
* wchodzi z linku w mailu otwartego na komorce.
*/
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
// jeden przycisk glowny na widok
primary: 'bg-blue-500 text-white hover:bg-blue-600 active:translate-y-px disabled:bg-blue-200',
secondary:
'bg-white text-slate-700 ring-1 ring-inset ring-slate-200 hover:bg-slate-50 active:translate-y-px disabled:text-slate-300',
ghost: 'text-slate-600 hover:bg-blue-50 hover:text-blue-700 active:translate-y-px disabled:text-slate-300',
danger: 'bg-alarm-500 text-white hover:bg-alarm-600 active:translate-y-px disabled:bg-alarm-50',
}
const BUTTON_SIZES: Record<ButtonSize, string> = {
sm: 'min-h-[36px] px-3 text-sm gap-1.5',
md: 'min-h-[44px] px-4 text-sm gap-2',
lg: 'min-h-[48px] px-5 text-base gap-2',
}
export function Button({
variant = 'primary',
size = 'md',
icon,
children,
className = '',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant
size?: ButtonSize
icon?: IconName
}) {
return (
<button
{...props}
className={`inline-flex items-center justify-center rounded-md font-medium transition duration-fast ease-standard disabled:cursor-not-allowed ${BUTTON_VARIANTS[variant]} ${BUTTON_SIZES[size]} ${className}`}
>
{icon && <Icon name={icon} size={size === 'lg' ? 20 : 18} />}
{children}
</button>
)
}
/** Przycisk z sama ikona. Zachowuje pole dotykowe mimo malej ikony. */
export function IconButton({
icon,
label,
variant = 'ghost',
className = '',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
icon: IconName
label: string
variant?: ButtonVariant
}) {
return (
<button
{...props}
title={label}
aria-label={label}
className={`inline-flex h-11 w-11 items-center justify-center rounded-md transition duration-fast ease-standard disabled:cursor-not-allowed ${BUTTON_VARIANTS[variant]} ${className}`}
>
<Icon name={icon} size={18} />
</button>
)
}
/** Pole formularza z etykieta, nazwa zmiennej srodowiskowej i bledem. */
/**
* Pole formularza: etykieta, kontrolka, podpowiedz i blad.
*
* SZEROKOSC POLA A SZEROKOSC STRONY
* ------------------------------------------------------------------------
* Strona ma jednolita szerokosc `max-w-container` (1200px) dla wszystkich
* ekranow panelu, ale **pole tekstowe rozciagniete na 1200px jest gorsze niz
* waskie**: oko gubi poczatek nastepnego wiersza, a przy krotkiej wartosci
* (godzina, liczba miejsc) puste pole wyglada na zepsute. Dlatego szerokosc
* strony i szerokosc pola to dwie rozne decyzje.
*
* Limit siedzi tutaj, a nie w kazdym formularzu z osobna: nowy ekran dostaje
* go automatycznie, zamiast zaczynac od pola przez caly monitor.
*
* `wide` jest furtka dla pol, ktore z natury potrzebuja miejsca: edytor tresci,
* dluga tresc zgody, tabela w polu. Nie uzywac go do zwyklego pola tekstowego.
*/
export function Field({
label,
envName,
error,
hint,
badge,
wide = false,
children,
}: {
label: string
envName?: string
error?: string
hint?: ReactNode
badge?: ReactNode
/** Pole na pelna szerokosc karty, np. edytor tresci. */
wide?: boolean
children: ReactNode
}) {
return (
<div className={wide ? undefined : 'max-w-2xl'}>
<label className="mb-1.5 flex flex-wrap items-baseline gap-1.5 text-sm font-semibold text-slate-800">
{label}
{envName && <span className="font-mono text-2xs font-normal text-slate-400">({envName})</span>}
{badge}
</label>
{children}
{hint && <p className="mt-1.5 text-xs leading-relaxed text-slate-500">{hint}</p>}
{error && <p className="mt-1.5 text-xs font-medium text-alarm-600">{error}</p>}
</div>
)
}
/** 16px na telefonie, zeby iOS nie powiekszal widoku przy wejsciu w pole. */
const CONTROL_CLASS =
'w-full min-h-[44px] rounded-md border border-slate-200 bg-white px-3 text-base sm:text-sm 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 disabled:bg-slate-50 disabled:text-slate-400'
export function Input({ className = '', ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={`${CONTROL_CLASS} ${className}`} />
}
export function Textarea({ className = '', ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea {...props} className={`${CONTROL_CLASS} py-2.5 ${className}`} />
}
export function Select({
className = '',
children,
...props
}: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select {...props} className={`${CONTROL_CLASS} ${className}`}>
{children}
</select>
)
}
/**
* Pole hasla z podgladem.
*
* Przelacznik pozwala sprawdzic literowke i to, co wstawilo autouzupelnienie
* przegladarki. Przycisk jest poza kolejnoscia tabulacji, zeby nie przeszkadzal
* przy wypelnianiu formularza z klawiatury.
*/
export function PasswordInput({ className = '', ...props }: InputHTMLAttributes<HTMLInputElement>) {
const [visible, setVisible] = useState(false)
return (
<div className="relative">
<input
{...props}
type={visible ? 'text' : 'password'}
className={`${CONTROL_CLASS} pr-12 ${className}`}
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition duration-fast ease-standard hover:text-slate-700"
aria-label={visible ? 'Ukryj hasło' : 'Pokaż hasło'}
title={visible ? 'Ukryj hasło' : 'Pokaż hasło'}
tabIndex={-1}
>
<Icon name={visible ? 'eye-off' : 'eye'} size={18} />
</button>
</div>
)
}
/** Status: pelne zaokraglenie, semantyka operacyjna. */
type BadgeTone = 'ok' | 'warn' | 'alarm' | 'info' | 'brand' | 'neutral'
const BADGE_TONES: Record<BadgeTone, string> = {
ok: 'bg-ok-50 text-ok-600 ring-ok-500/20',
warn: 'bg-warn-50 text-warn-600 ring-warn-500/20',
alarm: 'bg-alarm-50 text-alarm-600 ring-alarm-500/20',
info: 'bg-info-50 text-info-600 ring-info-500/20',
brand: 'bg-blue-50 text-blue-700 ring-blue-500/20',
neutral: 'bg-slate-100 text-slate-600 ring-slate-300/40',
}
export function Badge({
tone = 'neutral',
dot = false,
children,
className = '',
}: {
tone?: BadgeTone
dot?: boolean
children: ReactNode
className?: string
}) {
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${BADGE_TONES[tone]} ${className}`}
>
{dot && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{children}
</span>
)
}
export function Card({
title,
description,
actions,
children,
className = '',
}: {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
}) {
return (
<section className={`rounded-lg border border-slate-100 bg-white shadow-sm ${className}`}>
{(title || actions) && (
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 px-4 py-4 sm:px-5">
<div className="min-w-0">
{title && <h2 className="text-base font-semibold text-slate-900">{title}</h2>}
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
</div>
{actions}
</header>
)}
<div className="p-4 sm:p-5">{children}</div>
</section>
)
}
export function Spinner({ label = 'Wczytywanie...' }: { label?: string }) {
return (
<p className="flex items-center justify-center gap-2 py-10 text-sm text-slate-500">
<Icon name="loader" size={18} className="animate-spin" />
{label}
</p>
)
}
export function EmptyState({
icon,
title,
description,
}: {
icon: IconName
title: string
description?: string
}) {
return (
<div className="flex flex-col items-center gap-2 px-4 py-14 text-center">
<Icon name={icon} size={32} className="text-slate-300" />
<p className="font-medium text-slate-700">{title}</p>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
</div>
)
}
export function ErrorState({ message }: { message: string }) {
return (
<div 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">{message}</p>
</div>
)
}
/** Komunikat kontekstowy. Ton wedlug semantyki operacyjnej. */
export function Alert({
tone = 'info',
icon,
children,
}: {
tone?: 'info' | 'warn' | 'alarm' | 'ok'
icon?: IconName
children: ReactNode
}) {
const tones = {
info: 'border-info-500/20 bg-info-50 text-blue-800',
warn: 'border-warn-500/25 bg-warn-50 text-warn-600',
alarm: 'border-alarm-500/20 bg-alarm-50 text-alarm-600',
ok: 'border-ok-500/20 bg-ok-50 text-ok-600',
}
const defaultIcons = {
info: 'circle-alert',
warn: 'alert-triangle',
alarm: 'circle-alert',
ok: 'check-circle',
} as const
return (
<div className={`flex items-start gap-2.5 rounded-md border p-3 text-sm leading-relaxed ${tones[tone]}`}>
<Icon name={icon ?? defaultIcons[tone]} size={18} className="mt-px" />
<div className="min-w-0 flex-1">{children}</div>
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
Warstwa bazowa wedlug systemu HydroNexis.
Chlodne neutralne, spokojny ruch, widoczny pierscien fokusu.
*/
@layer base {
html {
-webkit-text-size-adjust: 100%;
}
body {
@apply bg-slate-50 font-sans text-base text-slate-700 antialiased;
}
/* Naglowki: sentence case, ciasny tracking, semibold */
h1, h2, h3, h4 {
@apply font-semibold tracking-tight text-slate-900;
}
/*
Widoczny pierscien fokusu jest wymogiem dostepnosci w narzedziu
do infrastruktury krytycznej. Nie usuwac.
*/
:focus-visible {
@apply outline-none ring-[3px] ring-blue-500/45;
}
/* Liczby, terminy i identyfikatory monospace z tabular-nums */
.hx-data {
@apply font-mono tabular-nums;
}
/* Nadlinia, wersaliki z szerokim trackingiem */
.hx-eyebrow {
@apply text-2xs font-semibold uppercase tracking-caps text-slate-500;
}
}
@layer utilities {
/*
Bezpieczne marginesy na telefonach z wycieciem ekranu.
Strona zapisu jest otwierana glownie z linku w mailu na komorce.
*/
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.pt-safe {
padding-top: env(safe-area-inset-top, 0px);
}
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Prezentacja dat w strefie webinaru.
*
* Backend zwraca znaczniki w UTC. Uczestnik i administrator moga siedziec
* w innej strefie niz webinar, wiec strefa jest zawsze podawana jawnie.
*/
const DATE_TIME = new Intl.DateTimeFormat('pl-PL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
/** Data i godzina w podanej strefie, bez nazwy strefy. */
export function formatInZone(iso: string | null | undefined, timezone: string): string {
if (!iso) return '-'
try {
return new Intl.DateTimeFormat('pl-PL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: timezone,
}).format(new Date(iso))
} catch {
// strefa moze byc bledna, wracamy do strefy przegladarki
return DATE_TIME.format(new Date(iso))
}
}
/**
* Osobno data i osobno godzina, w podanej strefie.
*
* Do listy webinarow, gdzie termin lezy w waskiej kolumnie: w jednej linii
* data z godzina i nazwa strefy zabieraly tyle miejsca, ze kolumna akcji
* uciekala poza ekran. Nazwy strefy tu nie ma, bo na liscie nie ma jej po co
* porownywac. Tam, gdzie strefa ma znaczenie (formularz, strona publiczna,
* lista zapisanych), nadal uzywamy `formatWithZone`.
*/
export function formatDateTimeParts(
iso: string | null | undefined,
timezone: string,
): { date: string; time: string } {
if (!iso) return { date: '-', time: '' }
const options: Intl.DateTimeFormatOptions = { timeZone: timezone }
try {
const date = new Intl.DateTimeFormat('pl-PL', {
...options,
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(iso))
const time = new Intl.DateTimeFormat('pl-PL', {
...options,
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso))
return { date, time }
} catch {
// bledna strefa: wracamy do strefy przegladarki, zeby nie pokazac pustki
const fallback = DATE_TIME.format(new Date(iso))
const [date, time] = fallback.split(', ')
return { date, time: time ?? '' }
}
}
/** Data i godzina z jawnym oznaczeniem strefy, np. "15.09.2026, 10:00 (Europe/Warsaw)". */
export function formatWithZone(iso: string | null | undefined, timezone: string): string {
if (!iso) return '-'
return `${formatInZone(iso, timezone)} (${timezone})`
}
/** Data i godzina w strefie przegladarki, do znacznikow technicznych. */
export function formatLocal(iso: string | null | undefined): string {
if (!iso) return '-'
return DATE_TIME.format(new Date(iso))
}
/**
* Zamienia wartosc z pola datetime-local na ISO 8601 z przesunieciem strefy webinaru.
*
* Pole datetime-local nie zna stref, zwraca goly napis "2026-09-15T10:00".
* Administrator wpisuje godzine w strefie webinaru, wiec trzeba doliczyc
* wlasciwe przesuniecie dla tej daty, uwzgledniajac czas letni.
*/
export function zonedInputToIso(value: string, timezone: string): string | null {
if (!value) return null
try {
const offset = offsetForZone(value, timezone)
return `${value}:00${offset}`
} catch {
return new Date(value).toISOString()
}
}
/** Zamienia ISO z backendu na wartosc pola datetime-local w strefie webinaru. */
export function isoToZonedInput(iso: string | null | undefined, timezone: string): string {
if (!iso) return ''
try {
const parts = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
timeZone: timezone,
hour12: false,
}).format(new Date(iso))
// format sv-SE daje "2026-09-15 10:00", pole oczekuje "2026-09-15T10:00"
return parts.replace(' ', 'T')
} catch {
return ''
}
}
/**
* Przesuniecie strefy dla konkretnej daty, w formacie +02:00.
* Liczone z roznicy miedzy ta sama chwila odczytana w UTC i w strefie docelowej.
*/
function offsetForZone(localDateTime: string, timezone: string): string {
const asUtc = new Date(`${localDateTime}:00Z`)
const inZone = new Intl.DateTimeFormat('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: timezone,
hour12: false,
}).format(asUtc)
const zoneAsUtc = new Date(`${inZone.replace(' ', 'T')}Z`)
const diffMinutes = Math.round((zoneAsUtc.getTime() - asUtc.getTime()) / 60000)
const sign = diffMinutes >= 0 ? '+' : '-'
const abs = Math.abs(diffMinutes)
const hours = String(Math.floor(abs / 60)).padStart(2, '0')
const minutes = String(abs % 60).padStart(2, '0')
return `${sign}${hours}:${minutes}`
}
/** Lista stref do wyboru w formularzu. */
export const COMMON_TIMEZONES = [
'Europe/Warsaw',
'Europe/London',
'Europe/Berlin',
'Europe/Kiev',
'UTC',
]
/** Czas trwania w formie czytelnej, np. "2 godziny" albo "1 godzina 30 minut". */
export function formatDuration(startIso: string, endIso: string): string {
const minutes = Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000)
if (minutes <= 0) return '-'
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
const parts: string[] = []
if (hours > 0) parts.push(`${hours} ${plural(hours, 'godzina', 'godziny', 'godzin')}`)
if (rest > 0) parts.push(`${rest} ${plural(rest, 'minuta', 'minuty', 'minut')}`)
return parts.join(' ')
}
/** Odmiana rzeczownika po liczbie, wedlug regul polskich. */
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
if (last >= 2 && last <= 4) return few
return many
}
/** Tylko data, bez godziny, do naglowka strony publicznej. */
export function formatDateOnly(iso: string, timezone: string): string {
try {
return new Intl.DateTimeFormat('pl-PL', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: timezone,
}).format(new Date(iso))
} catch {
return formatInZone(iso, timezone)
}
}
/** Tylko godzina, do naglowka strony publicznej. */
export function formatTimeOnly(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 ''
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { IconName } from '../components/Icon'
import type {
ClosedReason,
ConsentType,
RegistrationStatus,
UserRole,
WebinarStatus,
} from '../api/types'
/** Etykiety po polsku dla wartosci slownikowych z backendu. */
export const WEBINAR_STATUS_LABEL: Record<WebinarStatus, string> = {
DRAFT: 'Wersja robocza',
PUBLISHED: 'Opublikowany',
FINISHED: 'Zakończony',
CANCELLED: 'Odwołany',
}
/** Ton odznaki wedlug semantyki operacyjnej systemu HydroNexis. */
export const WEBINAR_STATUS_TONE: Record<WebinarStatus, 'ok' | 'warn' | 'alarm' | 'info' | 'brand' | 'neutral'> = {
DRAFT: 'neutral',
PUBLISHED: 'ok',
FINISHED: 'info',
CANCELLED: 'alarm',
}
export const REGISTRATION_STATUS_TONE: Record<RegistrationStatus, 'ok' | 'warn' | 'alarm' | 'info' | 'brand' | 'neutral'> = {
PENDING: 'warn',
CONFIRMED: 'ok',
CANCELLED: 'neutral',
}
export const WEBINAR_STATUS_STYLE: Record<WebinarStatus, string> = {
DRAFT: 'bg-slate-100 text-slate-700 ring-slate-200',
PUBLISHED: 'bg-green-50 text-green-700 ring-green-200',
FINISHED: 'bg-blue-50 text-blue-700 ring-blue-200',
CANCELLED: 'bg-red-50 text-red-700 ring-red-200',
}
export const REGISTRATION_STATUS_LABEL: Record<RegistrationStatus, string> = {
PENDING: 'Oczekuje',
CONFIRMED: 'Potwierdzony',
CANCELLED: 'Zrezygnował',
}
export const REGISTRATION_STATUS_STYLE: Record<RegistrationStatus, string> = {
PENDING: 'bg-amber-50 text-amber-700 ring-amber-200',
CONFIRMED: 'bg-green-50 text-green-700 ring-green-200',
CANCELLED: 'bg-slate-100 text-slate-600 ring-slate-200',
}
export const CONSENT_LABEL: Record<ConsentType, string> = {
TERMS: 'Regulamin',
MARKETING_EMAIL: 'Marketing e-mail',
MARKETING_SMS: 'Marketing SMS',
}
export const ROLE_LABEL: Record<UserRole, string> = {
ADMIN: 'Administrator',
MODERATOR: 'Moderator',
}
/** Wyjasnienie, dlaczego zapisy nie przyjmuja zgloszen. Krotka forma do panelu. */
export const CLOSED_REASON_LABEL: Record<ClosedReason, string> = {
CLOSED_BY_ADMIN: 'Zapisy zamknięte ręcznie',
DEADLINE_PASSED: 'Minął termin zapisów',
NO_SEATS: 'Brak wolnych miejsc',
NOT_PUBLISHED: 'Webinar nieopublikowany',
CANCELLED: 'Webinar odwołany',
ALREADY_HELD: 'Webinar już się odbył',
}
/** Komunikat dla uczestnika na stronie publicznej, pelnym zdaniem. */
export const PUBLIC_CLOSED_MESSAGE: Record<ClosedReason, { icon: IconName; title: string; text: string }> = {
CLOSED_BY_ADMIN: {
icon: 'lock',
title: 'Zapisy są zamknięte',
text: 'Organizator zamknął zapisy na to wydarzenie.',
},
DEADLINE_PASSED: {
icon: 'clock',
title: 'Termin zapisów minął',
text: 'Nie można już zapisać się na to wydarzenie.',
},
NO_SEATS: {
icon: 'users',
title: 'Brak wolnych miejsc',
text: 'Wszystkie miejsca zostały zajęte. Zapisy mogą się otworzyć, jeśli ktoś zrezygnuje.',
},
NOT_PUBLISHED: {
icon: 'eye-off',
title: 'Zapisy niedostępne',
text: 'To wydarzenie nie przyjmuje obecnie zgłoszeń.',
},
CANCELLED: {
icon: 'x-circle',
title: 'Webinar został odwołany',
text: 'Organizator odwołał to wydarzenie. Jeśli byłeś zapisany, otrzymasz osobną wiadomość.',
},
ALREADY_HELD: {
icon: 'history',
title: 'Webinar już się odbył',
text: 'To wydarzenie jest już za nami. Zapisy zostały zamknięte.',
},
}
/**
* Podpowiedz sluga z tytulu. Odwzorowuje regule z backendu:
* bez znakow diakrytycznych, male litery, myslniki zamiast pozostalych znakow.
*/
export function slugify(source: string): string {
return source
.replace(/ł/g, 'l')
.replace(/Ł/g, 'L')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 160)
.replace(/-+$/g, '')
}
+150
View File
@@ -0,0 +1,150 @@
import type { IconName } from '../components/Icon'
import type { UserRole } from '../api/types'
import { PANEL_HOME, panelPath } from './routes'
/**
* Deklaracja nawigacji panelu.
*
* JEDNA STRUKTURA, KOMPONENT TYLKO JA RENDERUJE
* ---------------------------------------------------------------------------
* Dodanie pozycji ma byc zmiana jednej linii tutaj, a nie dopisywaniem warunku
* w komponencie menu. Rola, obszar, trasa i ikona sa **danymi**, wiec regula
* "kto to widzi" siedzi w jednym miejscu i da sie ja sprawdzic wzrokiem,
* zamiast szukac po `isAdmin &&` rozsypanych po JSX.
*
* Widocznosc pozycji to wygoda, nie zabezpieczenie: zrodlem prawdy jest
* backend, ktory i tak odrzuci zadanie moderatora na /api/admin/settings.
*/
/** Obszar pracy. Menu pokazuje pozycje wylacznie wybranego obszaru. */
export type NavAreaId = 'webinary' | 'akademia'
export type NavItem = {
/** Trasa. Sluzy takze za klucz listy, wiec musi byc niepowtarzalna. */
to: string
label: string
icon: IconName
/**
* Role, ktore widza pozycje. Puste albo brak oznacza: wszyscy zalogowani.
*
* Lista rol zamiast flagi `adminOnly`, bo rol moze przybyc, a wtedy flaga
* wymagalaby przepisania kazdego uzycia.
*/
roles?: UserRole[]
/**
* Dopasowanie dokladne zamiast po prefiksie.
*
* Potrzebne przy pozycji wskazujacej na "/": bez tego podswietlalaby sie
* na kazdym ekranie panelu, bo kazda sciezka zaczyna sie od ukosnika.
*/
end?: boolean
/**
* Widok jeszcze nie istnieje.
*
* Pozycja jest wtedy wyszarzona i nieklikalna, zamiast prowadzic do pustego
* ekranu albo bledu trasy. Zdjecie tej flagi po dodaniu widoku jest zmiana
* jednego slowa.
*/
planned?: boolean
/** Podpis pod etykieta, gdy sama nazwa nie wystarcza. */
hint?: string
}
export type NavArea = {
id: NavAreaId
label: string
icon: IconName
/** Trasa otwierana po przelaczeniu na ten obszar. */
home: string
items: NavItem[]
}
/**
* Obszary pracy.
*
* Webinary i Akademia to dwa rozne tryby pracy tej samej osoby: kto prowadzi
* szkolenia na zywo, nie zaglada do kursow i odwrotnie. Wspolne menu obu
* obszarow mialoby kilkanascie pozycji, z ktorych polowa jest w danej chwili
* bez znaczenia.
*/
export const NAV_AREAS: NavArea[] = [
{
id: 'webinary',
label: 'Webinary',
icon: 'presentation',
home: PANEL_HOME,
items: [
{ to: PANEL_HOME, label: 'Podsumowanie', icon: 'droplet', end: true },
{ to: panelPath('/webinary'), label: 'Webinary', icon: 'presentation' },
{ to: panelPath('/zapytania'), label: 'Zapytania', icon: 'send' },
],
},
{
id: 'akademia',
label: 'Akademia',
icon: 'video',
home: panelPath('/kursy'),
items: [
{ to: panelPath('/kursy'), label: 'Kursy', icon: 'file-text' },
{ to: panelPath('/zamowienia'), label: 'Zamówienia', icon: 'send' },
{ to: panelPath('/kursy/kursanci'), label: 'Kursanci', icon: 'users' },
{ to: panelPath('/kursy/postepy'), label: 'Postępy', icon: 'check-circle' },
{ to: panelPath('/kursy/certyfikaty'), label: 'Certyfikaty', icon: 'award' },
],
},
]
/**
* Pozycje wspolne dla calej instancji, pokazywane pod obszarami.
*
* Nie naleza do zadnego obszaru, bo dotycza instancji jako calosci: kontakt
* jest jeden, niezaleznie od tego, czy dostaje zaproszenie na webinar, czy
* zapisuje sie na kurs.
*/
export const NAV_COMMON: NavItem[] = [
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
]
/** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */
export function isVisibleFor(item: NavItem, role: UserRole | undefined): boolean {
if (!item.roles || item.roles.length === 0) {
return true
}
return role !== undefined && item.roles.includes(role)
}
/**
* Obszar, do ktorego nalezy sciezka.
*
* Wejscie na trase drugiego obszaru ma przelaczyc menu, zeby zgadzalo sie
* z zawartoscia: uzytkownik otwierajacy zakladke z kursem nie moze zobaczyc
* menu webinarow. Dopasowanie po prefiksie, bo trasy szczegolowe
* (/webinary/12/edycja) naleza do tego samego obszaru co lista.
*
* Zwraca undefined dla sciezek wspolnych i nieznanych: wtedy obszar zostaje
* taki, jaki byl, zamiast przeskakiwac na domyslny przy wejsciu w Ustawienia.
*/
export function areaForPath(pathname: string): NavAreaId | undefined {
for (const area of NAV_AREAS) {
for (const item of area.items) {
if (item.to === PANEL_HOME) {
/*
* Pulpit panelu jest przedrostkiem kazdej trasy panelu, wiec
* dopasowanie po prefiksie zwracaloby go dla wszystkiego. Dokladne
* dopasowanie, tak samo jak flaga `end` przy tej pozycji menu.
*/
if (pathname === PANEL_HOME) {
return area.id
}
continue
}
if (pathname === item.to || pathname.startsWith(item.to + '/')) {
return area.id
}
}
}
return undefined
}
+96
View File
@@ -0,0 +1,96 @@
import type { NotificationChannel } from '../api/types'
/**
* Wspolna logika opisu przypomnien.
*
* Uzywana zarowno w harmonogramie konkretnego webinaru, jak i w domyslnym
* zestawie w ustawieniach. Trzymanie tego w jednym miejscu pilnuje, zeby oba
* ekrany mowily o tym samym tak samo.
*/
/** Zamienia minuty na czytelny opis odstepu. */
export function describeOffset(minutes: number): string {
if (minutes % 1440 === 0) {
const days = minutes / 1440
return `${days} ${days === 1 ? 'dzień' : 'dni'} przed`
}
if (minutes % 60 === 0) {
const hours = minutes / 60
return `${hours} ${hours === 1 ? 'godzina' : hours < 5 ? 'godziny' : 'godzin'} przed`
}
return `${minutes} min przed`
}
/**
* Gotowe odstepy, zeby nie trzeba bylo liczyc minut w pamieci.
*
* Etykiety pochodza z `describeOffset`, wiec lista wyboru i lista pozycji
* nazywaja ten sam odstep tak samo. Wpisane recznie rozjezdzaly sie:
* "24 godziny przed" w liscie wyboru kontra "1 dzien przed" na liscie.
*/
export const REMINDER_PRESET_MINUTES = [10080, 4320, 1440, 180, 60, 15]
export const REMINDER_PRESETS = REMINDER_PRESET_MINUTES.map((minutes) => ({
minutes,
label: describeOffset(minutes),
}))
/** Nazwa kanalu w jezyku uzytkownika. */
export function describeChannel(channel: NotificationChannel): string {
return channel === 'SMS' ? 'SMS' : 'e-mail'
}
/**
* Jedna pozycja domyslnego zestawu przypomnien.
*
* `template` zostaje w modelu, bo backend go wymaga, ale nie pokazujemy go
* w interfejsie: dzisiaj istnieje tylko szablon `reminder`, a nazwa pliku
* nic uzytkownikowi nie mowi.
*/
export type DefaultReminder = {
channel: NotificationChannel
minutes: number
template: string
}
/**
* Rozklada ustawienie DEFAULT_REMINDERS na liste pozycji.
*
* Format zapisu (`kanal:minuty:szablon`, pozycje po przecinku) jest kontraktem
* backendu i zostaje bez zmian. Interfejs tylko przestal go pokazywac.
* Pozycja nieczytelna jest pomijana, tak samo jak robi to backend, zeby
* literowka w bazie nie wysypala calego ekranu.
*/
export function parseDefaultReminders(value: string): DefaultReminder[] {
if (!value || !value.trim()) return []
return value
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part): DefaultReminder | null => {
const pieces = part.split(':')
if (pieces.length !== 3) return null
const channel = pieces[0].trim().toUpperCase()
const minutes = Number(pieces[1].trim())
const template = pieces[2].trim()
if (channel !== 'EMAIL' && channel !== 'SMS') return null
if (!Number.isFinite(minutes) || minutes <= 0) return null
if (!template) return null
return { channel: channel as NotificationChannel, minutes, template }
})
.filter((item): item is DefaultReminder => item !== null)
}
/** Sklada liste pozycji z powrotem do formatu zapisu. */
export function formatDefaultReminders(reminders: DefaultReminder[]): string {
return reminders.map((r) => `${r.channel}:${r.minutes}:${r.template}`).join(',')
}
/** Porzadek od najwczesniejszego przypomnienia do najblizszego startu. */
export function sortReminders(reminders: DefaultReminder[]): DefaultReminder[] {
return [...reminders].sort((a, b) => b.minutes - a.minutes)
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Mapa adresow aplikacji.
*
* KORZEN NALEZY DO GOSCIA, PANEL MA WLASNY PREFIKS.
* ---------------------------------------------------------------------------
* Pod "/" jest strona glowna Akademii, dostepna bez logowania. Caly panel
* administracyjny siedzi pod "/panel". Wczesniej bylo odwrotnie: korzen nalezal
* do pulpitu panelu, wiec niezalogowany gosc wchodzacy na adres instancji
* dostawal ekran logowania administratora zamiast oferty szkolen.
*
* PREFIKS PANELU JEST STALA, NIE NAPISEM ROZSYPANYM PO KODZIE.
* ---------------------------------------------------------------------------
* Adresy panelu sklada `panelPath`, dzieki czemu zmiana prefiksu jest zmiana
* w jednym miejscu, a nie szukaniem po kilkudziesieciu wywolaniach navigate()
* i atrybutach `to`. Przeoczony napis nie wywala builda: prowadzi w pustke
* dopiero u uzytkownika.
*/
/** Prefiks calego panelu administracyjnego. */
export const PANEL_PREFIX = '/panel'
/**
* Adres wewnatrz panelu.
*
* `panelPath()` daje sam pulpit, `panelPath('/webinary')` podstrone.
* Argument zaczyna sie od ukosnika albo jest pusty.
*/
export function panelPath(suffix = ''): string {
return suffix ? `${PANEL_PREFIX}${suffix}` : PANEL_PREFIX
}
/** Pulpit panelu. Cel przekierowania przy braku uprawnien do podstrony. */
export const PANEL_HOME = PANEL_PREFIX
/**
* Sciezki panelu sprzed przeniesienia.
*
* Kazda dostaje przekierowanie na odpowiednik pod /panel, zeby zapisane
* zakladki i linki w mailach nie prowadzily w pustke.
*
* DOPASOWANIE OBEJMUJE PELNY SEGMENT, NIGDY SAM PREFIKS.
* ---------------------------------------------------------------------------
* "/kursy" (panel) i "/kurs/:slug" (publiczna strona kursu) roznia sie jedna
* litera. Przekierowanie dopasowujace prefiks lapaloby publiczna strone kursu
* i wyrzucalo goscia do panelu. Trasy budowane sa wiec jako dokladny segment
* plus osobny wariant z gwiazdka na podsciezki.
*/
export const LEGACY_PANEL_SEGMENTS = [
'pulpit',
'zamowienia',
'kontakty',
'zapytania',
'banery',
'ustawienia',
'uzytkownicy',
] as const
/**
* Segmenty, ktore CELOWO nie dostaja przekierowania: `/kursy` i `/webinary`.
*
* KOLIZJA ROZSTRZYGNIETA NA KORZYSC CZESCI PUBLICZNEJ.
* ---------------------------------------------------------------------------
* Oba adresy naleza teraz do publicznych list kursow i webinarow. Sa krotkie,
* czytelne i trafiaja do wyszukiwarek oraz do materialow reklamowych, wiec to
* one maja je dostac, a nie ekrany, do ktorych wchodzi kilka osob w firmie.
*
* TO JEST SWIADOMA STRATA.
* ---------------------------------------------------------------------------
* Administrator z zapisana zakladka na `/webinary` trafi na oferte zamiast do
* panelu. Nie da sie tego rozstrzygnac trasa: pod jednym adresem nie moga stac
* dwie rozne strony, a rozroznianie po zalogowaniu oznaczaloby, ze ten sam
* link pokazuje co innego roznym osobom, i ze gosc nie moze podejrzec oferty,
* gdy akurat ma otwarta sesje panelu.
*
* Lagodzi to pasek `PanelMovedNotice`, pokazywany na obu listach wylacznie
* osobom z rola panelu: mowi, ze panel jest pod /panel, i prowadzi tam jednym
* klikiem. Pozostale segmenty panelu, ktore z niczym nie koliduja, maja
* przekierowania bez zmian.
*/
export const PUBLIC_LIST_ROUTES = {
courses: '/kursy',
webinars: '/webinary',
} as const
/** Klucz zapamietanego zamkniecia paska o przeniesieniu panelu. */
export const PANEL_MOVED_DISMISSED_KEY = 'webinar.panelMovedNoticeDismissed'
/** Publiczne trasy kont kursantow, przeniesione z /akademia/* na korzen. */
export const STUDENT_ROUTES = {
login: '/logowanie',
register: '/rejestracja',
passwordReset: '/przypomnienie-hasla',
newPassword: '/reset-hasla',
verify: '/potwierdzenie',
dashboard: '/moje-konto',
/** Wybor sciezki, gdy adres ma konto w obu swiatach. */
choice: '/wybor-konta',
} as const
+21
View File
@@ -0,0 +1,21 @@
/**
* Sciezka, pod ktora wolno przekierowac po zalogowaniu.
*
* `location.state` przezywa odswiezenie strony i da sie go ustawic z zewnatrz
* przez history.pushState, wiec wartosc stamtad jest **danymi od uzytkownika**,
* nie zaufanym wejsciem. Zasada z CLAUDE.md: do navigate() i do <Link to>
* trafia wylacznie sciezka wzgledna zaczynajaca sie od pojedynczego ukosnika.
*
* Odrzucamy wszystko ze schematem (`http:`, `javascript:`), z dwoma ukosnikami
* (`//evil.tld` przegladarka czyta jako inny host) i z backslashem, ktory
* czesc przegladarek normalizuje do ukosnika.
*
* Wspolne dla obu swiatow: mieszkalo wczesniej wylacznie w formularzu panelu,
* przez co logowanie kursanta gubilo cel i zawsze ladowalo na pulpicie.
*/
export function safeInternalPath(value: string | undefined | null, fallback = '/'): string {
if (!value || !value.startsWith('/')) return fallback
if (value.startsWith('//') || value.includes('\\')) return fallback
if (/^\/+[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) return fallback
return value
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Oczyszczanie tresci HTML na potrzeby PODGLADU w panelu.
*
* Zrodlo prawdy to `HtmlSanitizer` na backendzie: kazda tresc zapisana do bazy
* jest tam czyszczona biala lista, a widoki publiczne dostaja juz wersje
* bezpieczna. Ten helper obejmuje jedyny wyjatek: podglad banera renderuje
* **stan edytora sprzed zapisu**, ktory nie przeszedl jeszcze przez backend.
* Bez czyszczenia wklejenie `<img src=x onerror=...>` do edytora wykonaloby sie
* od razu w kontekscie panelu (self-XSS).
*
* Nie zastepuje sanityzacji backendu i nie jest bariera bezpieczenstwa dla
* tresci przychodzacej: sluzy wylacznie do bezpiecznego pokazania wlasnego
* wpisu autora przed zapisem. Uzywa natywnego parsera przegladarki, wiec nie
* dokłada zadnej zaleznosci.
*/
/** Znaczniki, ktore w podgladzie nie maja czego pokazac, a moga wykonac kod. */
const FORBIDDEN_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'base', 'form']
/** Dozwolone protokoly w href/src. Wszystko inne (javascript:, data:) wypada. */
const SAFE_URL = /^(https?:|mailto:|\/)/i
export function sanitizeHtmlPreview(raw: string | null | undefined): string {
if (!raw) {
return ''
}
const doc = new DOMParser().parseFromString(raw, 'text/html')
doc.querySelectorAll(FORBIDDEN_TAGS.join(',')).forEach((el) => el.remove())
doc.querySelectorAll('*').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase()
// wszystkie procedury obslugi zdarzen (onerror, onclick, onload, ...)
if (name.startsWith('on')) {
el.removeAttribute(attr.name)
continue
}
// adresy z niebezpiecznym protokolem
if ((name === 'href' || name === 'src') && !SAFE_URL.test(attr.value.trim())) {
el.removeAttribute(attr.name)
}
// srcset omija kontrole src, wiec wycinamy go w calosci w podgladzie
if (name === 'srcset') {
el.removeAttribute(attr.name)
}
}
})
return doc.body.innerHTML
}
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { publicClient } from '../api/publicClient'
import type { BannerPlacement, PublicBanner } from '../api/types'
/**
* Banery w sali webinarowej.
*
* DLACZEGO ODPYTYWANIE, A NIE WEBSOCKET
* ------------------------------------------------------------------------
* Baner moze byc ustawiony "od 40. minuty prezentacji". Zmiana nie pochodzi
* wtedy od nikogo: nikt niczego nie klika, po prostu **mija czas**, wiec nie
* ma zdarzenia do rozeslania. Serwer musialby pilnowac zegara osobno dla
* kazdego webinaru i sam wysylac powiadomienie, co jest znacznie wieksza
* maszyneria niz jedno lekkie zapytanie na kilka minut.
*
* Odstep jest celowo dlugi: banery zmieniaja sie w skali minut, a nie sekund.
*
* ZLICZANIE
* ------------------------------------------------------------------------
* IMPRESSION idzie **raz na baner i sesje**, nie przy kazdej rotacji: baner
* wraca na ekran wielokrotnie, wiec liczenie kazdego pojawienia sie zawyzaloby
* statystyke kilkudziesieciokrotnie. Pilnuje tego takze baza, ale nie ma sensu
* wysylac zadania, o ktorym z gory wiadomo, ze zostanie odrzucone.
*/
/** Co ile odswiezamy liste banerow. Banery zmieniaja sie w skali minut. */
const REFRESH_INTERVAL_MS = 60_000
export function useBanners(token: string | undefined) {
const [banners, setBanners] = useState<PublicBanner[]>([])
/**
* Odstep rotacji przychodzi razem z banerami.
*
* Nie przez `/public/config`: tamten endpoint jest jawny dla calego
* internetu i kazde pole trzeba tam dopisywac swiadomie, a ta wartosc jest
* potrzebna wylacznie na sali.
*/
const [rotationSeconds, setRotationSeconds] = useState(20)
/** Zamkniete przez uczestnika. Nie wracaja do konca sesji. */
const [dismissed, setDismissed] = useState<number[]>([])
/** Ktore banery juz policzylismy jako wyswietlone w tej sesji. */
const counted = useRef<Set<number>>(new Set())
const load = useCallback(async () => {
if (!token) return
try {
const response = await publicClient.get<{
banners: PublicBanner[]
rotationSeconds: number
}>(`/public/participation/${token}/banners`)
setBanners(response.data.banners)
setRotationSeconds(response.data.rotationSeconds)
} catch {
// brak banerow nie moze zepsuc sali: uczestnik ma ogladac webinar,
// a nie ogladac komunikat o bledzie reklamy
}
}, [token])
useEffect(() => {
void load()
const timer = window.setInterval(() => void load(), REFRESH_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [load])
/** Zapis wyswietlenia, raz na baner w tej sesji. */
const countImpression = useCallback(
(bannerId: number) => {
if (!token || counted.current.has(bannerId)) return
counted.current.add(bannerId)
void publicClient
.post(`/public/participation/${token}/banners/${bannerId}/impression`)
.catch(() => {
// statystyka nie jest warta pokazywania bledu uczestnikowi
})
},
[token],
)
/** Zapis klikniecia. Kazde klikniecie sie liczy: to realne zainteresowanie. */
const countClick = useCallback(
(bannerId: number) => {
if (!token) return
void publicClient
.post(`/public/participation/${token}/banners/${bannerId}/click`)
.catch(() => {})
},
[token],
)
const dismiss = useCallback((bannerId: number) => {
setDismissed((current) => (current.includes(bannerId) ? current : [...current, bannerId]))
}, [])
/** Banery jednego miejsca, bez zamknietych. */
const forPlacement = useCallback(
(placement: BannerPlacement) =>
banners.filter((banner) => banner.placement === placement && !dismissed.includes(banner.id)),
[banners, dismissed],
)
return { banners, forPlacement, countImpression, countClick, dismiss, rotationSeconds }
}
/**
* Rotacja w jednym miejscu.
*
* Osobny hook, bo kazde miejsce rotuje niezaleznie: baner boczny i baner pod
* wideo nie musza zmieniac sie w tej samej chwili, a wspolny licznik wiazalby
* je bez powodu.
*
* Przy jednym banerze nie ustawiamy zegara w ogole: timer przelaczajacy
* element na ten sam element jest czysta strata.
*/
export function useRotation(count: number, seconds: number): number {
const [index, setIndex] = useState(0)
useEffect(() => {
if (count <= 1) {
setIndex(0)
return
}
const timer = window.setInterval(
() => setIndex((current) => (current + 1) % count),
Math.max(5, seconds) * 1000,
)
return () => window.clearInterval(timer)
}, [count, seconds])
// lista mogla sie skrocic miedzy tickami, wiec pilnujemy zakresu
return count === 0 ? 0 : index % count
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect } from 'react'
type Meta = {
title: string
description?: string
}
/**
* Ustawia tytul strony i podstawowe meta tagi.
*
* Aplikacja jest SPA, wiec te tagi widzi dopiero klient po wykonaniu skryptu.
* Komunikatory pobierajace zajawke bez uruchamiania JavaScriptu zobacza wartosci
* z index.html. Pelna zajawka per webinar wymagalaby renderowania po stronie
* serwera albo prerenderingu, co jest poza zakresem tego kroku.
*/
export function useDocumentMeta({ title, description }: Meta) {
useEffect(() => {
const previousTitle = document.title
document.title = title
const tags: HTMLMetaElement[] = []
function setMeta(attr: 'name' | 'property', key: string, content: string) {
let tag = document.head.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
if (!tag) {
tag = document.createElement('meta')
tag.setAttribute(attr, key)
document.head.appendChild(tag)
tags.push(tag)
}
tag.setAttribute('content', content)
}
setMeta('property', 'og:title', title)
setMeta('property', 'og:type', 'website')
setMeta('property', 'og:url', window.location.href)
setMeta('name', 'twitter:card', 'summary')
setMeta('name', 'twitter:title', title)
if (description) {
setMeta('name', 'description', description)
setMeta('property', 'og:description', description)
setMeta('name', 'twitter:description', description)
}
return () => {
document.title = previousTitle
// usuwamy tylko tagi dodane przez ten hook, istniejace zostaja
tags.forEach((tag) => tag.remove())
}
}, [title, description])
}
+336
View File
@@ -0,0 +1,336 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Client, type IMessage } from '@stomp/stompjs'
import { publicClient } from '../api/publicClient'
/**
* Polaczenie z czatem i pytaniami.
*
* Token wysylamy **raz, w naglowku CONNECT**, a nie przy kazdej wiadomosci:
* backend sprawdza go przy nawiazywaniu polaczenia i zapamietuje autora
* w sesji. Dzieki temu wiadomosc niesie sama tresc i nikt nie moze podac sie
* za kogo innego, podmieniajac pole w zadaniu.
*/
export type InteractionType = 'CHAT' | 'QUESTION' | 'TASK_SIGNUP' | 'OFFER_REQUEST'
export type InteractionStatus = 'NEW' | 'APPROVED' | 'REJECTED' | 'ANSWERED' | 'HANDLED'
export type ChatMode = 'ENABLED' | 'DISABLED' | 'SPEAKERS_ONLY'
/**
* Co ile wysylac sygnal obecnosci.
*
* Minuta, zgodnie z zalozeniem. Prog zamkniecia sesji
* (ATTENDANCE_TIMEOUT_MINUTES) jest wyrazne wiekszy, wiec chwilowa utrata
* polaczenia nie rozbija obecnosci na kawalki.
*/
const HEARTBEAT_INTERVAL_MS = 60_000
export type InteractionView = {
id: number
type: InteractionType
status: InteractionStatus
content: string
authorName: string
fromSpeaker: boolean
pinned: boolean
answer: string | null
votes: number
votedByMe: boolean
createdAt: string
}
/**
* Widok interakcji dla MODERATORA (panel prowadzenia).
*
* Osobny od publicznego InteractionView: niesie identyfikator autora i jego
* stan moderacji, zeby dalo sie autora wyciszyc albo zablokowac. Publiczny
* widok celowo tych danych nie ma. authorRegistrationId jest null dla
* wiadomosci prelegenta (jego sie nie moderuje).
*/
export type ModeratorInteractionView = {
id: number
type: InteractionType
status: InteractionStatus
content: string
authorName: string
fromSpeaker: boolean
pinned: boolean
answer: string | null
votes: number
authorRegistrationId: number | null
authorChatMuted: boolean
authorRoomBanned: boolean
createdAt: string
}
type History = {
webinarId: number
chatMode: ChatMode
/** Czy kazdy uczestnik moze mowic bez proszenia o glos. */
openMic: boolean
/** Czy kazdy uczestnik moze wlaczyc kamere (wideo dla wszystkich). */
openVideo: boolean
questionsRequireApproval: boolean
speaker: boolean
/** Wlasne id uczestnika, do rozpoznania zdarzen moderacji jego samego. */
myRegistrationId: number | null
/** Czy uczestnik jest wyciszony na czacie juz przy wejsciu. */
chatMuted: boolean
chat: InteractionView[]
questions: InteractionView[]
}
/** Zdarzenie moderacji uczestnika: wyciszenie/blokada. */
export type ModerationEvent = {
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED'
registrationId: number
}
type Event = { kind: string; payload: InteractionView }
/** Zgloszenie do zabrania glosu, tak jak przychodzi z backendu. */
export type SpeakEventPayload = {
id: number
registrationId: number
participantName: string
status: 'PENDING' | 'GRANTED' | 'REVOKED' | 'WITHDRAWN' | 'DECLINED'
createdAt: string
resolvedAt: string | null
}
/** Adres uchwytu WebSocket. nginx proxuje /ws na backend. */
function socketUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}/ws`
}
export function useInteractions(
token: string | undefined,
/**
* Wolane przy zdarzeniu zgloszenia do zabrania glosu.
*
* Subskrypcja siedzi tutaj, a nie w osobnym hooku, bo polaczenie jest jedno:
* drugi klient STOMP oznaczalby drugie uwierzytelnienie i druga sesje
* obecnosci, czyli uczestnika liczonego podwojnie.
*/
onSpeakEvent?: (payload: SpeakEventPayload) => void,
/** Wolane, gdy prowadzacy przelaczy tryb otwartego mikrofonu. */
onOpenMicChange?: (enabled: boolean) => void,
/** Wolane przy moderacji uczestnika (wyciszenie/blokada), tylko gdy dotyczy jego samego. */
onModeration?: (event: ModerationEvent) => void,
/** Wolane, gdy prowadzacy przelaczy tryb wideo dla wszystkich. */
onOpenVideoChange?: (enabled: boolean) => void,
) {
const [history, setHistory] = useState<History | null>(null)
const [chat, setChat] = useState<InteractionView[]>([])
const [questions, setQuestions] = useState<InteractionView[]>([])
const [connected, setConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const clientRef = useRef<Client | null>(null)
/** Uchwyt cyklicznego sygnalu obecnosci, kasowany przy rozlaczeniu. */
const heartbeatRef = useRef<number | null>(null)
/*
* Wywolanie zwrotne trzymamy w referencji, a nie w zaleznosciach efektu:
* inaczej kazde przerysowanie strony tworzy nowa funkcje, efekt startuje
* od nowa i polaczenie WebSocket zrywa sie przy kazdej wiadomosci czatu.
*/
const speakHandler = useRef(onSpeakEvent)
speakHandler.current = onSpeakEvent
const openMicHandler = useRef(onOpenMicChange)
openMicHandler.current = onOpenMicChange
const moderationHandler = useRef(onModeration)
moderationHandler.current = onModeration
const openVideoHandler = useRef(onOpenVideoChange)
openVideoHandler.current = onOpenVideoChange
// wlasne id uczestnika w referencji: subskrypcja czyta je bez zaleznosci
// od stanu, wiec nie zrywa polaczenia przy kazdej zmianie history
const myRegistrationIdRef = useRef<number | null>(null)
// historia: WebSocket dowozi tylko to, co dzieje sie od podlaczenia,
// wiec kto wchodzi w trakcie, musi jeszcze zobaczyc wczesniejsze wpisy
useEffect(() => {
if (!token) return
let cancelled = false
publicClient
.get<History>(`/public/interactions/${token}`)
.then((response) => {
if (cancelled) return
setHistory(response.data)
myRegistrationIdRef.current = response.data.myRegistrationId
setChat(response.data.chat)
setQuestions(response.data.questions)
})
.catch(() => {
if (!cancelled) setError('Nie udało się wczytać historii rozmowy.')
})
return () => {
cancelled = true
}
}, [token])
useEffect(() => {
if (!token || !history) return
const client = new Client({
brokerURL: socketUrl(),
// token idzie w naglowku CONNECT, nie w adresie: adres trafia do logow
// serwera proxy, a token daje dostep do spotkania
connectHeaders: { 'X-Join-Token': token },
reconnectDelay: 4000,
onConnect: () => {
setConnected(true)
setError(null)
/*
* Sygnal obecnosci: od razu po polaczeniu, potem co minute.
*
* Idzie tym samym polaczeniem co czat, wiec nie kosztuje nowego
* zadania ani ponownego uwierzytelnienia. Backend przedluza otwarta
* sesje zamiast zakladac nowa, a brak sygnalu przez
* ATTENDANCE_TIMEOUT_MINUTES zamyka ja automatycznie.
*
* Pierwszy sygnal od razu, bo uczestnik, ktory zajrzal na dwie minuty,
* inaczej nie zostalby w ogole odnotowany.
*/
client.publish({ destination: '/app/heartbeat', body: '{}' })
heartbeatRef.current = window.setInterval(() => {
if (client.connected) {
client.publish({ destination: '/app/heartbeat', body: '{}' })
}
}, HEARTBEAT_INTERVAL_MS)
client.subscribe(`/topic/webinars/${history.webinarId}/chat`, (message: IMessage) => {
const event = JSON.parse(message.body) as Event
if (event.kind === 'CHAT_POSTED') {
setChat((current) => [...current, event.payload])
}
if (event.kind === 'CHAT_REMOVED') {
setChat((current) => current.filter((item) => item.id !== event.payload.id))
}
})
client.subscribe(`/topic/webinars/${history.webinarId}/questions`, (message: IMessage) => {
const event = JSON.parse(message.body) as Event
setQuestions((current) => applyQuestionEvent(current, event))
})
client.subscribe(`/topic/webinars/${history.webinarId}/speak`, (message: IMessage) => {
const event = JSON.parse(message.body) as {
kind: string
payload: SpeakEventPayload
openMic?: boolean
openVideo?: boolean
}
// tryb "wideo dla wszystkich": uczestnik zapamietuje stan i pobiera
// token na nowo, zeby dostac (albo stracic) prawo do kamery
if (event.kind === 'OPEN_VIDEO') {
setHistory((current) =>
current ? { ...current, openVideo: event.openVideo ?? false } : current)
openVideoHandler.current?.(event.openVideo ?? false)
return
}
// ten sam temat niesie kilka rzeczy: zgloszenia glosu, przelaczenie
// trybu otwartego mikrofonu oraz moderacje uczestnika. Rozrozniamy po
// `kind`, bo dotycza tego samego obszaru (kto moze mowic / byc na sali).
if (event.kind === 'OPEN_MIC') {
setHistory((current) =>
current ? { ...current, openMic: event.openMic ?? false } : current)
openMicHandler.current?.(event.openMic ?? false)
return
}
if (
event.kind === 'MUTED' || event.kind === 'UNMUTED' ||
event.kind === 'BANNED' || event.kind === 'UNBANNED'
) {
const mod = event as unknown as ModerationEvent
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
// wspolny, wiec przychodza tu takze moderacje innych osob
if (mod.registrationId === myRegistrationIdRef.current) {
moderationHandler.current?.(mod)
}
return
}
speakHandler.current?.(event.payload)
})
client.subscribe('/user/queue/errors', (message: IMessage) => {
const payload = JSON.parse(message.body) as { message: string }
setError(payload.message)
})
},
onDisconnect: () => setConnected(false),
onStompError: (frame) => {
setConnected(false)
setError(frame.headers.message ?? 'Połączenie z czatem zostało odrzucone.')
},
onWebSocketClose: () => setConnected(false),
})
client.activate()
clientRef.current = client
return () => {
if (heartbeatRef.current !== null) {
window.clearInterval(heartbeatRef.current)
heartbeatRef.current = null
}
void client.deactivate()
clientRef.current = null
}
}, [token, history])
const sendChat = useCallback((content: string) => {
clientRef.current?.publish({ destination: '/app/chat', body: JSON.stringify({ content }) })
}, [])
const sendQuestion = useCallback((content: string) => {
clientRef.current?.publish({ destination: '/app/question', body: JSON.stringify({ content }) })
}, [])
const vote = useCallback((interactionId: number) => {
// znaczymy od razu u siebie: powtorny glos i tak nie zostanie policzony,
// a uczestnik nie ma czekac na odpowiedz serwera, zeby zobaczyc skutek
setQuestions((current) =>
current.map((q) => (q.id === interactionId ? { ...q, votedByMe: true } : q)))
clientRef.current?.publish({
destination: '/app/vote',
body: JSON.stringify({ interactionId }),
})
}, [])
return {
history,
chat,
questions,
connected,
error,
clearError: () => setError(null),
sendChat,
sendQuestion,
vote,
}
}
/**
* Nakladanie zdarzenia na liste pytan.
*
* Zachowujemy `votedByMe` z poprzedniego stanu: rozglaszana wersja jest
* wspolna dla wszystkich odbiorcow, wiec nie wie, czy **ta** przegladarka
* juz glosowala.
*/
function applyQuestionEvent(current: InteractionView[], event: Event): InteractionView[] {
if (event.kind === 'QUESTION_REMOVED') {
return current.filter((item) => item.id !== event.payload.id)
}
const existing = current.find((item) => item.id === event.payload.id)
const merged = { ...event.payload, votedByMe: existing?.votedByMe ?? event.payload.votedByMe }
if (!existing) {
return [...current, merged]
}
return current.map((item) => (item.id === merged.id ? merged : item))
}
+44
View File
@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query'
import { publicClient } from '../api/publicClient'
/**
* Dane prawne instancji: regulamin, polityka prywatnosci, dane firmy.
*
* Pobierane z otwartego endpointu /public/legal. Buforowane na czas sesji,
* bo tresc dokumentow zmienia sie rzadko, a strony i stopka pytaja o nia
* w kilku miejscach.
*/
export type LegalCompany = {
name: string | null
address: string | null
taxId: string | null
registry: string | null
email: string | null
phone: string | null
dpoContact: string | null
}
export type Legal = {
company: LegalCompany
termsHtml: string | null
privacyHtml: string | null
brandName: string
}
export function useLegal() {
return useQuery({
queryKey: ['legal'],
queryFn: async () => (await publicClient.get<Legal>('/public/legal')).data,
staleTime: 5 * 60 * 1000,
})
}
/** Czy w danych firmy jest cokolwiek do pokazania. */
export function hasCompanyData(company: LegalCompany | undefined): boolean {
if (!company) return false
return Boolean(
company.name || company.address || company.taxId || company.registry ||
company.email || company.phone || company.dpoContact,
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { publicClient } from '../api/publicClient'
/** Jawna konfiguracja instancji, ta sama dla panelu i dla stron publicznych. */
export type PublicConfig = {
brandName: string
publicUrl: string
timezone: string
/** Adres logo instancji. Pusty oznacza znak domyslny. */
logoUrl: string
/** Adres ikony karty przegladarki. Pusty oznacza ikone domyslna. */
faviconUrl: string
}
const EMPTY: PublicConfig = {
brandName: '',
publicUrl: '',
timezone: 'Europe/Warsaw',
logoUrl: '',
faviconUrl: '',
}
/** Klucz cache, do uniewazniania po zapisie ustawien. */
export const PUBLIC_CONFIG_KEY = ['public-config'] as const
/**
* Pobiera jawna konfiguracje instancji.
*
* Zapytanie idzie przez publicClient, wiec dziala tak samo dla niezalogowanego
* uczestnika i dla obu rol w panelu.
*
* Wynik jest buforowany na czas sesji: staleTime Infinity sprawia, ze przejscia
* miedzy ekranami nie powoduja kolejnych zapytan. Zmiana ustawien w panelu
* uniewaznia ten klucz recznie, wiec nazwa marki odswieza sie od razu.
*/
export function usePublicConfig(): PublicConfig {
const { data } = useQuery({
queryKey: PUBLIC_CONFIG_KEY,
staleTime: Infinity,
gcTime: Infinity,
retry: false,
queryFn: async () => {
const response = await publicClient.get<PublicConfig>('/public/config')
return response.data
},
})
return data ?? EMPTY
}
/** Publiczny adres instancji bez ukosnika na koncu. Pusty, gdy nie ustawiono. */
export function usePublicUrl(): string {
const { publicUrl } = usePublicConfig()
return publicUrl.endsWith('/') ? publicUrl.slice(0, -1) : publicUrl
}
/**
* Podmienia ikone karty przegladarki na ikone instancji.
*
* Wolane raz, w korzeniu aplikacji. Gdy APP_FAVICON_URL jest puste, zostaje
* ikona domyslna z index.html.
*/
export function useInstanceFavicon() {
const { faviconUrl } = usePublicConfig()
useEffect(() => {
if (!faviconUrl) {
return
}
const link =
document.querySelector<HTMLLinkElement>('link[rel="icon"]') ??
document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }))
const previous = link.href
link.href = faviconUrl
// typ zdejmujemy, bo adres z ustawien moze wskazywac dowolny format
link.removeAttribute('type')
return () => {
link.href = previous
}
}, [faviconUrl])
}
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useState } from 'react'
import { publicClient } from '../api/publicClient'
/**
* Zgloszenie checi zabrania glosu.
*
* STAN POCHODZI Z SERWERA, NIE Z KLIKNIECIA
* ------------------------------------------------------------------------
* Przycisk pokazuje to, co odpowiedzial backend, a nie to, co uzytkownik
* przed chwila kliknal. Uczestnik moze miec otwarte dwie karty, moglo mu
* zerwac polaczenie w chwili przyznania glosu, a moderator moze glos odebrac
* w dowolnym momencie. Stan liczony lokalnie rozjezdzalby sie z prawda.
*
* Zdarzenia z WebSocketu przekazuje `MeetingPage` przez `apply`: hook nie
* otwiera wlasnego polaczenia, bo jedno juz istnieje na potrzeby czatu.
*/
export type SpeakStatus = 'PENDING' | 'GRANTED' | 'REVOKED' | 'WITHDRAWN' | 'DECLINED'
export type SpeakRequestView = {
id: number
registrationId: number
participantName: string
status: SpeakStatus
createdAt: string
resolvedAt: string | null
}
export function useSpeakRequest(token: string | undefined) {
const [request, setRequest] = useState<SpeakRequestView | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
/*
* Stan poczatkowy z serwera.
*
* WebSocket dowozi tylko to, co dzieje sie od podlaczenia, wiec uczestnik,
* ktoremu przyznano glos w czasie przerwy w polaczeniu albo przed
* odswiezeniem strony, bez tego nie dowiedzialby sie o tym wcale.
*/
useEffect(() => {
if (!token) return
let cancelled = false
publicClient
.get<{ request: SpeakRequestView | null }>(`/public/participation/${token}/speak-request`)
.then((response) => {
if (!cancelled) setRequest(response.data.request)
})
.catch(() => {
// brak stanu nie moze zablokowac wejscia na spotkanie: uczestnik
// zobaczy przycisk w stanie wyjsciowym i zglosi sie ponownie
})
return () => {
cancelled = true
}
}, [token])
const send = useCallback(
async (method: 'post' | 'delete') => {
if (!token) return
setBusy(true)
setError(null)
try {
const response = await publicClient[method]<{ request: SpeakRequestView | null }>(
`/public/participation/${token}/speak-request`,
)
setRequest(response.data.request)
} catch {
setError('Nie udało się wysłać zgłoszenia. Spróbuj ponownie.')
} finally {
setBusy(false)
}
},
[token],
)
/**
* Nalozenie zdarzenia z WebSocketu.
*
* Temat jest wspolny dla calego spotkania, wiec przychodza tu takze cudze
* zgloszenia. Bierzemy pod uwage wylacznie te, ktore dotycza **tego**
* uczestnika: inaczej przyznanie glosu komus innemu wlaczaloby mikrofon
* wszystkim naraz.
*
* Dopasowanie idzie po `registrationId`, a NIE po `id` zgloszenia. Wczesniej
* filtr `current.id !== incoming.id` gubil zdarzenia w dwoch realnych
* przypadkach, przez co stan glosu nie zmienial sie na zywo i trzeba bylo
* przeladowac strone:
* - gdy lokalnie nie bylo jeszcze zgloszenia (`current == null`), a przyszlo
* przyznanie/odebranie glosu,
* - gdy po wycofaniu i ponownym zgloszeniu powstalo NOWE id, a stare
* porownanie go nie rozpoznawalo.
* `registrationId` jednoznacznie wskazuje uczestnika i nie zmienia sie miedzy
* kolejnymi zgloszeniami tej samej osoby.
*/
const apply = useCallback((incoming: SpeakRequestView) => {
setRequest((current) => {
// Bez biezacego zgloszenia nie wiemy, ktory registrationId jest nasz,
// a temat jest wspolny dla calej sali - wiec cudzego zdarzenia nie
// przyjmujemy (inaczej przyznanie glosu komus innemu wlaczyloby mikrofon
// u nas). Uczestnik bez zgloszenia i tak nie ma czego aktualizowac:
// wlasne zgloszenie ustawia stan przez `send`, nie przez `apply`.
if (!current) return current
// Dopasowanie po registrationId, a NIE po id zgloszenia: po wycofaniu
// i ponownym zgloszeniu powstaje NOWE id, ale ten sam uczestnik. Stare
// porownanie po id gubilo wtedy zdarzenie i stan nie zmienial sie na zywo.
if (current.registrationId !== incoming.registrationId) return current
return incoming
})
}, [])
const status = request?.status ?? null
return {
request,
/** Czeka na decyzje moderatora. */
pending: status === 'PENDING',
/** Ma glos: mikrofon jest dostepny. */
granted: status === 'GRANTED',
busy,
error,
clearError: () => setError(null),
raiseHand: () => send('post'),
lowerHand: () => send('delete'),
apply,
}
}
+40
View File
@@ -0,0 +1,40 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { App } from './App'
import { AuthProvider } from './auth/AuthContext'
import { StudentAuthProvider } from './academy/StudentAuthContext'
import { ToastProvider } from './components/Toasts'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 401 obsluguje interceptor, ponawianie takiego zadania nie ma sensu
retry: false,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ToastProvider>
{/*
Dwie sesje obok siebie: panel i Akademia. Kazda z wlasnym tokenem
i wlasnym kluczem w localStorage, wiec zalogowanie sie do jednej
nie wyrzuca z drugiej.
*/}
<AuthProvider>
<StudentAuthProvider>
<App />
</StudentAuthProvider>
</AuthProvider>
</ToastProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)
+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>
)
}

Some files were not shown because too many files have changed in this diff Show More