feat: add support for sticky sponsor banner (#7602)
* Add sponsors functionality * Fix overlapping issue * Add sticky top sponsorpull/7603/head
parent
7f399f5c7c
commit
65f1c9ca50
9 changed files with 404 additions and 12 deletions
@ -0,0 +1,104 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { httpGet, httpPatch, httpPost } from '../../lib/http'; |
||||||
|
import { sponsorHidden } from '../../stores/page'; |
||||||
|
import { useStore } from '@nanostores/react'; |
||||||
|
import { X } from 'lucide-react'; |
||||||
|
import { setViewSponsorCookie } from '../../lib/jwt'; |
||||||
|
import { isMobile } from '../../lib/is-mobile'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { getUrlUtmParams } from '../../lib/browser.ts'; |
||||||
|
|
||||||
|
export type BottomRightSponsorType = { |
||||||
|
id: string; |
||||||
|
company: string; |
||||||
|
description: string; |
||||||
|
gaLabel: string; |
||||||
|
imageUrl: string; |
||||||
|
pageUrl: string; |
||||||
|
title: string; |
||||||
|
url: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type V1GetSponsorResponse = { |
||||||
|
id?: string; |
||||||
|
href?: string; |
||||||
|
sponsor?: BottomRightSponsorType; |
||||||
|
}; |
||||||
|
|
||||||
|
type BottomRightSponsorProps = { |
||||||
|
sponsor: BottomRightSponsorType; |
||||||
|
|
||||||
|
onSponsorClick: () => void; |
||||||
|
onSponsorImpression: () => void; |
||||||
|
onSponsorHidden: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function BottomRightSponsor(props: BottomRightSponsorProps) { |
||||||
|
const { sponsor, onSponsorImpression, onSponsorClick, onSponsorHidden } = |
||||||
|
props; |
||||||
|
|
||||||
|
const [isHidden, setIsHidden] = useState(false); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!sponsor) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
onSponsorImpression(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const { url, title, imageUrl, description, company, gaLabel } = sponsor; |
||||||
|
|
||||||
|
const isRoadmapAd = title.toLowerCase() === 'advertise with us!'; |
||||||
|
|
||||||
|
if (isHidden) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
href={url} |
||||||
|
target="_blank" |
||||||
|
rel="noopener sponsored nofollow" |
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 flex bg-white shadow-lg outline-0 outline-transparent sm:bottom-[15px] sm:left-auto sm:right-[15px] sm:max-w-[350px]" |
||||||
|
onClick={onSponsorClick} |
||||||
|
> |
||||||
|
<span |
||||||
|
className="absolute right-1 top-1 text-gray-400 hover:text-gray-800 sm:right-1.5 sm:top-1.5 sm:text-gray-300" |
||||||
|
aria-label="Close" |
||||||
|
onClick={(e) => { |
||||||
|
e.preventDefault(); |
||||||
|
e.stopPropagation(); |
||||||
|
|
||||||
|
setIsHidden(true); |
||||||
|
onSponsorHidden(); |
||||||
|
}} |
||||||
|
> |
||||||
|
<X className="h-5 w-5 sm:h-4 sm:w-4" /> |
||||||
|
</span> |
||||||
|
<span> |
||||||
|
<img |
||||||
|
src={imageUrl} |
||||||
|
className="block h-[106px] object-cover sm:h-[153px] sm:w-[118.18px]" |
||||||
|
alt="Sponsor Banner" |
||||||
|
/> |
||||||
|
</span> |
||||||
|
<span className="flex flex-1 flex-col justify-between text-xs sm:text-sm"> |
||||||
|
<span className="p-[10px]"> |
||||||
|
<span className="mb-0.5 block font-semibold">{title}</span> |
||||||
|
<span className="block text-gray-500">{description}</span> |
||||||
|
</span> |
||||||
|
{!isRoadmapAd && ( |
||||||
|
<> |
||||||
|
<span className="sponsor-footer hidden sm:block"> |
||||||
|
Partner Content |
||||||
|
</span> |
||||||
|
<span className="block pb-1 text-center text-[10px] uppercase text-gray-400 sm:hidden"> |
||||||
|
Partner Content |
||||||
|
</span> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</span> |
||||||
|
</a> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,195 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { httpGet, httpPatch } from '../../lib/http'; |
||||||
|
import { sponsorHidden } from '../../stores/page'; |
||||||
|
import { useStore } from '@nanostores/react'; |
||||||
|
import { setViewSponsorCookie } from '../../lib/jwt'; |
||||||
|
import { isMobile } from '../../lib/is-mobile'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { getUrlUtmParams } from '../../lib/browser.ts'; |
||||||
|
import { StickyTopSponsor } from './StickyTopSponsor.tsx'; |
||||||
|
import { BottomRightSponsor } from './BottomRightSponsor.tsx'; |
||||||
|
|
||||||
|
type PageSponsorType = { |
||||||
|
company: string; |
||||||
|
description: string; |
||||||
|
gaLabel: string; |
||||||
|
imageUrl: string; |
||||||
|
pageUrl: string; |
||||||
|
title: string; |
||||||
|
url: string; |
||||||
|
id: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export type StickyTopSponsorType = PageSponsorType & { |
||||||
|
buttonText: string; |
||||||
|
style?: { |
||||||
|
fromColor?: string; |
||||||
|
toColor?: string; |
||||||
|
textColor?: string; |
||||||
|
buttonBackgroundColor?: string; |
||||||
|
buttonTextColor?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
export type BottomRightSponsorType = PageSponsorType; |
||||||
|
|
||||||
|
type V1GetSponsorResponse = { |
||||||
|
bottomRightAd?: BottomRightSponsorType; |
||||||
|
stickyTopAd?: StickyTopSponsorType; |
||||||
|
}; |
||||||
|
|
||||||
|
type PageSponsorsProps = { |
||||||
|
gaPageIdentifier?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
const CLOSE_SPONSOR_KEY = 'sponsorClosed'; |
||||||
|
|
||||||
|
function markSponsorHidden(sponsorId: string) { |
||||||
|
Cookies.set(`${CLOSE_SPONSOR_KEY}-${sponsorId}`, '1', { |
||||||
|
path: '/', |
||||||
|
expires: 1, |
||||||
|
sameSite: 'lax', |
||||||
|
secure: true, |
||||||
|
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh', |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function isSponsorMarkedHidden(sponsorId: string) { |
||||||
|
return Cookies.get(`${CLOSE_SPONSOR_KEY}-${sponsorId}`) === '1'; |
||||||
|
} |
||||||
|
|
||||||
|
export function PageSponsors(props: PageSponsorsProps) { |
||||||
|
const { gaPageIdentifier } = props; |
||||||
|
|
||||||
|
const $isSponsorHidden = useStore(sponsorHidden); |
||||||
|
|
||||||
|
const [stickyTopSponsor, setStickyTopSponsor] = |
||||||
|
useState<StickyTopSponsorType | null>(); |
||||||
|
const [bottomRightSponsor, setBottomRightSponsor] = |
||||||
|
useState<BottomRightSponsorType | null>(); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const foundUtmParams = getUrlUtmParams(); |
||||||
|
|
||||||
|
if (!foundUtmParams.utmSource) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
localStorage.setItem('utm_params', JSON.stringify(foundUtmParams)); |
||||||
|
}, []); |
||||||
|
|
||||||
|
async function loadSponsor() { |
||||||
|
const currentPath = window.location.pathname; |
||||||
|
if ( |
||||||
|
currentPath === '/' || |
||||||
|
currentPath === '/best-practices' || |
||||||
|
currentPath === '/roadmaps' || |
||||||
|
currentPath.startsWith('/guides') || |
||||||
|
currentPath.startsWith('/videos') || |
||||||
|
currentPath.startsWith('/account') || |
||||||
|
currentPath.startsWith('/team/') |
||||||
|
) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const { response, error } = await httpGet<V1GetSponsorResponse>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`, |
||||||
|
{ |
||||||
|
href: window.location.pathname, |
||||||
|
mobile: isMobile() ? 'true' : 'false', |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error) { |
||||||
|
console.error(error); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setStickyTopSponsor(response?.stickyTopAd); |
||||||
|
setBottomRightSponsor(response?.bottomRightAd); |
||||||
|
} |
||||||
|
|
||||||
|
async function logSponsorImpression( |
||||||
|
sponsor: BottomRightSponsorType | StickyTopSponsorType, |
||||||
|
) { |
||||||
|
window.fireEvent({ |
||||||
|
category: 'SponsorImpression', |
||||||
|
action: `${sponsor?.company} Impression`, |
||||||
|
label: |
||||||
|
sponsor?.gaLabel || `${gaPageIdentifier} / ${sponsor?.company} Link`, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
async function clickSponsor( |
||||||
|
sponsor: BottomRightSponsorType | StickyTopSponsorType, |
||||||
|
) { |
||||||
|
const { id: sponsorId, company, gaLabel } = sponsor; |
||||||
|
|
||||||
|
const labelValue = gaLabel || `${gaPageIdentifier} / ${company} Link`; |
||||||
|
|
||||||
|
window.fireEvent({ |
||||||
|
category: 'SponsorClick', |
||||||
|
action: `${company} Redirect`, |
||||||
|
label: labelValue, |
||||||
|
value: labelValue, |
||||||
|
}); |
||||||
|
|
||||||
|
const clickUrl = new URL( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-view-sponsor/${sponsorId}`, |
||||||
|
); |
||||||
|
|
||||||
|
const { response, error } = await httpPatch<{ status: 'ok' }>( |
||||||
|
clickUrl.toString(), |
||||||
|
{ |
||||||
|
mobile: isMobile(), |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
console.error(error); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setViewSponsorCookie(sponsorId); |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
window.setTimeout(loadSponsor); |
||||||
|
}, []); |
||||||
|
|
||||||
|
if ($isSponsorHidden) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
{stickyTopSponsor && !isSponsorMarkedHidden(stickyTopSponsor.id) && ( |
||||||
|
<StickyTopSponsor |
||||||
|
sponsor={stickyTopSponsor} |
||||||
|
onSponsorImpression={() => { |
||||||
|
logSponsorImpression(stickyTopSponsor).catch(console.error); |
||||||
|
}} |
||||||
|
onSponsorClick={() => { |
||||||
|
clickSponsor(stickyTopSponsor).catch(console.error); |
||||||
|
}} |
||||||
|
onSponsorHidden={() => { |
||||||
|
markSponsorHidden(stickyTopSponsor.id); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{bottomRightSponsor && !isSponsorMarkedHidden(bottomRightSponsor.id) && ( |
||||||
|
<BottomRightSponsor |
||||||
|
sponsor={bottomRightSponsor} |
||||||
|
onSponsorClick={() => { |
||||||
|
clickSponsor(bottomRightSponsor).catch(console.error); |
||||||
|
}} |
||||||
|
onSponsorHidden={() => { |
||||||
|
markSponsorHidden(bottomRightSponsor.id); |
||||||
|
}} |
||||||
|
onSponsorImpression={() => { |
||||||
|
logSponsorImpression(bottomRightSponsor).catch(console.error); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,87 @@ |
|||||||
|
import { cn } from '../../lib/classname.ts'; |
||||||
|
import { useScrollPosition } from '../../hooks/use-scroll-position.ts'; |
||||||
|
import { X } from 'lucide-react'; |
||||||
|
import type { StickyTopSponsorType } from './PageSponsors.tsx'; |
||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { isOnboardingStripHidden } from '../../stores/page.ts'; |
||||||
|
|
||||||
|
type StickyTopSponsorProps = { |
||||||
|
sponsor: StickyTopSponsorType; |
||||||
|
|
||||||
|
onSponsorImpression: () => void; |
||||||
|
onSponsorClick: () => void; |
||||||
|
onSponsorHidden: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
const SCROLL_DISTANCE = 100; |
||||||
|
|
||||||
|
export function StickyTopSponsor(props: StickyTopSponsorProps) { |
||||||
|
const { sponsor, onSponsorHidden, onSponsorImpression, onSponsorClick } = |
||||||
|
props; |
||||||
|
|
||||||
|
const { y: scrollY } = useScrollPosition(); |
||||||
|
const [isImpressionLogged, setIsImpressionLogged] = useState(false); |
||||||
|
const [isHidden, setIsHidden] = useState(false); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!sponsor) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// hide the onboarding strip when the sponsor is visible
|
||||||
|
isOnboardingStripHidden.set(true); |
||||||
|
}, [sponsor]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (scrollY < SCROLL_DISTANCE || isImpressionLogged) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsImpressionLogged(true); |
||||||
|
onSponsorImpression(); |
||||||
|
}, [scrollY]); |
||||||
|
|
||||||
|
if (scrollY < SCROLL_DISTANCE || isHidden) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
target="_blank" |
||||||
|
href="https://www.google.com" |
||||||
|
onClick={onSponsorClick} |
||||||
|
className={cn( |
||||||
|
'fixed left-0 right-0 top-0 z-[91] flex min-h-[45px] w-full flex-row items-center justify-center px-14 pb-2 pt-1.5 text-base font-medium text-yellow-950', |
||||||
|
)} |
||||||
|
style={{ |
||||||
|
backgroundImage: `linear-gradient(to bottom, ${sponsor.style?.fromColor}, ${sponsor.style?.toColor})`, |
||||||
|
color: sponsor.style?.textColor, |
||||||
|
}} |
||||||
|
> |
||||||
|
<img className="h-[23px]" src={sponsor.imageUrl} alt={'ad'} /> |
||||||
|
<span className="mx-3 truncate">{sponsor.description}</span> |
||||||
|
<button |
||||||
|
className="flex-truncate rounded-md px-3 py-1 text-sm transition-colors" |
||||||
|
style={{ |
||||||
|
backgroundColor: sponsor.style?.buttonBackgroundColor, |
||||||
|
color: sponsor.style?.buttonTextColor, |
||||||
|
}} |
||||||
|
> |
||||||
|
{sponsor.buttonText} |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="absolute right-5 top-1/2 ml-1 -translate-y-1/2 px-1 py-1 opacity-70 hover:opacity-100" |
||||||
|
onClick={(e) => { |
||||||
|
e.preventDefault(); |
||||||
|
e.stopPropagation(); |
||||||
|
|
||||||
|
setIsHidden(true); |
||||||
|
onSponsorHidden(); |
||||||
|
}} |
||||||
|
> |
||||||
|
<X className="h-4 w-4" strokeWidth={3} /> |
||||||
|
</button> |
||||||
|
</a> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue