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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user