Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
@@ -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?
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+5306
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
© {year} {brandName || 'Platforma szkoleniowa'}. Wszystkie prawa zastrzeżone.
|
||||
</span>
|
||||
<span className="hx-data">Obsługiwane przez platformę webinarową</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()}
|
||||
>
|
||||
• —
|
||||
</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()}
|
||||
>
|
||||
»
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="Linia pozioma"
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
>
|
||||
—
|
||||
</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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
@@ -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, '')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user