UNPKG

@flightstats/react-gpt

Version:

A react display ad component using Google Publisher Tag

792 lines (730 loc) 24.7 kB
/* eslint-disable react/sort-comp */ import React, {Component} from "react"; import PropTypes from "prop-types"; import ReactDOM from "react-dom"; import invariant from "invariant"; import deepEqual from "deep-equal"; import hoistStatics from "hoist-non-react-statics"; import Events from "./Events"; import filterPropsSimple from "./utils/filterProps"; import {createManager, pubadsAPI} from "./createManager"; /** * An Ad Component using Google Publisher Tags. * This component should work standalone w/o context. * https://developers.google.com/doubleclick-gpt/ * * @module Bling * @class Bling * @fires Bling#Events.READY * @fires Bling#Events.SLOT_RENDER_ENDED * @fires Bling#Events.IMPRESSION_VIEWABLE * @fires Bling#Events.SLOT_VISIBILITY_CHANGED * @fires Bling#Events.SLOT_LOADED */ class Bling extends Component { static propTypes = { /** * An optional string to be used as container div id. * * @property id */ id: PropTypes.string, /** * An optional string indicating ad unit path which will be used * to create an ad slot. * * @property adUnitPath */ adUnitPath: PropTypes.string.isRequired, /** * An optional object which includes ad targeting key-value pairs. * * @property targeting */ targeting: PropTypes.object, /** * An optional prop to specify the ad slot size which accepts [googletag.GeneralSize](https://developers.google.com/doubleclick-gpt/reference#googletag.GeneralSize) as a type. * This will be preceded by the sizeMapping if specified. * * @property slotSize */ slotSize: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), /** * An optional array of object which contains an array of viewport size and slot size. * This needs to be set if the ad needs to serve different ad sizes per different viewport sizes (responsive ad). * Setting the `slot` to any dimension that's not configured in DFP results in rendering an empty ad. * The ad slot size which is provided for the viewport size of [0, 0] will be used as default ad size if none of viewport size matches. * * https://support.google.com/dfp_premium/answer/3423562?hl=en * * e.g. * * sizeMapping={[ * {viewport: [0, 0], slot: [320, 50]}, * {viewport: [768, 0], slot: [728, 90]} * ]} * * @property sizeMapping */ sizeMapping: PropTypes.arrayOf( PropTypes.shape({ viewport: PropTypes.array, slot: PropTypes.array }) ), /** * An optional flag to indicate whether an ad slot should be out-of-page slot. * * @property outOfPage */ outOfPage: PropTypes.bool, /** * An optional flag to indicate whether companion ad service should be enabled for the ad. * If an object is passed, it takes as a configuration expecting `enableSyncLoading` or `refreshUnfilledSlots`. * * @property companionAdService */ companionAdService: PropTypes.oneOfType([ PropTypes.bool, PropTypes.object ]), /** * An optional HTML content for the slot. If specified, the ad will render with the HTML content using content service. * * @property content */ content: PropTypes.string, /** * An optional click through URL. If specified, any landing page URL associated with the creative that is served is overridden. * * @property clickUrl */ clickUrl: PropTypes.string, /** * An optional string or an array of string which specifies a page-level ad category exclusion for the given label name. * * @property categoryExclusion */ categoryExclusion: PropTypes.oneOfType([ PropTypes.string, PropTypes.array ]), /** * An optional map of key-value pairs for an AdSense attribute on a particular ad slot. * see the list of supported key value: https://developers.google.com/doubleclick-gpt/adsense_attributes#adsense_parameters.googletag.Slot * * @property attributes */ attributes: PropTypes.object, /** * An optional flag to indicate whether an empty ad should be collapsed or not. * * @property collapseEmptyDiv */ collapseEmptyDiv: PropTypes.oneOfType([ PropTypes.bool, PropTypes.array ]), /** * An optional flag to indicate whether ads in this slot should be forced to be rendered using a SafeFrame container. * * @property forceSafeFrame */ forceSafeFrame: PropTypes.bool, /** * An optional object to set the slot-level preferences for SafeFrame configuration. * * @property safeFrameConfig */ safeFrameConfig: PropTypes.object, /** * An optional event handler function for `googletag.events.SlotRenderEndedEvent`. * * @property onSlotRenderEnded */ onSlotRenderEnded: PropTypes.func, /** * An optional event handler function for `googletag.events.ImpressionViewableEvent`. * * @property onImpressionViewable */ onImpressionViewable: PropTypes.func, /** * An optional event handler function for `googletag.events.slotVisibilityChangedEvent`. * * @property onSlotVisibilityChanged */ onSlotVisibilityChanged: PropTypes.func, /** * An optional event handler function for `googletag.events.SlotOnloadEvent`. * * @property onSlotOnload */ onSlotOnload: PropTypes.func, /** * An optional flag to indicate whether an ad should only render when it's fully in the viewport area. * * @property renderWhenViewable */ renderWhenViewable: PropTypes.bool, /** * An optional number to indicate how much percentage of an ad area needs to be in a viewable area before rendering. * Acceptable range is between 0 and 1. * * @property viewableThreshold */ viewableThreshold: PropTypes.number, /** * An optional call back function to notify when the script is loaded. * * @property onScriptLoaded */ onScriptLoaded: PropTypes.func, /** * An optional call back function to notify when the media queries on the document change. * * @property onMediaQueryChange */ onMediaQueryChange: PropTypes.func, /** * An optional object to be applied as `style` props to the container div. * * @property style */ style: PropTypes.object }; /** * An array of prop names which can reflect to the ad by calling `refresh`. * * @property refreshableProps * @static */ static refreshableProps = [ "targeting", "sizeMapping", "clickUrl", "categoryExclusion", "attributes", "collapseEmptyDiv", "companionAdService", "forceSafeFrame", "safeFrameConfig" ]; /** * An array of prop names which requires to create a new ad slot and render as a new ad. * * @property reRenderProps * @static */ static reRenderProps = ["adUnitPath", "slotSize", "outOfPage", "content"]; /** * An instance of ad manager. * * @property _adManager * @private * @static */ static _adManager = createManager(); /** * * @property * @private * @static */ static _config = { /** * An optional string for GPT seed file url to override. */ seedFileUrl: "//www.googletagservices.com/tag/js/gpt.js", /** * An optional flag to indicate whether an ad should only render when it's fully in the viewport area. Default is `true`. */ renderWhenViewable: true, /** * An optional number to indicate how much percentage of an ad area needs to be in a viewable area before rendering. Default value is 0.5. * Acceptable range is between 0 and 1. */ viewableThreshold: 0.5, /** * An optional function to create an object with filtered current props and next props for a given keys to perform equality check. */ filterProps: filterPropsSimple, /** * An optional function for the filtered props and the next props to perform equality check. */ propsEqual: deepEqual }; static on(eventType, cb) { Bling._on("on", eventType, cb); } static once(eventType, cb) { Bling._on("once", eventType, cb); } static removeListener(...args) { Bling._adManager.removeListener(...args); } static removeAllListeners(...args) { Bling._adManager.removeAllListeners(...args); } static _on(fn, eventType, cb) { if (typeof cb !== "function") { return; } if (eventType === Events.READY && Bling._adManager.isReady) { cb.call(Bling._adManager, Bling._adManager.googletag); } else { Bling._adManager[fn](eventType, cb); } } static configure(config = {}) { Bling._config = { ...Bling._config, ...config }; } /** * Returns the GPT version. * * @method getGPTVersion * @returns {Number|boolean} a version or false if GPT is not yet ready. * @static */ static getGPTVersion() { return Bling._adManager.getGPTVersion(); } /** * Returns the Pubads Service version. * * @method getPubadsVersion * @returns {Number|boolean} a version or false if Pubads Service is not yet ready. * @static */ static getPubadsVersion() { return Bling._adManager.getPubadsVersion(); } /** * Sets a flag to indicate whether the correlator value should always be same across the ads in the page or not. * * @method syncCorrelator * @param {boolean} value * @static */ static syncCorrelator(value) { Bling._adManager.syncCorrelator(value); } /** * Trigger re-rendering of all the ads. * * @method render * @static */ static render() { Bling._adManager.renderAll(); } /** * Refreshes all the ads in the page with a new correlator value. * * @param {Array} slots An array of ad slots. * @param {Object} options You can pass `changeCorrelator` flag. * @static */ static refresh(slots, options) { Bling._adManager.refresh(slots, options); } /** * Clears the ads for the specified ad slots, if no slots are provided, all the ads will be cleared. * * @method clear * @param {Array} slots An optional array of slots to clear. * @static */ static clear(slots) { Bling._adManager.clear(slots); } /** * Updates the correlator value for the next ad request. * * @method updateCorrelator * @static */ static updateCorrelator() { Bling._adManager.updateCorrelator(); } static set testManager(testManager) { invariant(testManager, "Pass in createManagerTest to mock GPT"); Bling._adManager = testManager; } state = { scriptLoaded: false, inViewport: false }; get adSlot() { return this._adSlot; } get viewableThreshold() { return this.props.viewableThreshold >= 0 ? this.props.viewableThreshold : Bling._config.viewableThreshold; } componentDidMount() { Bling._adManager.addInstance(this); Bling._adManager .load(Bling._config.seedFileUrl) .then(this.onScriptLoaded.bind(this)) .catch(this.onScriptError.bind(this)); } componentWillReceiveProps(nextProps) { const {propsEqual} = Bling._config; const {sizeMapping} = this.props; if ( (nextProps.sizeMapping || sizeMapping) && !propsEqual(nextProps.sizeMapping, sizeMapping) ) { Bling._adManager.removeMQListener(this, nextProps); } } shouldComponentUpdate(nextProps, nextState) { // if adUnitPath changes, need to create a new slot, re-render // otherwise, just refresh const {scriptLoaded, inViewport} = nextState; const notInViewport = this.notInViewport(nextProps, nextState); const inViewportChanged = this.state.inViewport !== inViewport; const isScriptLoaded = this.state.scriptLoaded !== scriptLoaded; // Exit early for visibility change, before executing deep equality check. if (notInViewport) { return false; } else if (inViewportChanged) { return true; } const {filterProps, propsEqual} = Bling._config; const refreshableProps = filterProps( Bling.refreshableProps, this.props, nextProps ); const reRenderProps = filterProps( Bling.reRenderProps, this.props, nextProps ); const shouldRender = !propsEqual( reRenderProps.props, reRenderProps.nextProps ); const shouldRefresh = !shouldRender && !propsEqual(refreshableProps.props, refreshableProps.nextProps); if (shouldRefresh) { this.configureSlot(this._adSlot, nextProps); } if (Bling._adManager._syncCorrelator) { if (shouldRefresh) { Bling._adManager.refresh(); } else if (shouldRender || isScriptLoaded) { Bling._adManager.renderAll(); } } else { if (shouldRefresh) { this.refresh(); return false; } if (shouldRender || isScriptLoaded) { return true; } } return false; } componentDidUpdate() { if (this.notInViewport(this.props, this.state)) { return; } if (this._divId) { // initial render will enable pubads service before any ad renders // so taken care of by the manager if (Bling._adManager._initialRender) { Bling._adManager.render(); } else { this.renderAd(); } } } componentWillUnmount() { Bling._adManager.removeInstance(this); if (this._adSlot) { Bling._adManager.googletag.destroySlots([this._adSlot]); this._adSlot = null; } } onScriptLoaded() { const {onScriptLoaded} = this.props; if (this.getRenderWhenViewable()) { this.foldCheck(); } this.setState({scriptLoaded: true}, onScriptLoaded); // eslint-disable-line react/no-did-mount-set-state } onScriptError(err) { console.warn( `Ad: Failed to load gpt for ${Bling._config.seedFileUrl}`, err ); } getRenderWhenViewable(props = this.props) { return props.renderWhenViewable !== undefined ? props.renderWhenViewable : Bling._config.renderWhenViewable; } foldCheck() { if (this.state.inViewport) { return; } let slotSize = this.getSlotSize(); if (Array.isArray(slotSize) && Array.isArray(slotSize[0])) { slotSize = slotSize[0]; } if ( slotSize === "fluid" || (Array.isArray(slotSize) && slotSize[0] === "fluid") ) { slotSize = [0, 0]; } const inViewport = Bling._adManager.isInViewport( ReactDOM.findDOMNode(this), slotSize, this.viewableThreshold ); if (inViewport) { this.setState({inViewport: true}); } } defineSizeMapping(adSlot, sizeMapping) { if (sizeMapping) { Bling._adManager.addMQListener(this, this.props); const sizeMappingArray = sizeMapping .reduce((mapping, size) => { return mapping.addSize(size.viewport, size.slot); }, Bling._adManager.googletag.sizeMapping()) .build(); adSlot.defineSizeMapping(sizeMappingArray); } } setAttributes(adSlot, attributes) { // no clear method, attempting to clear existing attributes before setting new ones. const attributeKeys = adSlot.getAttributeKeys(); attributeKeys.forEach(key => { adSlot.set(key, null); }); if (attributes) { Object.keys(attributes).forEach(key => { adSlot.set(key, attributes[key]); }); } } setTargeting(adSlot, targeting) { adSlot.clearTargeting(); if (targeting) { Object.keys(targeting).forEach(key => { adSlot.setTargeting(key, targeting[key]); }); } } addCompanionAdService(serviceConfig, adSlot) { const companionAdsService = Bling._adManager.googletag.companionAds(); adSlot.addService(companionAdsService); if (typeof serviceConfig === "object") { if (serviceConfig.hasOwnProperty("enableSyncLoading")) { companionAdsService.enableSyncLoading(); } if (serviceConfig.hasOwnProperty("refreshUnfilledSlots")) { companionAdsService.setRefreshUnfilledSlots( serviceConfig.refreshUnfilledSlots ); } } } getSlotSize() { const { slotSize: origSlotSize, sizeMapping: origSizeMapping } = this.props; let slotSize; if (origSlotSize) { slotSize = origSlotSize; } else if (origSizeMapping) { const sizeMapping = origSizeMapping; slotSize = sizeMapping[0] && sizeMapping[0].slot; } return slotSize; } renderAd() { this.defineSlot(); this.display(); } notInViewport(props = this.props, state = this.state) { const {inViewport} = state; return this.getRenderWhenViewable(props) && !inViewport; } defineSlot() { const {adUnitPath, outOfPage} = this.props; const divId = this._divId; const slotSize = this.getSlotSize(); if (!this._adSlot) { if (outOfPage) { this._adSlot = Bling._adManager.googletag.defineOutOfPageSlot( adUnitPath, divId ); } else { this._adSlot = Bling._adManager.googletag.defineSlot( adUnitPath, slotSize || [], divId ); } } this.configureSlot(this._adSlot); } configureSlot(adSlot, props = this.props) { const { sizeMapping, attributes, targeting, companionAdService, categoryExclusion, collapseEmptyDiv, safeFrameConfig, content, clickUrl, forceSafeFrame } = props; this.defineSizeMapping(adSlot, sizeMapping); if (collapseEmptyDiv !== undefined) { if (Array.isArray(collapseEmptyDiv)) { adSlot.setCollapseEmptyDiv.call(adSlot, ...collapseEmptyDiv); } else { adSlot.setCollapseEmptyDiv(collapseEmptyDiv); } } // Overrides click url if (clickUrl) { adSlot.setClickUrl(clickUrl); } // Sets category exclusion if (categoryExclusion) { let exclusion = categoryExclusion; if (typeof exclusion === "string") { exclusion = [exclusion]; } adSlot.clearCategoryExclusions(); exclusion.forEach(item => { adSlot.setCategoryExclusion(item); }); } // Sets AdSense attributes this.setAttributes(adSlot, attributes); // Sets custom targeting parameters this.setTargeting(adSlot, targeting); if (safeFrameConfig) { adSlot.setSafeFrameConfig(safeFrameConfig); } if (forceSafeFrame) { adSlot.setForceSafeFrame(forceSafeFrame); } // Enables companion ad service if (companionAdService) { this.addCompanionAdService(companionAdService, adSlot); } // GPT checks if the same service is already added. if (content) { adSlot.addService(Bling._adManager.googletag.content()); } else { adSlot.addService(Bling._adManager.googletag.pubads()); } } display() { const {content} = this.props; const divId = this._divId; const adSlot = this._adSlot; if (content) { Bling._adManager.googletag.content().setContent(adSlot, content); } else { if ( !Bling._adManager._disableInitialLoad && !Bling._adManager._syncCorrelator ) { Bling._adManager.updateCorrelator(); } Bling._adManager.googletag.display(divId); if ( Bling._adManager._disableInitialLoad && !Bling._adManager._initialRender ) { this.refresh(); } } } clear() { const adSlot = this._adSlot; if (adSlot && adSlot.hasOwnProperty("getServices")) { // googletag.ContentService doesn't clear content const services = adSlot.getServices(); if (this._divId && services.some(s => !!s.setContent)) { document.getElementById(this._divId).innerHTML = ""; return; } Bling._adManager.clear([adSlot]); } } refresh(options) { const adSlot = this._adSlot; if (adSlot) { this.clear(); Bling._adManager.refresh([adSlot], options); } } render() { const {scriptLoaded} = this.state; const {id, outOfPage, style} = this.props; const shouldNotRender = this.notInViewport(this.props, this.state); if (!scriptLoaded || shouldNotRender) { let slotSize = this.getSlotSize(); if (!outOfPage) { invariant( slotSize, "Either 'slotSize' or 'sizeMapping' prop needs to be set." ); } if (Array.isArray(slotSize) && Array.isArray(slotSize[0])) { slotSize = slotSize[0]; } // https://developers.google.com/doubleclick-gpt/reference?hl=en#googletag.NamedSize if ( slotSize === "fluid" || (Array.isArray(slotSize) && slotSize[0] === "fluid") ) { slotSize = ["auto", "auto"]; } const emptyStyle = slotSize && { width: slotSize[0], height: slotSize[1] }; // render node element instead of script element so that `inViewport` check works. return <div style={emptyStyle} />; } // clear the current ad if exists this.clear(); if (this._adSlot) { Bling._adManager.googletag.destroySlots([this._adSlot]); this._adSlot = null; } this._divId = id || Bling._adManager.generateDivId(); return <div id={this._divId} style={style} />; } } // proxy pubads API through Bling export default hoistStatics( Bling, pubadsAPI.reduce((api, method) => { api[method] = (...args) => Bling._adManager.pubadsProxy({method, args}); return api; }, {}) );