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
+160
View File
@@ -0,0 +1,160 @@
import { useEffect } from 'react'
import type { BannerPlacement, PublicBanner } from '../../api/types'
import { Icon } from '../../components/Icon'
import { useRotation } from '../../lib/useBanners'
/**
* Miejsce na baner w sali.
*
* ROTACJA JEST NA MIEJSCE, NIE NA CALA SALE
* ------------------------------------------------------------------------
* Kazde miejsce rotuje niezaleznie: baner boczny i pas pod wideo nie musza
* zmieniac sie w tej samej chwili, a wspolny licznik wiazalby je bez powodu.
*
* KLIKNIECIE MA DWIE DROGI
* ------------------------------------------------------------------------
* Zwykly baner otwiera `targetUrl` w nowej karcie. Baner z tematem oferty
* otwiera **formularz zapytania** w oknie, bez opuszczania sali: to domyka
* droge od reklamy do kontaktu handlowego. Uczestnik, ktory wyszedl na obcy
* serwis w trakcie prezentacji, zwykle juz nie wraca.
*
* `rel="noopener noreferrer"` odcina nowej karcie dostep do `window.opener`
* i nie zdradza obcemu serwisowi, z jakiego adresu przyszedl uczestnik.
*/
export function BannerSlot({
placement,
banners,
rotationSeconds,
onImpression,
onClick,
onDismiss,
onOfferTopic,
className = '',
}: {
placement: BannerPlacement
banners: PublicBanner[]
rotationSeconds: number
onImpression: (bannerId: number) => void
onClick: (bannerId: number) => void
/** Zamkniecie, wylacznie przy OVERLAY. */
onDismiss?: (bannerId: number) => void
/** Otwarcie formularza oferty z wybranym z gory tematem. */
onOfferTopic?: (topic: string) => void
className?: string
}) {
const index = useRotation(banners.length, rotationSeconds)
const banner = banners[index]
/*
* Wyswietlenie liczymy, gdy baner faktycznie wchodzi na ekran, a nie gdy
* przychodzi z serwera: baner z rotacji, ktorego uczestnik nigdy nie
* zobaczyl, nie zostal wyswietlony.
*/
useEffect(() => {
if (banner) onImpression(banner.id)
}, [banner, onImpression])
if (!banner) return null
const isOffer = Boolean(banner.offerTopicName)
const clickable = isOffer || Boolean(banner.targetUrl)
function handleClick() {
onClick(banner.id)
if (isOffer && banner.offerTopicName && onOfferTopic) {
onOfferTopic(banner.offerTopicName)
}
}
const content = (
<>
{banner.mediaUrl ? (
<img
src={banner.mediaUrl}
alt={banner.title}
className="block w-full object-contain"
loading="lazy"
/>
) : (
/*
Tresc jest oczyszczona sanitizerem na backendzie, tym samym co tresc
strony zapisu, wiec nie czyscimy jej drugi raz w przegladarce.
*/
<div
className="prose prose-sm max-w-none p-3"
dangerouslySetInnerHTML={{ __html: banner.contentHtml ?? '' }}
/>
)}
</>
)
const frame =
placement === 'OVERLAY'
? 'relative overflow-hidden rounded-lg border border-white/20 bg-white shadow-xl'
: 'relative overflow-hidden rounded-lg border border-slate-200 bg-white'
return (
<div className={`${frame} ${className}`}>
{/*
Baner z adresem zewnetrznym jest prawdziwym linkiem, a nie przyciskiem:
tylko wtedy dziala srodkowy przycisk myszy i menu kontekstowe.
Baner otwierajacy formularz oferty musi byc przyciskiem, bo nigdzie
nie prowadzi.
*/}
{isOffer ? (
<button type="button" onClick={handleClick} className="block w-full text-left">
{content}
</button>
) : banner.targetUrl ? (
<a
href={banner.targetUrl}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
className="block"
>
{content}
</a>
) : (
content
)}
{isOffer && (
<p className="border-t border-slate-100 px-3 py-1.5 text-xs font-medium text-blue-700">
Kliknij, żeby zapytać o ofertę
</p>
)}
{/*
OVERLAY zaslania czesc obrazu, wiec musi dac sie zamknac: baner nie do
usuniecia w trakcie prezentacji jest gorszy niz brak banera.
*/}
{placement === 'OVERLAY' && onDismiss && (
<button
type="button"
onClick={() => onDismiss(banner.id)}
aria-label="Zamknij baner"
className="absolute right-1 top-1 flex h-8 w-8 items-center justify-center rounded-md bg-white/90 text-slate-600 shadow-sm ring-1 ring-slate-200 transition-colors duration-fast hover:bg-white hover:text-slate-900"
>
<Icon name="x" size={16} />
</button>
)}
{/* wskaznik rotacji: uczestnik widzi, ze jest wiecej niz jeden baner */}
{banners.length > 1 && (
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
{banners.map((item, i) => (
<span
key={item.id}
className={`h-1.5 w-1.5 rounded-full ${
i === index ? 'bg-blue-500' : 'bg-slate-300'
}`}
/>
))}
</div>
)}
{!clickable && <span className="sr-only">{banner.title}</span>}
</div>
)
}