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