Initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import '@livekit/components-styles'
|
||||
import { GridLayout, LiveKitRoom, ParticipantTile, RoomAudioRenderer, useTracks } from '@livekit/components-react'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* Strona, ktora nagrywa Egress.
|
||||
*
|
||||
* DLACZEGO WLASNY SZABLON, A NIE DOMYSLNY
|
||||
* ------------------------------------------------------------------------
|
||||
* Egress nagrywa **to, co widzi przegladarka**, wiec musi otworzyc jakas
|
||||
* strone. Domyslnie pobiera ja z `https://template.livekit.io`, czyli
|
||||
* z cudzego serwera w internecie. To oznacza dwie rzeczy, ktore w tym
|
||||
* projekcie sa nie do przyjecia:
|
||||
*
|
||||
* 1. **Nagrywanie przestaje dzialac bez dostepu do internetu.** Instancja
|
||||
* klienta stoi czesto w sieci zamknietej; Egress nie pobiera wtedy
|
||||
* szablonu i nagranie konczy sie bledem "Start signal not received".
|
||||
* 2. Wyglad nagrania zalezy od strony trzeciej, ktora moze sie zmienic.
|
||||
*
|
||||
* Wlasny szablon lezy w naszym froncie i jest osiagalny po sieci Dockera,
|
||||
* wiec nagrywanie dziala tak samo w sieci zamknietej.
|
||||
*
|
||||
* SYGNAL STARTU I KONCA
|
||||
* ------------------------------------------------------------------------
|
||||
* Egress czeka na `window.START_RECORDING()`, zanim zacznie pisac plik, i na
|
||||
* `window.END_RECORDING()`, zeby go domknac. **Bez wywolania pierwszej funkcji
|
||||
* nagranie jest przerywane po kilkunastu sekundach** z bledem 412. Wolamy ja,
|
||||
* gdy pokoj jest polaczony i sa w nim jakies sciezki: nagranie ma zaczynac sie
|
||||
* od obrazu, a nie od pustej planszy.
|
||||
*
|
||||
* Ta strona nie jest dla ludzi: nie ma tu panelu interakcji, banerow ani
|
||||
* paska akcji. Ma pokazywac obraz spotkania i nic wiecej.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
START_RECORDING?: () => void
|
||||
END_RECORDING?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export function RecordingTemplate() {
|
||||
const [params] = useSearchParams()
|
||||
const url = params.get('url') ?? ''
|
||||
const token = params.get('token') ?? ''
|
||||
|
||||
if (!url || !token) {
|
||||
return <div style={{ color: '#fff', padding: 24 }}>Brak parametrów nagrywania.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100vw', height: '100vh', background: '#111' }} data-lk-theme="default">
|
||||
<LiveKitRoom serverUrl={url} token={token} connect audio={false} video={false}>
|
||||
<RecordingStage />
|
||||
<RoomAudioRenderer />
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uklad kafelkow plus sygnalizacja do Egress.
|
||||
*
|
||||
* Musi byc osobnym komponentem **wewnatrz** `LiveKitRoom`: `useTracks` czyta
|
||||
* kontekst pokoju, ktorego poza nim nie ma.
|
||||
*/
|
||||
function RecordingStage() {
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: false },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: true, updateOnlyOn: [RoomEvent.ActiveSpeakersChanged] },
|
||||
)
|
||||
|
||||
/*
|
||||
* Sygnal startu wysylamy raz, gdy pojawi sie pierwsza sciezka. Wyslanie go
|
||||
* od razu po polaczeniu dawaloby kilka sekund czarnego obrazu na poczatku
|
||||
* kazdego nagrania.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (tracks.length > 0 && window.START_RECORDING) {
|
||||
window.START_RECORDING()
|
||||
// wolamy tylko raz: powtorne wywolanie Egress ignoruje, ale nie ma
|
||||
// powodu go zasypywac przy kazdej zmianie liczby sciezek
|
||||
window.START_RECORDING = undefined
|
||||
}
|
||||
}, [tracks.length])
|
||||
|
||||
return (
|
||||
<GridLayout tracks={tracks} style={{ height: '100vh' }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { publicClient, publicErrorMessage, publicFieldErrors } from '../../api/publicClient'
|
||||
import type { ConsentText, ConsentType, PublicWebinar, RegistrationResult } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
|
||||
type FormState = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
company: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { firstName: '', lastName: '', email: '', phone: '', company: '' }
|
||||
|
||||
/**
|
||||
* Adres przeniesiony z karty webinaru na stronie glownej.
|
||||
*
|
||||
* Karta ma tam jedno pole na adres, ale zapis wymaga takze imienia, nazwiska
|
||||
* i zgody na regulamin: `first_name` i `last_name` maja NOT NULL, a zgoda musi
|
||||
* byc zapisana z wersja tresci. Dlatego karta nie zapisuje, tylko przenosi
|
||||
* tutaj to, co uzytkownik zdazyl wpisac, zeby nie musial pisac adresu drugi raz.
|
||||
*
|
||||
* Wartosc pochodzi z adresu strony, wiec jest **danymi od uzytkownika**:
|
||||
* traktujemy ja jak tresc pola formularza, nie jak zaufane wejscie. Nie ma
|
||||
* tu ryzyka przekierowania, bo nie buduje sie z niej zadnej sciezki.
|
||||
*/
|
||||
function prefilledEmail(): string {
|
||||
try {
|
||||
const value = new URLSearchParams(window.location.search).get('email')
|
||||
return value && value.length <= 255 ? value.trim() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Prosty format adresu. Wlasciwa walidacje robi backend. */
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
|
||||
|
||||
/**
|
||||
* Pola sa wysokie i maja 16px tekstu, zeby iOS nie powiekszal widoku
|
||||
* przy wejsciu w pole, a palec trafial bez celowania.
|
||||
*/
|
||||
const INPUT_CLASS =
|
||||
'w-full min-h-[48px] rounded-md border border-slate-200 bg-white px-4 text-base text-slate-900 placeholder:text-slate-400 transition duration-fast ease-standard hover:border-slate-200 focus:border-blue-500 focus:outline-none focus:ring-[3px] focus:ring-blue-500/45'
|
||||
|
||||
export function RegistrationForm({
|
||||
webinar,
|
||||
onSuccess,
|
||||
}: {
|
||||
webinar: PublicWebinar
|
||||
onSuccess: (email: string) => void
|
||||
}) {
|
||||
const [form, setForm] = useState<FormState>(() => ({ ...EMPTY, email: prefilledEmail() }))
|
||||
// zgody zawsze startuja odznaczone, takze ta wymagana
|
||||
const [consents, setConsents] = useState<Partial<Record<ConsentType, boolean>>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [generalError, setGeneralError] = useState<string | null>(null)
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await publicClient.post<RegistrationResult>(
|
||||
`/public/webinars/${webinar.slug}/register`,
|
||||
{
|
||||
email: form.email.trim(),
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
phone: form.phone.trim() || null,
|
||||
company: form.company.trim() || null,
|
||||
consents,
|
||||
/*
|
||||
* Zaproszenie, z ktorego przyszedl uczestnik. Backend sprawdza, czy
|
||||
* faktycznie dotyczy tego webinaru: identyfikator jest w adresie,
|
||||
* wiec kazdy moze go podmienic.
|
||||
*/
|
||||
invitationId: invitationIdFromUrl(),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => onSuccess(form.email.trim()),
|
||||
onError: (error) => {
|
||||
// komunikaty backendu pokazujemy wiernie, bez podmieniania na wlasne
|
||||
setErrors(publicFieldErrors(error))
|
||||
setGeneralError(publicErrorMessage(error, 'Nie udało się zapisać. Spróbuj ponownie.'))
|
||||
},
|
||||
})
|
||||
|
||||
function validate(): Record<string, string> {
|
||||
const found: Record<string, string> = {}
|
||||
if (!form.firstName.trim()) found.firstName = 'Podaj imię'
|
||||
if (!form.lastName.trim()) found.lastName = 'Podaj nazwisko'
|
||||
if (!form.email.trim()) found.email = 'Podaj adres e-mail'
|
||||
else if (!EMAIL_PATTERN.test(form.email.trim())) found.email = 'Niepoprawny adres e-mail'
|
||||
|
||||
const requiredConsent = webinar.consents.find((c) => c.required)
|
||||
if (requiredConsent && !consents[requiredConsent.type]) {
|
||||
found.consents = 'Akceptacja regulaminu jest wymagana'
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setGeneralError(null)
|
||||
const found = validate()
|
||||
setErrors(found)
|
||||
if (Object.keys(found).length > 0) return
|
||||
submit.mutate()
|
||||
}
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: string) {
|
||||
setForm((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ id: 'firstName', label: 'Imię', required: true, autoComplete: 'given-name' },
|
||||
{ id: 'lastName', label: 'Nazwisko', required: true, autoComplete: 'family-name' },
|
||||
{ id: 'email', label: 'Adres e-mail', required: true, autoComplete: 'email', type: 'email', inputMode: 'email' as const },
|
||||
{ id: 'phone', label: 'Telefon', required: false, autoComplete: 'tel', type: 'tel', inputMode: 'tel' as const },
|
||||
{ id: 'company', label: 'Firma', required: false, autoComplete: 'organization' },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Formularz zapisu</h2>
|
||||
|
||||
{generalError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2.5 rounded-md border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600"
|
||||
>
|
||||
<Icon name="circle-alert" size={18} className="mt-px" />
|
||||
<p className="min-w-0 flex-1">{generalError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<label htmlFor={field.id} className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{field.label}{' '}
|
||||
{field.required ? (
|
||||
<span className="text-alarm-500">*</span>
|
||||
) : (
|
||||
<span className="font-normal text-slate-400">(opcjonalnie)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id={field.id}
|
||||
type={'type' in field ? field.type : 'text'}
|
||||
inputMode={'inputMode' in field ? field.inputMode : undefined}
|
||||
className={INPUT_CLASS}
|
||||
value={form[field.id]}
|
||||
onChange={(e) => update(field.id, e.target.value)}
|
||||
autoComplete={field.autoComplete}
|
||||
/>
|
||||
{errors[field.id] && (
|
||||
<p className="mt-1.5 text-sm text-alarm-600">{errors[field.id]}</p>
|
||||
)}
|
||||
{field.id === 'email' && !errors.email && (
|
||||
<p className="mt-1.5 text-xs text-slate-500">
|
||||
Na ten adres wyślemy potwierdzenie z linkiem do spotkania.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<legend className="sr-only">Zgody</legend>
|
||||
|
||||
{/*
|
||||
Przelacznik "master" nad lista zgod: jednym klikiem zaznacza albo
|
||||
odznacza wszystkie ponizej. NIE jest zaznaczony domyslnie - zgoda
|
||||
musi byc swiadomym dzialaniem uzytkownika, wiec to on go klika.
|
||||
Stan liczy sie z dzieci: wszystkie zaznaczone -> zaznaczony,
|
||||
czesc -> stan posredni (indeterminate), zadne -> pusty.
|
||||
*/}
|
||||
{(() => {
|
||||
const total = webinar.consents.length
|
||||
const selected = webinar.consents.filter((c) => consents[c.type] === true).length
|
||||
const allChecked = total > 0 && selected === total
|
||||
const someChecked = selected > 0 && selected < total
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-300 bg-slate-25 p-3.5 transition duration-fast ease-standard hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => {
|
||||
// stan posredni: czesc zgod zaznaczona
|
||||
if (el) el.indeterminate = someChecked
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const value = e.target.checked
|
||||
setConsents(() => {
|
||||
const next: Partial<Record<ConsentType, boolean>> = {}
|
||||
for (const c of webinar.consents) next[c.type] = value
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 rounded border-slate-300 text-blue-500 focus:ring-[3px] focus:ring-blue-500/45"
|
||||
/>
|
||||
<span className="min-w-0 text-sm font-semibold leading-relaxed text-slate-900">
|
||||
Zaznacz wszystkie zgody
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Dzieci: wciete i polaczone pionowa linia, uklad drzewa */}
|
||||
<div className="ml-3 space-y-3 border-l border-slate-200 pl-4">
|
||||
{webinar.consents.map((consent) => (
|
||||
<label
|
||||
key={consent.type}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3.5 transition duration-fast ease-standard hover:border-slate-200 hover:bg-slate-25"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
// zgody nigdy nie sa zaznaczone domyslnie
|
||||
checked={consents[consent.type] === true}
|
||||
onChange={(e) =>
|
||||
setConsents((current) => ({ ...current, [consent.type]: e.target.checked }))
|
||||
}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 rounded border-slate-200 text-blue-500 focus:ring-[3px] focus:ring-blue-500/45"
|
||||
/>
|
||||
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
|
||||
{consent.required && (
|
||||
<span className="mr-1 font-semibold text-slate-900">Wymagane:</span>
|
||||
)}
|
||||
{renderConsentText(consent)}
|
||||
{!consent.required && (
|
||||
<span className="ml-1 text-xs text-slate-400">(opcjonalne)</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errors.consents && <p className="text-sm text-alarm-600">{errors.consents}</p>}
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submit.isPending}
|
||||
className="flex min-h-[52px] w-full items-center justify-center gap-2 rounded-md bg-blue-500 px-4 text-base font-semibold text-white transition duration-fast ease-standard hover:bg-blue-600 active:translate-y-px focus:outline-none focus:ring-[3px] focus:ring-blue-500/45 disabled:bg-blue-200"
|
||||
>
|
||||
{submit.isPending && <Icon name="loader" size={18} className="animate-spin" />}
|
||||
{submit.isPending ? 'Zapisywanie...' : 'Zapisz się na webinar'}
|
||||
</button>
|
||||
|
||||
<p className="hx-data text-center text-2xs text-slate-400">
|
||||
Wersja treści zgód: {webinar.consentVersion}
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identyfikator zaproszenia z adresu strony.
|
||||
*
|
||||
* Link w zaproszeniu ma postac /w/{slug}?zaproszenie={id}. Wartosc niepoprawna
|
||||
* pomijamy po cichu: zapis ma sie udac niezaleznie od tego, czy da sie go
|
||||
* powiazac ze zrodlem.
|
||||
*/
|
||||
/**
|
||||
* Renderuje tresc zgody, zamieniajac wskazane slowo na klikalny odnosnik.
|
||||
*
|
||||
* Gdy zgoda ma `linkUrl` i `linkLabel` (np. slowo "regulamin" -> /regulamin),
|
||||
* to jedno wystapienie tego slowa staje sie odnosnikiem otwieranym w nowej
|
||||
* karcie. Reszta tresci zostaje zwyklym tekstem. Bez linku - caly tekst zwykly.
|
||||
* `rel="noopener noreferrer"` odcina nowej karcie dostep do okna zapisu.
|
||||
*/
|
||||
function renderConsentText(consent: ConsentText): ReactNode {
|
||||
const { text, linkUrl, linkLabel } = consent
|
||||
if (!linkUrl || !linkLabel) {
|
||||
return text
|
||||
}
|
||||
const index = text.toLowerCase().indexOf(linkLabel.toLowerCase())
|
||||
if (index < 0) {
|
||||
return text
|
||||
}
|
||||
const before = text.slice(0, index)
|
||||
const match = text.slice(index, index + linkLabel.length)
|
||||
const after = text.slice(index + linkLabel.length)
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
// klik w link nie moze przelaczac checkboxa zgody
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
{after}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function invitationIdFromUrl(): number | null {
|
||||
const raw = new URLSearchParams(window.location.search).get('zaproszenie')
|
||||
if (!raw) return null
|
||||
const parsed = Number(raw)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Client, type IMessage } from '@stomp/stompjs'
|
||||
import { publicClient, publicErrorMessage } from '../../api/publicClient'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Button, Spinner } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Zadania powstale podczas webinaru.
|
||||
*
|
||||
* Zadanie ogloszone przez prowadzacego pojawia sie tu bez odswiezania strony,
|
||||
* a liczba wolnych miejsc zmienia sie u wszystkich, gdy ktos sie zapisze
|
||||
* albo wycofa.
|
||||
*/
|
||||
|
||||
export type TaskView = {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
capacity: number | null
|
||||
taken: number
|
||||
open: boolean
|
||||
full: boolean
|
||||
signedUp: boolean
|
||||
deadline: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function TasksTab({ token, webinarId }: { token: string; webinarId: number }) {
|
||||
const [tasks, setTasks] = useState<TaskView[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await publicClient.get<{ tasks: TaskView[] }>(
|
||||
`/public/participation/${token}/tasks`)
|
||||
setTasks(response.data.tasks)
|
||||
} catch (e) {
|
||||
setError(publicErrorMessage(e, 'Nie udało się wczytać zadań.'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// zmiany od prowadzacego i od innych uczestnikow
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const stomp = new Client({
|
||||
brokerURL: `${protocol}//${window.location.host}/ws`,
|
||||
connectHeaders: { 'X-Join-Token': token },
|
||||
reconnectDelay: 4000,
|
||||
onConnect: () => {
|
||||
stomp.subscribe(`/topic/webinars/${webinarId}/tasks`, (message: IMessage) => {
|
||||
const event = JSON.parse(message.body) as { kind: string; payload: TaskView }
|
||||
setTasks((current) => applyTaskEvent(current, event))
|
||||
})
|
||||
},
|
||||
})
|
||||
stomp.activate()
|
||||
return () => {
|
||||
void stomp.deactivate()
|
||||
}
|
||||
}, [token, webinarId])
|
||||
|
||||
async function toggle(task: TaskView) {
|
||||
setBusy(task.id)
|
||||
setError(null)
|
||||
try {
|
||||
const path = `/public/participation/${token}/tasks/${task.id}/signup`
|
||||
const response = task.signedUp
|
||||
? await publicClient.delete<TaskView>(path)
|
||||
: await publicClient.post<TaskView>(path)
|
||||
setTasks((current) =>
|
||||
current.map((t) => (t.id === task.id ? response.data : t)))
|
||||
} catch (e) {
|
||||
setError(publicErrorMessage(e, 'Nie udało się zmienić zgłoszenia.'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{error && (
|
||||
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-2 text-xs text-alarm-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
Prowadzący nie ogłosił jeszcze żadnych zadań.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tasks.map((task) => {
|
||||
const left = task.capacity === null ? null : task.capacity - task.taken
|
||||
const blocked = !task.open || (task.full && !task.signedUp)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`rounded-md border p-3 ${
|
||||
task.signedUp ? 'border-ok-500/30 bg-ok-50' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-sm text-slate-600">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="hx-data text-xs text-slate-500">
|
||||
{task.capacity === null
|
||||
? `${task.taken} zapisanych, bez limitu`
|
||||
: `${task.taken} / ${task.capacity} miejsc`}
|
||||
</span>
|
||||
{left !== null && left > 0 && !task.signedUp && (
|
||||
<span className="text-xs text-slate-400">wolne: {left}</span>
|
||||
)}
|
||||
{!task.open && <span className="text-xs text-warn-600">zapisy zamknięte</span>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={task.signedUp ? 'secondary' : 'primary'}
|
||||
className="ml-auto"
|
||||
disabled={busy === task.id || (blocked && !task.signedUp)}
|
||||
onClick={() => toggle(task)}
|
||||
>
|
||||
{task.signedUp ? 'Wycofaj się' : task.full ? 'Brak miejsc' : 'Zgłaszam się'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{task.signedUp && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs text-ok-600">
|
||||
<Icon name="check-circle" size={14} />
|
||||
Jesteś zapisany. Po webinarze dostaniesz mail z listą swoich zadań.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nakladanie zdarzenia na liste. `signedUp` zostaje z poprzedniego stanu. */
|
||||
function applyTaskEvent(
|
||||
current: TaskView[],
|
||||
event: { kind: string; payload: TaskView },
|
||||
): TaskView[] {
|
||||
if (event.kind === 'TASK_REMOVED') {
|
||||
return current.filter((t) => t.id !== event.payload.id)
|
||||
}
|
||||
|
||||
const existing = current.find((t) => t.id === event.payload.id)
|
||||
// rozglaszana wersja jest wspolna dla wszystkich, wiec nie wie,
|
||||
// czy TA przegladarka jest zapisana
|
||||
const merged = { ...event.payload, signedUp: existing?.signedUp ?? false }
|
||||
|
||||
if (!existing) return [...current, merged]
|
||||
return current.map((t) => (t.id === merged.id ? merged : t))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { publicClient, publicErrorMessage } from '../../api/publicClient'
|
||||
import type { UnsubscribeResult } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PublicLayout } from './PublicLayout'
|
||||
|
||||
/**
|
||||
* Wypisanie z wiadomosci marketingowych.
|
||||
*
|
||||
* DWA KROKI, NIE JEDEN
|
||||
* ------------------------------------------------------------------------
|
||||
* Samo otwarcie linku niczego nie zmienia: skanery poczty i podglad linkow
|
||||
* w kliencie odwiedzaja adresy z wiadomosci automatycznie, wiec wypisanie
|
||||
* przy wejsciu na strone wypisywaloby ludzi, ktorzy niczego nie kliknęli.
|
||||
* Zmiana nastepuje dopiero po nacisnieciu przycisku.
|
||||
*
|
||||
* Strona mowi wprost, czego wypisanie **nie** obejmuje: maile o webinarze,
|
||||
* na ktory ktos sam sie zapisal, ida dalej, bo to obsluga zamowionej uslugi,
|
||||
* a nie marketing.
|
||||
*/
|
||||
export function UnsubscribePage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const [done, setDone] = useState<UnsubscribeResult | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['unsubscribe', token],
|
||||
queryFn: async () => {
|
||||
const response = await publicClient.get<UnsubscribeResult>(`/public/unsubscribe/${token}`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
async function confirm() {
|
||||
setPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await publicClient.post<UnsubscribeResult>(`/public/unsubscribe/${token}`)
|
||||
setDone(response.data)
|
||||
} catch (e) {
|
||||
setError(publicErrorMessage(e, 'Nie udało się zapisać rezygnacji. Spróbuj ponownie.'))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className="flex justify-center py-16">
|
||||
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
|
||||
<Icon name="circle-alert" size={32} className="mx-auto text-warn-600" />
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||
Nie znaleźliśmy takiego zapisu
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Link mógł wygasnąć albo został już wykorzystany. Jeśli nadal dostajesz wiadomości,
|
||||
odpowiedz na dowolną z nich.
|
||||
</p>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const state = done ?? query.data
|
||||
const finished = done !== null || query.data.alreadyDone
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6">
|
||||
{finished ? (
|
||||
<div className="text-center">
|
||||
<Icon name="check-circle" size={32} className="mx-auto text-ok-500" />
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||
{done?.alreadyDone || query.data.alreadyDone
|
||||
? 'Już nie wysyłamy Ci wiadomości'
|
||||
: 'Rezygnacja została zapisana'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Adres <span className="hx-data font-medium">{state.email}</span> nie będzie już
|
||||
dostawał zaproszeń ani informacji handlowych.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-slate-900">
|
||||
Rezygnacja z wiadomości handlowych
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Po potwierdzeniu adres{' '}
|
||||
<span className="hx-data font-medium">{state.email}</span> przestanie dostawać
|
||||
zaproszenia na webinary i inne informacje handlowe.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 rounded-md bg-alarm-50 p-3 text-sm text-alarm-600">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={pending}
|
||||
className="mt-5 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 px-4 py-3.5 text-base font-semibold text-white transition hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
<Icon name="user-off" size={20} />
|
||||
{pending ? 'Zapisywanie...' : 'Potwierdzam rezygnację'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Wazne rozroznienie: wypisanie dotyczy marketingu, a nie obslugi
|
||||
webinaru, na ktory ktos sam sie zapisal. Bez tego zdania uczestnik
|
||||
moglby sadzic, ze straci potwierdzenie i przypomnienie.
|
||||
*/}
|
||||
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
|
||||
Nadal będziesz dostawać wiadomości dotyczące webinarów, na które sam się zapisałeś:
|
||||
potwierdzenie zapisu, przypomnienie o terminie i podsumowanie po spotkaniu. To obsługa
|
||||
zamówionej usługi, a nie informacje handlowe.
|
||||
</p>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type ZoomMtgEmbedded from '@zoom/meetingsdk/embedded'
|
||||
|
||||
/** Typ klienta osadzonego, zwracany przez createClient(). */
|
||||
type EmbeddedZoomClient = ReturnType<typeof ZoomMtgEmbedded.createClient>
|
||||
|
||||
/**
|
||||
* Pokoj Zoom osadzony w oknie webinaru (Meeting SDK Web, component view).
|
||||
*
|
||||
* GOTOWY KOMPONENT, NIE WLASNY INTERFEJS
|
||||
* ------------------------------------------------------------------------
|
||||
* Tak jak przy LiveKit i Jitsi, obraz i sterowanie daje gotowy komponent
|
||||
* dostawcy. Nasze zostaje to, czego Zoom nie ma: czat, pytania, zadania,
|
||||
* zapytania ofertowe, banery i pasek akcji.
|
||||
*
|
||||
* BEZ SharedArrayBuffer
|
||||
* ------------------------------------------------------------------------
|
||||
* Osadzamy BEZ naglowkow izolacji COOP/COEP. Meeting SDK dziala wtedy
|
||||
* poprawnie, traci tylko widok galerii i wirtualne tlo, ktore na webinarze
|
||||
* z widocznym glownie prelegentem nie sa potrzebne. Wlaczenie ich w przyszlosci
|
||||
* to dodanie naglowkow wylacznie na trasie spotkania.
|
||||
*
|
||||
* UPRAWNIENIA
|
||||
* ------------------------------------------------------------------------
|
||||
* Zoom NIE egzekwuje audio/wideo per uczestnik z podpisu: rola to tylko host
|
||||
* albo uczestnik. Wyciszenie i wylaczenie obrazu ustawia backend na poziomie
|
||||
* calego spotkania przy jego zakladaniu. To slabsza gwarancja niz LiveKit,
|
||||
* blizsza Jitsi. Dlatego props `canPublish*` sluza tu wylacznie do wygody
|
||||
* poczatkowego stanu, nie sa bariera.
|
||||
*/
|
||||
export function ZoomRoom({
|
||||
meetingNumber,
|
||||
signature,
|
||||
sdkKey,
|
||||
displayName,
|
||||
isHost,
|
||||
}: {
|
||||
meetingNumber: string
|
||||
signature: string
|
||||
sdkKey: string
|
||||
displayName: string
|
||||
isHost: boolean
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// klient Zoom jest tworzony raz i sprzatany przy odmontowaniu.
|
||||
// Typ pochodzi z SDK, a nie z wlasnej atrapy.
|
||||
const clientRef = useRef<EmbeddedZoomClient | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
async function start() {
|
||||
// Meeting SDK laduje sie dynamicznie, zeby nie wchodzil do glownego
|
||||
// pakietu: potrzebny jest wylacznie na sali.
|
||||
const mod = await import('@zoom/meetingsdk/embedded')
|
||||
const ZoomMtgEmbedded = mod.default
|
||||
if (cancelled || !container) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = ZoomMtgEmbedded.createClient()
|
||||
clientRef.current = client
|
||||
|
||||
await client.init({
|
||||
zoomAppRoot: container,
|
||||
language: 'pl-PL',
|
||||
patchJsMedia: true,
|
||||
})
|
||||
|
||||
await client.join({
|
||||
sdkKey,
|
||||
signature,
|
||||
meetingNumber,
|
||||
userName: displayName,
|
||||
// rola egzekwowana przez podpis po stronie serwera; tutaj tylko
|
||||
// przekazujemy to, co jest juz w podpisie
|
||||
password: '',
|
||||
})
|
||||
}
|
||||
|
||||
void start().catch((error) => {
|
||||
// blad wejscia nie moze wywrocic calego ekranu: panel interakcji
|
||||
// (czat, pytania) ma dzialac nawet gdy obraz sie nie zestawil
|
||||
console.error('Nie udalo sie dolaczyc do spotkania Zoom', error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
const client = clientRef.current
|
||||
if (client) {
|
||||
try {
|
||||
void client.leaveMeeting()
|
||||
} catch {
|
||||
// klient mogl nie dojsc do stanu polaczonego; sprzatanie i tak konczymy
|
||||
}
|
||||
clientRef.current = null
|
||||
}
|
||||
}
|
||||
}, [meetingNumber, signature, sdkKey, displayName, isHost])
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full" />
|
||||
}
|
||||
Reference in New Issue
Block a user