515d9a41e2
Deploy PROD / deploy (push) Successful in 26s
Przyznanie i odebranie glosu oraz oba tryby (otwarty mikrofon, wideo dla wszystkich) zmieniaja uprawnienia uczestnika przez updateParticipant, bez ponownego wejscia na sale. Odebranie najpierw wycisza sciezke na serwerze, wiec dziala takze u klienta, ktory nie odebral zdarzenia. Sala LiveKit ma staly key; mikrofon wlacza sie sam po przyznaniu glosu. Rola uczestnika liczona w jednym miejscu (VideoService.participantRole) dla biletu i zmiany na zywo. RoomPermissionTest pilnuje jawnej listy zrodel publikacji. Blokada pisania na czacie ma ikone przekreslonego dymku i znacznik "bez czatu" zamiast przekreslonego mikrofonu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
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,
|
|
/**
|
|
* Faktyczne uprawnienie do mikrofonu, prosto z tokenu wydanego przez backend.
|
|
*
|
|
* JEDYNE ZRODLO PRAWDY O TYM, CZY UCZESTNIK MOZE MOWIC
|
|
* ---------------------------------------------------------------------
|
|
* Wczesniej pasek skladal te odpowiedz sam, ze stanu zgloszenia
|
|
* (`speak.granted`) i trybu otwartego mikrofonu. Rozjezdzalo sie to
|
|
* z rzeczywistoscia, bo prawo do audio daje takze rola ACTIVE_PARTICIPANT
|
|
* przy wlaczonym "wideo dla wszystkich": odebranie glosu nie zmienialo wtedy
|
|
* uprawnien, ale napis "Masz glos" znikal. Uczestnik widzial zamkniety
|
|
* mikrofon przy dzialajacym i odwrotnie.
|
|
*/
|
|
canPublishAudio,
|
|
/**
|
|
* 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
|
|
canPublishAudio: 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} canPublishAudio={canPublishAudio} />
|
|
))}
|
|
|
|
{/*
|
|
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,
|
|
canPublishAudio,
|
|
}: {
|
|
speak: ReturnType<typeof useSpeakRequest>
|
|
canPublishAudio: boolean
|
|
}) {
|
|
/*
|
|
* Napis o mikrofonie idzie za UPRAWNIENIEM, a nie za stanem zgloszenia:
|
|
* mowi o tym, co uczestnik faktycznie moze, a nie o tym, o co prosil.
|
|
* Nie twierdzi, ze mikrofon jest wlaczony: sala wlacza go sama po
|
|
* przyznaniu glosu, ale uczestnik moze go zaraz wyciszyc przyciskiem.
|
|
* Przycisk "Oddaj glos" zostaje przy zgloszeniu, bo dotyczy wlasnie jego.
|
|
*/
|
|
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} />
|
|
{canPublishAudio ? 'Masz głos, możesz mówić' : 'Masz głos, trwa włączanie mikrofonu...'}
|
|
</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>
|
|
)
|
|
}
|