Skip to content

Instantly share code, notes, and snippets.

@alies-dev
Created October 31, 2017 14:31
Show Gist options
  • Save alies-dev/2b2bad8347c7d0848188f586097fb200 to your computer and use it in GitHub Desktop.
Save alies-dev/2b2bad8347c7d0848188f586097fb200 to your computer and use it in GitHub Desktop.
/**
* @module modules/courseAdInjector
* @description Inject advertisement block to the document using specified rules
* @example courseAdInjector.addCourseAd('.js-courseAd-area');
*/
define(['jquery', 'modules/countdown'], ($, Countdown) => {
/**
* @returns {Object}
* @private
*/
function getDefaultOptions() {
return {
nthElementPosition: 6,
minWordsInElement: 60,
immediateChildrenOnly: true,
elementType: 'P',
mixPixelsBetweenTwoAds: Math.max(window.screen.height * 2, 1000),
};
}
/**
* Merge custom options with default
*
* @private
* @param {Object} customOptions
* @returns {Object}
*/
function getOptions(customOptions) {
customOptions = customOptions || {};
const options = $.extend({}, getDefaultOptions(), customOptions);
options.elementType = options.elementType.toUpperCase();
return options;
}
/**
* Calculate and return number of words in a given string
* @param {string} str
* @returns {number} Number of words in the string
* @private
*/
function countWords(str) {
str = str.replace(/(^\s*)|(\s*$)/gi, ''); // exclude start and end white-space
str = str.replace(/[ ]{2,}/gi, ' '); // 2 or more space to 1
str = str.replace(/\n /, '\n'); // exclude newline with a start spacing
return str.split(' ').length;
}
/**
* Calculate and return number of words in a given HTMLElement
* @private
* @param {HTMLElement} element
* @returns {number} Number of words in HTMLElement
*/
function countWordsInElement(element) {
const $element = $(element);
const text = $element.text();
return countWords(text);
}
/**
* Returns an array of HTML elements which satisfy the individual conditions
* and next sibling is also satisfy the individual conditions.
* E.g. if we have A + B + C elements in a DOM tree (located in a row),
* and all of them are satisfy the individual conditions, then this method
* returns [A,B, C] array.
* Thus, ad can be injected right AFTER A, B or C elements.
*
* @private
* @param {string} holderSelector
* @param {Object} options
* @returns {Array<HTMLElement>}
*/
function getPreviousSiblings(holderSelector, options) {
/**
* REQUIREMENT SPECIFICATIONS FOR PLACING ADS:
*
* - There should be at least 6 paragraphs before course ads placed.
* - It should be as simple as possible to change the number of paragraphs needed before the course ads placed.
* Thus, if we want to change the minimum number of paragraphs needed before we can place course ads in the
* future, we should be able to do it easily.
* - Course ads should be placed after paragraph (e.g, <p>TEXT</p>COURSE ADS.<anytag>TEXT</anytag>).
* - Paragraph before the course ads should be longer than 50 words each, or equivalent number of characters.
* - The course ads should not be shown to logged-in members.
* - If the conditions are not met, then course ads should NOT be placed.
*/
const $elements = $(holderSelector + (options.immediateChildrenOnly ? '>' : ' ') + options.elementType);
const elementsFiltered = [];
if ($elements.length < options.nthElementPosition) {
return elementsFiltered;
}
$elements.each((index, element) => {
// filter out first nthElementPosition elements
if (index < options.nthElementPosition) {
return;
}
// filter out by previous siblings type/tag
const previousElementSibling = element.previousElementSibling;
if (previousElementSibling === null || previousElementSibling.tagName !== options.elementType) {
return;
}
// filter out by next siblings type/tag
const nextElementSibling = element.nextElementSibling;
if (nextElementSibling === null || nextElementSibling.tagName !== options.elementType) {
return;
}
// filter out by words count
const wordsInElementText = countWordsInElement(element);
if (wordsInElementText <= options.minWordsInElement) {
return;
}
elementsFiltered.push(element);
});
const elementsFilteredHaveFilteredSibling = [];
$.each(elementsFiltered, (index, element) => {
// filter out last element (which has no filtered nextElementSibling)
if (index === elementsFiltered.length + 1) {
return;
}
// select only elements that have filtered next sibling
const nextFilteredElement = elementsFiltered[index + 1];
if (element.nextElementSibling === nextFilteredElement) {
elementsFilteredHaveFilteredSibling.push(element);
}
});
return elementsFilteredHaveFilteredSibling;
}
/**
* Use API to get course ad block (in HTML).
* @see \IDF\Http\Controllers\Courses\CourseController::getCoursesAdCode
*
* @private
*
* @param {string} publicationType
* @param {string} publicationId
*
* @returns {Promise<{html: string}>}
*/
function getAdCode(publicationType, publicationId) {
return window
.fetch(`/courses/ad-code?publicationType=${publicationType}&publicationId=${publicationId}`, {
method: 'GET',
credentials: 'same-origin',
// cache: 'no-cache',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': window.csrfToken,
},
})
.then(response => response.json());
}
/**
* Generate a template for countdown
*
* @private
* @param {Date} endDate
* @returns {string} Template for Countdown
*/
function getCountdownTemplate(endDate) {
const now = new Date();
const diffInMilliSecs = endDate.getTime() - now.getTime();
// 1000 * 60 * 60 * 24 = 86400000 (1 day)
if (diffInMilliSecs > 86400000) {
return `<span>in <span class="day">
<span class="number">{days}</span>
<span class="unit">days</span></span>
</span>`;
}
// 1000 * 60 * 60 = 3600000 (1hour)
if (diffInMilliSecs > 3600000) {
return `<span>in
<span class="hour"><span class="number">{hours}</span>
<span class="unit">hrs</span></span>&nbsp;<span class="minute"><span class="number">{minutes}</span>
<span class="unit">mins</span></span>&nbsp;<span class="second"><span class="number">{seconds}</span>
<span class="unit">secs</span></span>
</span>`;
}
// 1000 * 60 = 60000 (1min)
if (diffInMilliSecs > 60000) {
return `<span>in <span class="minute"><span class="number">{minutes}</span>
<span class="unit">mins</span>&nbsp;</span><span class="second"><span class="number">{seconds}</span>
<span class="unit">secs</span></span>
</span>`;
}
// 1000 (1sec)
if (diffInMilliSecs > 1000) {
return `<span>in <span class="second"><span class="number">{seconds}</span> <span class="unit">secs</span>
</span></span>`;
}
return `<span>Enrollment is closed</span>`;
}
/**
* Inject Ad block and run countdowns for courses
*
* @private
* @param {jQuery} $adBlock
* @param {jQuery} $siblingPrevious
*/
function injectAdAndRunCountdowns($adBlock, $siblingPrevious) {
$siblingPrevious.after($adBlock);
// run countdown for each course in ad block
const clocks = $('.js-schedule-period', $adBlock);
$.each(clocks, (index, clock) => {
const $courseRow = $(clock).closest('.js-courseAdItem');
const endDate = new Date($courseRow.data('startTimestamp') * 1000);
const $clockElement = $courseRow.find('.js-schedule-period');
new Countdown({
// eslint-disable-line no-new
selector: `#${$clockElement.attr('id')}`,
dateEnd: endDate,
msgAfter: '',
msgPattern: getCountdownTemplate(endDate),
onEnd: () => {
$courseRow.hide();
},
});
});
}
/**
* Determenate whether Ad block can be added to the given element.
* @param {HTMLElement} element HTMLElement to potentially inject ad
* @param {Array} previousSiblingsWithAd Previous Siblings With Ad
* @param {number} viewportSize Minimum space between 2 different ad blocks
* @returns {boolean}
*/
function getIsAdCanBeInjectedToViewport(element, previousSiblingsWithAd, viewportSize) {
if (previousSiblingsWithAd.length === 0) {
return true;
}
const lastElementHasAdAfter = previousSiblingsWithAd[previousSiblingsWithAd.length - 1];
const previousAdBlock = lastElementHasAdAfter.nextElementSibling;
const previousAdBlockBottomCoordinate = previousAdBlock.offsetTop + previousAdBlock.offsetHeight;
return element.offsetTop > previousAdBlockBottomCoordinate + viewportSize;
}
/**
* Finds qualifying previous siblings for ad elements and (if any) get ad code using ajax and add it
* after the first qualifying sibling
*
* @param {string} holderSelector
* @param {string} publicationType
* @param {string} publicationId
* @param {Object} customOptions see full list in this::getDefaultOptions()
*/
function addCourseAd(holderSelector, publicationType, publicationId, customOptions) {
const options = getOptions(customOptions);
const previousSiblings = getPreviousSiblings(holderSelector, options);
if (!previousSiblings.length) {
return;
}
getAdCode(publicationType, publicationId).then(parsedJsonResponseData => {
const previousSiblingsWithAd = [];
$.each(previousSiblings, (index, previousSibling) => {
const isAdCanBeInjectedToViewport = getIsAdCanBeInjectedToViewport(
previousSibling,
previousSiblingsWithAd,
options.mixPixelsBetweenTwoAds
);
if (!isAdCanBeInjectedToViewport) {
// skip current element because ad is already injected exist in viewport
return;
}
const $adBlock = $(parsedJsonResponseData.html);
injectAdAndRunCountdowns($adBlock, $(previousSibling));
previousSiblingsWithAd.push(previousSibling);
});
});
}
return /** @alias module:modules/courseAdInjector */ {
/**
* Finds qualifying previous siblings for ad elements and (if any) get ad code using ajax and add it
* after the first qualifying sibling
*
* @method
* @param {string} holderSelector
* @param {Object} customOptions see full list in this::getDefaultOptions()
*/
addCourseAd: addCourseAd,
};
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment