161 lines
5.0 KiB
TypeScript
161 lines
5.0 KiB
TypeScript
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>
|
|
)
|
|
}
|