ads/public/Ads.js

import customEvent from '../../utils/customEvent';
import InView from '../../utils/classes/InView';
import NativeAd from './NativeAd';
import Cookies from '../../vendor/jscookie';
import '../../vendor/amazon-bidding-helper';

/* global googletag */
/* global GNCA_APS_Helper */
/* global gnAdSettings */
/* global apstag */

/**
 * Module for handling pre-defined ad slots and dynamically generated ad slots.
 *
 * @module Ads
 * @prop {string} gptAdPath - ad unit path
 * @prop {string} states.loaded - CSS class for div with ad loaded
 * @prop {string} states.empty - CSS Class for empty ad div
 * @prop {string} states.hidden - CSS class for hiding an element
 * @prop {Object} ads - all pre-defined ad slots and data
 * @prop {Object} watcher - in view watcher for lazy loading of ads
 */
const Ads = {
	states: {
		loaded: 'c-ad__unit--loaded',
		empty: 'c-ad__unit--empty',
		adUnitFallback: 'c-ad__unit--fallback',
		adFallback: 'c-ad--fallback',
		hidden: 'is-hidden',
	},
	inViewSelector: '.c-ad__unit[data-in-view=true]',
	gptAdPath: '',
	ads: {},
	watcher: null,
	pageLoaded: false,
	bidTimeout: 1200,
	bidEnabled: true,
	overNineteenCookie: 'over-nineteen',

	/**
	 * Scrape global variable gnAdSettings defined on the page to define ad slots,
	 * set up page targeting, and set up listeners for ad loaded events
	 *
	 * @method init
	 */
	init() {
		this.gptAdPath = 'undefined' !== typeof ( gnAdSettings ) && gnAdSettings.adPath ? gnAdSettings.adPath : '/6872/shaw.globalnews.ca';

		// set up in view watcher for lazy load ads
		this.watcher = new InView({
			threshold: 0.1,
			rootMargin: `${window.innerHeight * 0.4}px`,
		}, 'adWatcher' );
		this.watcher.init();

		/* global gnca_settings */
		/* eslint-disable camelcase */
		if ( gnca_settings && gnca_settings.ad_settings ) {
			if ( 'undefined' !== typeof ( gnca_settings.ad_settings.bidding_timeout ) ) {
				this.bidTimeout = gnca_settings.ad_settings.bidding_timeout;
			}
			if ( 'undefined' !== typeof ( gnca_settings.ad_settings.bidding_enabled ) ) {
				this.bidEnabled = gnca_settings.ad_settings.bidding_enabled;
			}
		}
		/* eslint-enable camelcase */

		// Configure google ads and enable googletag services
		googletag.cmd.push( () => {
			googletag.pubads().collapseEmptyDivs();
			googletag.companionAds().setRefreshUnfilledSlots( true );

			// Set page targeting
			[].forEach.call( Object.keys( gnAdSettings.pageTargeting ), ( key ) => {
				googletag.pubads().setTargeting( key, gnAdSettings.pageTargeting[key]);
			});

			if ( 'undefined' !== typeof ( window.ageGateRequired ) && Cookies.get( this.overNineteenCookie ) ) {
				googletag.pubads().setTargeting( 'userOverNineteen', 'true' );
			}

			googletag.pubads().collapseEmptyDivs();
			googletag.companionAds().setRefreshUnfilledSlots( true );

			// Batch SRA implemented
			googletag.pubads().enableSingleRequest();

			// Disable initial load
			googletag.pubads().disableInitialLoad();
			googletag.enableServices();
		});

		// Slot render ended listener
		googletag.cmd.push( () => {
			googletag.pubads().addEventListener( 'slotRenderEnded', ( event ) => {
				const divId = event.slot.getSlotElementId();
				const adDiv = document.querySelector( `#${divId}` );
				if ( adDiv ) {
					adDiv.classList.add( this.states.loaded );
					adDiv.dataset.loadTime = ( new Date() ).getTime();

					let [width, height] = [0, 0];
					if ( event.isEmpty ) {
						let adUnitCss = this.states.empty;
						let adCss = this.states.hidden;

						// Only trigger NativeAd.collapse manually if the ad slot isn't a NativeAd.
						if ( adDiv.dataset.adFallbackType && ! adDiv.dataset.isNativeAd ) {
							adUnitCss = this.states.adUnitFallback;
							adCss = this.states.adFallback;
							NativeAd.collapseAd( adDiv );
						}

						adDiv.classList.add( adUnitCss );
						if ( adDiv.parentNode ) {
							adDiv.parentNode.classList.add( adCss );
						}
					} else if ( event.size ) {
						[width, height] = event.size;
						adDiv.dataset.width = width;
						adDiv.dataset.height = height;
					}

					const parentComponent = document.querySelector( `[data-ad="${divId}"]` );
					if ( parentComponent ) {
						parentComponent.dataset.adEmpty = event.isEmpty.toString();
					}

					this.broadcastLoaded( adDiv, {
						isEmpty: event.isEmpty,
						size: [width, height],
					});
				}
			});
		});

		// Create ads defined in gnAdSettings.ads, including lazy and non-lazy ads.
		if ( 'undefined' !== typeof ( gnAdSettings )
			&& 'object' === typeof ( gnAdSettings.ads ) ) {
			[].forEach.call( gnAdSettings.ads, ( ad ) => {
				this.create( ad );
			});
		}

		// Request non-lazy load ads
		googletag.cmd.push( () => {
			this.requestOnPageLoad();
		});
	},

	/**
	 * Request all non-lazy and in view ads on page load
	 *
	 * @method requestOnPageLoad
	 */
	requestOnPageLoad() {
		this.pageLoaded = true;

		// Rely on this.display() to make ad request via bidding
		const ads = this.getAds( '', { lazy: false });
		const $inViewAds = document.querySelectorAll( this.inViewSelector );
		[].forEach.call( $inViewAds, ( $ad ) => {
			const adId = $ad.getAttribute( 'id' );
			ads.push( this.ads[adId]);
		});

		this.display( ads );
	},

	/**
	 * Register ad related data to be retrieved for ad bidding.
	 * Add inView event listener to trigger lazy load ads.
	 *
	 * @method registerAd
	 * @param {Object} ad - div id, slot defined via googletag, ad size mapping for responsive ads
	 */
	register( ad ) {
		if ( ad && ad.id ) {
			this.ads[ad.id] = ad;
			if ( ad.lazy ) {
				const $targetDiv = document.querySelector( `#${ad.id}` );
				if ( $targetDiv ) {
					$targetDiv.addEventListener( customEvent.IN_VIEW, ( evt ) => {
						if ( evt && evt.detail && evt.detail.isInView ) {
							evt.detail.caller.stopWatching( $targetDiv );
							// If initial ad request hasn't been made yet,
							// do not trigger display(), allow iniitial request
							// to load all non-lazy and in-view ads.
							if ( this.initialRequestMade() ) {
								this.display( ad );
							}
						}
					});

					// This fires an IN_VIEW event right away if $targetDiv is already in view.
					this.watcher.startWatching( $targetDiv );
				}
			}
		}
	},

	/**
	 * Call this function when an ad unit has been loaded
	 *
	 * @method broadcastLoaded
	 * @param {Object} $el - an element reference to the ad div that is ready to load
	 */
	broadcastLoaded( $el, data ) {
		customEvent.fire( $el, customEvent.LOADED, data );
	},

	/**
	 * Get all registered ad data and ad slot.
	 * @method getAds
	 * @param {String} attr - optional. When provided, retrieve the property of each ad
	 * @param {Object} filter - optional. Filters results by key/value specified in $filter
	 * @returns {Array} - All registered ads or an array of attributes of register ads
	 */
	getAds( attr, filter ) {
		let result = this.ads ? Object.keys( this.ads ).map( key => this.ads[key]) : [];

		if ( filter ) {
			const filterKeys = Object.keys( filter );
			result = result.filter( ( ad ) => {
				let include = true;
				[].forEach.call( filterKeys, ( k ) => {
					include = include && ( filter[k] === ad[k]);
				});
				return include;
			});
		}

		if ( attr ) {
			let values = Object.keys( result ).map( key => result[key]);
			values = values.map( ( ad ) => {
				if ( Object.keys( ad ).indexOf( attr ) >= 0 ) {
					return ad[attr];
				}
				return false;
			});
			result = values.filter( val => val );
		}

		return result;
	},

	/**
	 * Display ads via ad bidding or regular ad refresh
	 *
	 * @method display
	 * @param {Array} ads - An array of ad objects
	 */
	display( ads ) {
		const thatGptAdPath = this.gptAdPath;
		const adsToDisplay = ( 'undefined' === typeof ( ads.length ) ) ? [ads] : ads;
		const gptSlots = [];
		const biddingSlots = [];

		for ( let i = 0; i < adsToDisplay.length; i += 1 ) {
			const ad = adsToDisplay[i];
			const $adDiv = document.querySelector( `#${ad.id}` );

			// Ignore ad if it isn't physically visible,
			// unless it's a out of page slot (wallpaper / catfish).
			if ( ad.outOfPage
				|| ( $adDiv.parentNode
					&& 'none' !== getComputedStyle( $adDiv.parentNode ).getPropertyValue( 'display' ) ) ) {
				$adDiv.dataset.adInitialized = true;

				if ( ad.biddable ) {
					/* eslint-disable camelcase */
					const biddingSizes = ad.mappingObj && ( 'undefined' !== typeof ( GNCA_APS_Helper ) ) ? GNCA_APS_Helper.parseSizeMappings( ad.mappingObj ) : JSON.parse( ad.sizes );
					/* eslint-enable camelcase */

					biddingSlots.push({
						slotID: ad.id,
						slotName: `${thatGptAdPath}/${ad.id}`,
						sizes: biddingSizes,
					});
				}

				gptSlots.push( ad.slot );
			}
		}

		if ( this.amazonBiddingSupported() && biddingSlots.length > 0 ) {
			apstag.fetchBids({
				slots: biddingSlots,
				timeout: this.bidTimeout,
			}, ( bids ) => { // eslint-disable-line no-unused-vars
				// bids information returned from Amazon, might be useful in the future
				googletag.cmd.push( () => {
					apstag.setDisplayBids();
					googletag.pubads().refresh( gptSlots );
				});
			});
		} else {
			googletag.cmd.push( () => {
				googletag.pubads().refresh( gptSlots );
			});
		}
	},

	/**
	 * Refresh ads specified by an array of ad objects.
	 *
	 * Note:
	 * Currently, all we have to do is call display(), but in case anything changes in the future
	 * where refreshing of ads need to behave differently we can update this function.
	 *
	 * @method refresh
	 * @param {Array} ads - An array of ad objects
	 */
	refresh( ads ) {
		this.display( ads );
	},

	/**
	 * Create dynamic ads that are not defined in the markup
	 *
	 * @method createAd
	 * @param {Object} ad - ad data used for creating an ad
	 */
	create( ad ) {
		const adData = ad;

		googletag.cmd.push( () => {
			let slot = false;

			const $adDiv = document.querySelector( `#${adData.id}` );
			// Bail if ad div does not exist
			if ( ! $adDiv ) {
				return;
			}

			if ( $adDiv.dataset.isNativeAd ) {
				NativeAd.setup( $adDiv );
			}

			if ( ! ad.outOfPage ) {
				let sizes = JSON.parse( ad.sizes );
				if ( ad.fluid ) {
					if ( 'number' === typeof ( sizes[0]) ) {
						sizes = [sizes];
					}
					sizes.push( 'fluid' );
				}

				slot = googletag.defineSlot( this.gptAdPath, sizes, ad.id )
					.addService( googletag.pubads() );
			} else {
				slot = googletag.defineOutOfPageSlot( this.gptAdPath, ad.id )
					.addService( googletag.pubads() );
			}

			if ( ad.companion ) {
				slot.addService( googletag.companionAds() );
			}

			const { mapping } = ad;
			if ( mapping ) {
				const mappingObj = googletag.sizeMapping();
				[].forEach.call( Object.keys( mapping ), ( bp ) => {
					mappingObj.addSize( JSON.parse( bp ), JSON.parse( mapping[bp]) );
				});

				adData.mappingObj = mappingObj.build();
				slot.defineSizeMapping( adData.mappingObj );
			}

			const { targeting } = ad;
			if ( targeting ) {
				[].forEach.call( Object.keys( targeting ), ( key ) => {
					slot.setTargeting( key, targeting[key]);
				});
			}

			adData.slot = slot;

			// Ensure in view event listener in place before watching for in view.
			this.register( adData );
		});
	},

	/**
	 * Check if apstag amazon bidding is available
	 *
	 * @method amazonBiddingSupported
	 */
	amazonBiddingSupported() {
		let result = false;
		if ( this.bidEnabled && apstag && apstag.targetingKeys ) {
			const keys = apstag.targetingKeys();

			// keys return empty array if apstag script failed to load
			result = keys && keys.length > 0;
		}
		return result;
	},

	/**
	 * Check if initial ad request made.
	 *
	 * @method initialRequestMade
	 */
	initialRequestMade() {
		return this.pageLoaded;
	},
};

export default Ads;