Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
@@ -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>
)
}