diff --git a/src/components/PageSponsors/BottomRightSponsor.tsx b/src/components/PageSponsors/BottomRightSponsor.tsx new file mode 100644 index 000000000..f322cddce --- /dev/null +++ b/src/components/PageSponsors/BottomRightSponsor.tsx @@ -0,0 +1,102 @@ +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 ( + + { + e.preventDefault(); + setIsHidden(true); + onSponsorHidden(); + }} + > + + + + Sponsor Banner + + + + {title} + {description} + + {!isRoadmapAd && ( + <> + + Partner Content + + + Partner Content + + + )} + + + ); +} diff --git a/src/components/PageSponsors/PageSponsors.tsx b/src/components/PageSponsors/PageSponsors.tsx new file mode 100644 index 000000000..f92a1a70f --- /dev/null +++ b/src/components/PageSponsors/PageSponsors.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react'; +import { httpGet, httpPatch } 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'; +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 & {}; +export type BottomRightSponsorType = PageSponsorType; + +type V1GetSponsorResponse = { + bottomRightAd?: PageSponsorType; + stickyTopAd?: PageSponsorType; +}; + +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(); + const [bottomRightSponsor, setBottomRightSponsor] = + useState(); + + 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( + `${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 ( +
+ {stickyTopSponsor && !isSponsorMarkedHidden(stickyTopSponsor.id) && ( + { + logSponsorImpression(stickyTopSponsor).catch(console.error); + }} + onSponsorClick={() => { + clickSponsor(stickyTopSponsor).catch(console.error); + }} + onSponsorHidden={() => { + markSponsorHidden(stickyTopSponsor.id); + }} + /> + )} + {bottomRightSponsor && !isSponsorMarkedHidden(bottomRightSponsor.id) && ( + { + clickSponsor(bottomRightSponsor).catch(console.error); + }} + onSponsorHidden={() => { + markSponsorHidden(bottomRightSponsor.id); + }} + onSponsorImpression={() => { + logSponsorImpression(bottomRightSponsor).catch(console.error); + }} + /> + )} +
+ ); +} diff --git a/src/components/PageSponsors/StickyTopSponsor.tsx b/src/components/PageSponsors/StickyTopSponsor.tsx new file mode 100644 index 000000000..8e2e5fc2e --- /dev/null +++ b/src/components/PageSponsors/StickyTopSponsor.tsx @@ -0,0 +1,73 @@ +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'; + +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 (scrollY < SCROLL_DISTANCE || isImpressionLogged) { + return; + } + + setIsImpressionLogged(true); + onSponsorImpression(); + }, [scrollY]); + + if (scrollY < SCROLL_DISTANCE || isHidden) { + return null; + } + + return ( + + {'ad'} + + Register for our free cloud workshop + + + + + ); +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index d5f31f257..9983b3373 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -8,7 +8,7 @@ import Navigation from '../components/Navigation/Navigation.astro'; import OpenSourceBanner from '../components/OpenSourceBanner.astro'; import { PageProgress } from '../components/PageProgress'; import { Toaster } from '../components/Toast'; -import { PageSponsor } from '../components/PageSponsor'; +import { PageSponsors } from '../components/PageSponsors/PageSponsors'; import { siteConfig } from '../lib/config'; import '../styles/global.css'; import { PageVisit } from '../components/PageVisit/PageVisit'; @@ -184,7 +184,8 @@ const gaPageIdentifier = Astro.url.pathname -