/** * Represents an individual Youneeq recommendation instance. * * A YouneeqHandler instance is automatically created for each element with the ID or class * "youneeq", or of the "youneeq-section" type. Parameters for each YouneeqHandler instance can be * passed as data attributes on the containing element, and must be prefixed with "data-yq-". * Some parameters can have multiple values if they are separated by "|". For example: * *
* * Recognized parameters include: * * suggest-count: Number of articles to display. * suggest-function: Name of a function that gathers suggest parameters and returns it as an object. * Either suggest-function or suggest-count must be defined * in order for recommendations to be returned. * suggest-categories: Category filter for recommended articles. Accepts multiple values. * suggest-domains: Domain filter for recommended articles. * Can be "true", "false", or a list of domains. Accepts multiple values. * suggest-date-start: Starting date for date filter. * suggest-date-end: Ending date for date filter. * suggest-panel-custom: Defines article metadata fields to be returned. Accepts multiple values. * * observe: Indicates that the article should be observed. Can be "true" or "false". * observe-function: Name of a function that gathers article metadata and returns it as an object. * Either observe-function or observe and content-id must be defined * in order for articles to be observed. * observe-title: Title of the article. * observe-image: Article image URL. * observe-description: Article description. * observe-date: Article publish date. * observe-categories: Article categories. Accepts multiple values. * observe-tags: Article tags. Accepts multiple values. * * content-id: Content ID of the current article. * Content ID can also be provided through suggest-function or observe-function. * domain: Indicates the current site's domain name. * alt-href: Page URL. * display-function: Name of a function that accepts "response" and "tags" parameters * and generates recommendation HTML. * ajax-display-function: * features: List of additional behaviours to be assigned to this instance. * Recognized features include "no-google-analytics", "gigya", and "infinite-scroll". * Accepts multiple values. * priority: Priority of the recommendation element. * Elements with a higher priority will process first. * Defaults to 0 if not set. * * @version 3.0.6 * @since 3.0.6 Added initialization arguments to constructor. * @since 3.0.5 Improved height triggering for infinite scroll handling. * @since 3.0.4 Added new GA override ability. * @since 3.0.3 Improved infinite scroll handling. * @since 3.0.2 Removed GA tracker auto-detection. * @since 3.0.1 Added manual GA tracker settings. * @since 3.0.0 Version number now matches WordPress plugin version. * Refactored click tracking, now require HTTPS for yqmin script, * added error check to constructor. * @since 1.2.0 Refactored observe metadata auto-detection. * @since 1.1.0 Refactored feature detection. * @since 1.0.0 */ class YouneeqHandler { public box: HTMLElement; public id_num: number; public content_id: string; public alt_href: string; public content_type: string; public suggest: object; public observe: object; public tracking: object; public scrolling: object; public features: string[]; public is_loading: boolean; public use_scroll_handler: boolean = false; public scroll_ready: boolean = true; public static instances: YouneeqHandler[] = []; /** * Creates and registers a new YouneeqHandler. * * @since 3.0.6 Added args parameter. * @since 3.0.0 Added error check. * @since 1.0.0 * * @param {HTMLElement} box The HTML element on the page that will * act as the recommendation container. * @param {boolean} auto If true, this object will request articles * immediately after initialization is complete. * @param {object|null} args Initialization arguments. Will be overridden by data attributes. */ public constructor( box: HTMLElement, auto: boolean = true, args: object|null = null ) { if ( !this || !this.init_all_data ) { throw `YouneeqHandler() must be called with "new"`; } this.box = box; this.id_num = YouneeqHandler.instances.length; this.content_id = ``; this.alt_href = ``; this.content_type = `article`; this.suggest = {}; this.observe = {}; this.tracking = {}; this.scrolling = {}; this.features = []; this.is_loading = false; // Initialize suggest/observe/etc arguments and push to instances array. YouneeqHandler.instances.push( auto ? this.init_all_data( args ).request( [ `first`, `observe` ] ) : this.init_all_data( args) ); } /** * Generates YouneeqHandler instances for each valid HTML element on the page. * * @since 3.0.0 Always use HTTPS for yqmin script. * @since 1.2.0 Can now disable automatic loading of helper scripts. * @since 1.0.0 * * @param {boolean} include_scripts If additional helper scripts should be * loaded from the Youneeq API site. */ public static generate( include_scripts: boolean = true ): void { // Get and sort recommender elements. let youneeq_boxes = jQuery( `#youneeq, .youneeq, youneeq-section` ).get().sort( function( a, b ) { let prio_a = jQuery( a ).attr( `data-yq-priority` ), prio_b = jQuery( b ).attr( `data-yq-priority` ); prio_a = prio_a ? parseInt( prio_a ) : 0; prio_b = prio_b ? parseInt( prio_b ) : 0; if ( prio_a > prio_b ) { return 1; } else if ( prio_a < prio_b ) { return -1; } return 0; }); // Create handlers for recommender elements. let builder; if ( youneeq_boxes.length ) { builder = function() { Yq.onready( function() { youneeq_boxes.forEach( e => { let box = jQuery( e ), handler = new YouneeqHandler( e, ! box.hasClass( `yq-no-auto` ) ); box.data( `youneeqHandler`, handler ); }); }); } } else { builder = jQuery.noop; } // Fetch helper scripts. if ( include_scripts ) { let scripts = []; if ( !( 'JSON' in window ) ) { scripts.push( jQuery.getScript( `//api.youneeq.ca/scripts/json2.js` ) ); } if ( !( 'jzTimezoneDetector' in window ) ) { scripts.push( jQuery.getScript( `//api.youneeq.ca/scripts/detect_timezone.js` ) ); } if ( !( 'Yq' in window ) ) { scripts.push( jQuery.getScript( `https://api.youneeq.ca/app/yqmin` ) ); } if ( scripts.length ) { jQuery.when.apply( jQuery, scripts ).then( builder ); } else { builder(); } } else { builder(); } } /** * Handle story link clicks. Need to handle left click differently than right/middle clicks * since GA data has to finish sending before navigating away from the page. * * @since 3.0.4 * * @param {object} story jQuery object of the story element. * @param {object} link jQuery object of the clicked link. * @param {object} event GA tracking parameters. */ public static track_click( story, link, event ): void { let left_click = event.which == 1; if ( left_click ) { event.preventDefault(); } let yq_id = story.data( `yqId` ), yq_title = Yq.titleTrim( story.data( `yqTitle` ) ), yq_url = story.data( `yqUrl` ), link_url = link.attr( `href` ), ga_obj = event.data.ga || null, ga_handler = event.data.ga_handler || null; Yq.yq_panel_click( yq_url, yq_title, yq_id ); // Get GA object and handler from override function. if ( event.data.ga_override && typeof window[ event.data.ga_override ] != 'undefined' ) { let ga_override = window[ event.data.ga_override ]( story, link, ga_obj, ga_handler ); ga_obj = ga_override.obj || ga_obj; ga_handler = ga_override.handler || ga_handler; } // Check if GA object exists, then send event. if ( ga_obj && ga_handler && typeof window[ ga_obj ] != 'undefined' ) { let ga_data = { hitType: `event`, eventCategory: `Articles`, eventAction: `Youneeq View`, eventLabel: yq_url }; // Send GA event using beacon API if supported by the browser. if ( `sendBeacon` in navigator ) { ga_data.transport = `beacon`; window[ ga_obj ]( ga_handler, ga_data ); if ( left_click ) { window.location = link_url; } } else if ( left_click ) { let do_nav = function() { window.location = link_url; }; ga_data.hitCallback = do_nav; window[ ga_obj ]( ga_handler, ga_data ); window.setTimeout( do_nav, 1000 ); } else { window[ ga_obj ]( ga_handler, ga_data ); } } else if ( left_click ) { window.location = link_url; } } /** * Registers event handler to detect user scrolling to the bottom of the recommendation element. * * @since 3.0.5 Now tracks user position on page relative to the recommendation container. * @since 3.0.3 * * @param {YouneeqHandler} self Youneeq handler object to initialize scrolling for. * @param {object} page jQuery object containing the window object. */ public static register_scroll_handler( self, page ): void { if ( !self.use_scroll_handler ) { self.use_scroll_handler = true; let box = jQuery( self.box ); page.on( `scroll.yq`, function( e ) { if ( self.scroll_ready && ( box.offset().top + box.height() - page.scrollTop() - page.height() ) < self.scrolling.offset ) { self.scroll_ready = false; page.trigger( `yq:scrollBottom${ self.id_num }` ); window.setTimeout( function( self ) { self.scroll_ready = true; }, self.scrolling.cooldown, self ); } }); } } /** * Re-initializes suggest parameters and sends another recommendation request. * * @since 1.2.0 * * @param {string[]} tags List of tags specifying request context. */ public refresh( tags: string[] = [] ): void { this.suggest = {}; this.init_all_data().request( tags ); } /** * Send a Youneeq request. * * @since 1.0.0 * * @return {YouneeqHandler} * @param {string[]} tags List of tags specifying request context. */ public request( tags: string[] = [] ): this { if ( !this.is_loading ) { this.is_loading = true; let data = {}, can_observe = false, request_method = window.yq_sent_request ? Yq.observeMin : Yq.observe; window.yq_sent_request = true; for ( let i = 0, max = tags.length; i < max; i++ ) { if ( `observe` == tags[ i ] ) { can_observe = true; } } if ( this.content_id ) { data.content_id = this.content_id; } if ( this.content_type ) { data.content_type = this.content_type; } if ( this.alt_href ) { data.alt_href = this.alt_href; } if ( Object.keys( this.suggest ).length ) { data.suggest = [ this.suggest ]; } if ( can_observe && Object.keys( this.observe ).length ) { data.observe = [ this.observe ]; } request_method( data, this._populate( tags, `ajax_display` in this ) ); } return this; } /** * Generate recommended article HTML and display it on the page. * * @since 1.0.0 * * @param {YqResultsList} response Returned data from Youneeq request. * @param {string[]} tags List of tags specifying request context. */ public display( response: YqResultsList, tags: string[] ): void { if ( response && response.suggest && response.suggest.node ) { let stories = response.suggest.node, $box = jQuery( this.box ); for ( let i = 0, max = stories.length; i < max; i++ ) { let id = stories[ i ].id ? stories[ i ].id : ``, title = stories[ i ].title ? stories[ i ].title : ``, url = stories[ i ].url ? stories[ i ].url : ``, img = stories[ i ].image ? stories[ i ].image : ``, desc = stories[ i ].description ? stories[ i ].description : ``; $box.append( `
${ img ? `${ title }` : `` }

${ title }

${ desc ? `

${ desc }

` : `` }
` ); } } } /** * Call object initialization methods. * * @since 3.0.1 Added GA tracking initialization. * @since 1.0.0 * * @return {YouneeqHandler} */ private init_all_data( base_args ): this { let args = this.get_args( base_args ); return this.init_request_data( args ) .init_suggest_data( args ) .init_observe_data( args ) .init_tracking( args ) .init_scrolling( args ) .init_method_overrides( args ) .init_features( args ) .init_handlers( args ); } /** * Get element arguments from data attributes. * * @since 1.0.0 * * @return {object} Arguments object. */ private get_args( base_args ): object { let args = base_args ? base_args : {}; for ( let i = 0, max = this.box.attributes.length; i < max; i++ ) { let arg_name = this.box.attributes[ i ].name; if ( arg_name.substr( 0, 8 ) == `data-yq-` ) { args[ arg_name.substr( 8 ).replace( /-/g, `_` ) ] = this.box.attributes[ i ].value; } } return args; } /** * Collect basic request data. * * @since 1.0.0 * * @return {YouneeqHandler} * @param {object} args Arguments object. */ private init_request_data( args: object ): this { if ( `content_id` in args ) { this.content_id = args.content_id; } if ( `alt_href` in args ) { this.alt_href = args.alt_href; } else { this.alt_href = YouneeqHandler.get_og_tag( `url` ); } if ( `content_type` in args ) { this.content_type = args.content_type; } return this; } /** * Collect suggest request data. * * @since 1.2.0 Suggest data function now has the current * YouneeqHandler instance passed as a parameter. * @since 1.0.0 * * @return {YouneeqHandler} * @param {object} args Arguments object. */ private init_suggest_data( args: object ): this { if ( args.count || args.suggest_count || args.suggest_function ) { let count = 0, data = {}; if ( `suggest_function` in args && args.suggest_function in window ) { data = window[ args.suggest_function ]( this ); } if ( `name` in data ) { this.content_id = data.name; } if ( `count` in data ) { count = data.count; } else if ( `count` in args ) { count = args.count; } else if ( `suggest_count` in args ) { count = args.suggest_count; } if ( count ) { this.suggest = { type: `node`, count: `${ count }`, is_panel_detailed: `true`, }; if ( `categories` in data ) { this.suggest.categories = data.categories; } else if ( `suggest_categories` in args ) { this.suggest.categories = YouneeqHandler.split( args.suggest_categories ); } if ( `domains` in data ) { if ( data.domains === true || data.domains === false ) { this.suggest.isAllClientDomains = `true`; } else { this.suggest.domains = data.domains; } } else if ( `suggest_domains` in args ) { if ( args.suggest_domains == `true` ) { this.suggest.isAllClientDomains = `true`; } else { this.suggest.domains = YouneeqHandler.split( args.suggest_domains ); } } else { this.suggest.isAllClientDomains = `false`; } if ( `date_start` in data ) { this.suggest.date_start = data.date_start; } else if ( `suggest_date_start` in args ) { this.suggest.date_start = new Date( args.suggest_date_start ).toISOString(); } if ( `date_end` in data ) { this.suggest.date_end = data.date_end; } else if ( `suggest_date_end` in args ) { this.suggest.date_end = new Date( args.suggest_date_end ).toISOString(); } if ( `panel_custom` in data ) { this.suggest.panel_custom = data.panel_custom; } else if ( `suggest_panel_custom` in args ) { this.suggest.panel_custom = YouneeqHandler.split( args.suggest_panel_custom ); } if ( `panel_type` in data ) { this.suggest.panel_type = data.panel_type; } else if ( `suggest_panel_type` in args ) { this.suggest.panel_type = args.suggest_panel_type; } if ( `options` in data ) { this.suggest.options = data.options; } else if ( `suggest_options` in args ) { this.suggest.options = YouneeqHandler.split( args.suggest_options ); } } } return this; } /** * Collect observe request data. * * @since 1.2.0 Observe data function now has the current * YouneeqHandler instance passed as a parameter. * @since 1.0.0 * * @return {YouneeqHandler} * @param {object} args Arguments object. */ private init_observe_data( args: object ): this { if ( args.observe || args.observe_function ) { let title = ``, data = {}; if ( `observe_function` in args ) { data = window[ args.observe_function ]( this ); } if ( `name` in data ) { this.content_id = data.name; } if ( `observe` in data && ! data.observe ) { return this; } if ( `title` in data ) { title = data.title; } else if ( `observe_title` in args ) { title = args.observe_title; } else { title = YouneeqHandler.get_og_tag( `title` ); } if ( this.content_id && title ) { this.observe = { type: `node`, title: title }; if ( `url` in data ) { this.alt_href = data.url; } if ( `image` in data ) { this.observe.image = data.image; } else if ( `observe_image` in args ) { this.observe.image = args.observe_image; } else { this.observe.image = YouneeqHandler.get_og_tag( `image` ); } if ( `description` in data ) { this.observe.description = data.description; } else if ( `observe_description` in args ) { this.observe.description = args.observe_description; } else { this.observe.description = YouneeqHandler.get_og_tag( `description` ); } if ( `create_date` in data ) { this.observe.create_date = data.create_date; } else if ( `observe_date` in args ) { this.observe.create_date = new Date( args.observe_date ).toISOString(); } else { let date = YouneeqHandler.get_meta_tag( `article:published_time`, `date` ); this.observe.create_date = date instanceof Date ? date.toISOString() : date; } if ( `categories` in data ) { this.observe.categories = data.categories; } else if ( `observe_categories` in args ) { this.observe.categories = YouneeqHandler.split( args.observe_categories ); } if ( `tags` in data ) { this.observe.tags = data.tags; } else if ( `observe_tags` in args ) { this.observe.tags = YouneeqHandler.split( args.observe_tags ); } if ( `content_type` in data ) { this.content_type = data.content_type; } } } return this; } /** * Set up Google Analytics tracking parameters. * * @since 3.0.4 Added runtime override function. * @since 3.0.1 * * @return {YouneeqHandler} * @param {object} args Arguments object. */ private init_tracking( args: object ): this { // GA object to be used. if ( args.ga_function ) { this.tracking.ga = args.ga_function; } else { this.tracking.ga = `ga`; } // Name of GA tracker to use (without '.send' appended at the end). if ( args.ga_tracker ) { this.tracking.ga_tracker = args.ga_tracker; } else { this.tracking.ga_tracker = null; } // Name of GA runtime override function. if ( args.ga_override_function ) { this.tracking.ga_override = args.ga_override_function; } else { this.tracking.ga_override = null; } return this; } private init_scrolling( args: object ): this { if ( args.scroll_offset ) { let offset = parseInt( args.scroll_offset ); this.scrolling.offset = !isNaN( offset ) ? offset : 300; } else { this.scrolling.offset = 300; } if ( args.scroll_cooldown ) { let cooldown = parseInt( args.scroll_cooldown ); this.scrolling.cooldown = !isNaN( cooldown ) ? cooldown : 3000; } else { this.scrolling.cooldown = 3000; } return this; } /** * Set up method overrides. * * @since 1.0.0 * * @return {YouneeqHandler} * @param {object} args Arguments object. */ private init_method_overrides( args: object ): this { if ( args.ajax_display_function ) { this.ajax_display = window[ args.ajax_display_function ]; } else if ( args.display_function ) { this.display = window[ args.display_function ]; } return this; } /** * Activate optional recommendation functionality. * * @since 1.0.0 * * @return {YouneeqHandler} * @param {object} args Arguments object. */ private init_features( args: object ): this { if ( args.features ) { this.features = YouneeqHandler.split( args.features ); } return this; } /** * Set up event handlers. * * @since 1.0.0 * * @return {YouneeqHandler} * @param {object} args Arguments object. */ private init_handlers( args: object ): this { let $box = jQuery( this.box ), disable_ga_tracking = false; for ( let i = 0, max = this.features.length; i < max; i++ ) { switch ( this.features[ i ] ) { case `no-google-analytics`: disable_ga_tracking = true; break; case `gigya`: this.send_gigya_data(); break; case `infinite-scroll`: this.setup_infinite_scroll(); break; case `google-analytics`: break; default: window.console.warn( `YouneeqHandler: ${ this.features[ i ] } is not a recognized feature` ); } } $box.one( `yq:populateAttach`, { disable_ga_tracking: disable_ga_tracking, tracking_args: this.tracking }, this.attach_click_tracking ); return this; } /** * Attaches infinite scroll handler. * * @since 3.0.5 Now adds separate scroll handler for each instance. * @since 3.0.3 Separated scroll detection into separate function. * @since 1.0.0 * * @return {YouneeqHandler} */ private setup_infinite_scroll(): this { let page = jQuery( window ), self = this; YouneeqHandler.register_scroll_handler( self, page ); page.on( `yq:scrollBottom${ this.id_num }`, function() { self.request( [ `scroll` ] ); }); return this; } /** * Attaches click tracking to each displayed article. * * @since 3.0.4 Added GA tracker runtime override. * @since 3.0.2 Removed GA tracker auto-detection. * @since 3.0.1 Added support for manually setting GA tracker. * @since 1.0.0 * * @param {object} event jQuery event object. * @param {YqResultsList} response Returned data from Youneeq request. * @param {string[]} tags List of tags specifying request context. */ private attach_click_tracking( event: object, response: YqResultsList, tags: string[] ): void { let ga_handler = event.data.tracking_args.ga_tracker ? `${event.data.tracking_args.ga_tracker}.send` : `send`; jQuery( this ).on( `mousedown.yq`, `.yq-article a:not(.no-yq-tracking)`, { ga: event.data.tracking_args.ga, ga_handler: ga_handler, ga_override: event.data.tracking_args.ga_override }, function( e ) { let link = jQuery( this ); YouneeqHandler.track_click( link.parents( `.yq-article` ), link, e ); }) .on( `mousedown.yq`, `a.yq-article:not(.no-yq-tracking)`, { ga: event.data.tracking_args.ga, ga_handler: ga_handler, ga_override: event.data.tracking_args.ga_override }, function( e ) { let link = jQuery( this ); YouneeqHandler.track_click( link, link, e ); }); } /** * Sends Gigya user profile to the server. * * @since 1.2.0 Updated to current Youneeq IDM specifications. * @since 1.0.0 * * @param {number} retry Number of times to retry if Gigya is not yet ready. */ private send_gigya_data( retry: number = 1 ): void { if ( gigya.isReady ) { gigya.socialize.getUserInfo({ callback: response => { if ( response.errorCode == 0 ) { let user = response.user, fields = [ `birthDay`, `birthMonth`, `birthYear`, `city`, `country`, `email`, `firstName`, `gender`, `lastName`, `loginProvider`, `loginProviderUID`, `nickname`, `providers`, `state`, `zip` ], data = { idm: { id: user.ID, profile: { UID: user.ID } } }; for ( let field of fields ) { if ( field in user ) { data.idm.profile[ field ] = user[ field ]; } } Yq.observeMin( data, jQuery.noop ); } } }); } else if ( retry > 0 ) { window.setTimeout( retry => { this.send_gigya_data( retry ); }, 5000, retry - 1 ); } } /** * Attempts to split a string into an array of strings. * * @since 1.1.0 * * @return {string[]} Array of strings split from subject. * @param {string} subject String to split. */ private static split( subject: string ): string[] { let pipe_matches = subject.match( /\|/g ), pipes = pipe_matches ? pipe_matches.length : 0; if ( pipes ) { return subject.split( `|` ); } else { return subject.split( `,` ); } } /** * Gets the content of a meta element on the page. * * Normally returns a string, but may return a Date if 'date' is passed as the value for "format." * * @since 1.2.0 * * @return {string|Date|null} Formatted meta tag content. * @param {string} name Name of the meta tag. * @param {string} format If "date" is passed, a Date object will be returned. * @param {string} id Name of the HTML attribute containing the tag name. * @param {string} value Name of the HTML attribute containing the tag content. */ private static get_meta_tag( name: string, format?: string, id: string = `property`, value: string = `content` ): string|Date|null { let tag = jQuery( `meta[${ id }="${ name }"]` ), result = null; switch ( format ) { case `date`: result = tag.length ? new Date( tag.attr( value ) ) : null; break; default: result = tag.length ? tag.attr( value ) : ``; } return result; } /** * Gets the content of an open graph tag on the page. * * @since 1.2.0 * * @return {string} * @param {string} name Name of the meta tag (with "og:" prefix omitted). */ private static get_og_tag( name: string ): string { return YouneeqHandler.get_meta_tag( `og:${ name }` ) as string; } /** * Return a function that triggers populate events and displays recommendations. * * @since 1.0.0 * * @return {Function} * @param {string[]} tags List of tags specifying request context. * @param {boolean} ajax True if ajax display method should be used */ private _populate( tags: string[], ajax: boolean = false ): Function { if ( ajax ) { return response => { this.is_loading = false; let $box = jQuery( this.box ); $box.trigger( `yq:populatePrepare`, [ response, tags ] ); this.ajax_display( response, tags, r => { $box.trigger( `yq:populateAttach`, [ r, tags ] ); }); }; } else { return response => { this.is_loading = false; let $box = jQuery( this.box ); $box.trigger( `yq:populatePrepare`, [ response, tags ] ); this.display( response, tags ); $box.trigger( `yq:populateAttach`, [ response, tags ] ); }; } } } interface YqResultsList { suggest: YqResultsListSuggest; meta_info: any[]; page_hit: boolean; submitted: boolean; } interface YqResultsListSuggest { node?: YqResultsStory[]; } interface YqResultsStory { id?: string; title?: string; url?: string; image?: string; description?: string; } // Automatically detect and initialize YouneeqHandler instances. jQuery( function() { if ( !jQuery( `html.yq-no-auto, body.yq-no-auto` ).length ) { YouneeqHandler.generate(); } });