Home Reference Source Test Repository

src/components/article/article_component.js

import { Component } from "../../core/bane";
import $ from "jquery";
import debounce from "lodash/function/debounce";
import ArticleBodyComponent from "../article_body";
import SocialShareComponent from "../social_share";
import track from "../../core/decorators/track";
import publish from "../../core/decorators/publish";
import waitForTransition from "../../core/utils/waitForTransition";
import ArticleModel from "./article_model";
import rizzo from "../../rizzo";
import subscribe from "../../core/decorators/subscribe";

export default class ArticleComponent extends Component {
  initialize() {
    this.canUseScrollFeature = window.history && window.history.pushState;

    this._resetWindowScrollPosition();

    this.$document = $(document);
    this.$window = $(window);
    this.$footer = $(".footer");

    this.isNextArticleLoading = false;
    this.howManyArticlesHaveLoaded = 1;

    this.template = require("./article.hbs");
    this.loader = require("./article-loading.hbs");
    this.adLeaderboardTemplate = require("../ads/ad_article_leaderboard.hbs");

    this.articles = new Map();
    this.viewedArticles = [];
    this.listOfArticles = [];

    this._setFirstArticle();
    this.subscribe();
  }

  @subscribe("ad.loaded", "ads");
  _removeEmptySponsorAd(data) {
    if (data.size === "sponsor-logo") {
      let $article = this.$newArticle ? this.$newArticle : this.$el;
      let $sponsorLogo = $article.find(".js-sponsor-logo");

      if ($sponsorLogo.length && $sponsorLogo.hasClass("display-none")) {
        $sponsorLogo
          .closest(".js-article-sponsor-ad")
          .remove();
      }
    }

    if (data.size === "leaderboard-responsive") {
      this.adLoadedPromise && this.adLoadedPromise();
    }
  }

  /**
   * Reset the window's previous scroll position when the page loads
   */
  _resetWindowScrollPosition() {
    window.onunload = function() {
      $(window).scrollTop(0);
    };
  }

  /**
   * Take a given string and turn it into a slug
   * @param  {String} string String to slugify
   * @return {String}
   */
  _slugify(string) {
    if (!string) return "";

    return string.toLowerCase().replace(" ", "-");
  }

  /**
   * Set the first article
   */
  _setFirstArticle() {
    this.$activeArticle = this.$el.addClass("is-active");

    // Add the first article to the list of viewed articles
    this.viewedArticles.push({
      slug: this.$el.data("slug"),
      title: this.$el.data("title")
    });

    this.socialShare = new SocialShareComponent({
      el: this.$el.find(".js-action-sheet")
    });

    let firstArticle = new ArticleModel({
      url: `${window.location.pathname}.json`
    });

    firstArticle.fetch().then(() => {
      this.articles.set(this.$el[0], firstArticle);
      this._setInitialListOfArticles(firstArticle.get("related_articles").articles);
      this._setInitialCallouts(firstArticle.get("content").callouts);
    }, () => {
      rizzo.logger.error(`Unable to fetch ${window.location.pathname}.json`);
    });
  }

  _setInitialCallouts(callouts) {
    this.articleBody = new ArticleBodyComponent({
      el: this.$el.find(".js-article-body"),
      poiData: callouts
    });
  }

  _setInitialListOfArticles(articles) {
    this.listOfArticles = articles;
    this._setNextArticle();

    if (this.canUseScrollFeature) {
      let roomToScroll = this._getRoomToScroll(),
          amountNeededToScroll = this._getAmountNeededToScroll();

      if (roomToScroll < amountNeededToScroll) {
        this._scrollToNextArticle(amountNeededToScroll - roomToScroll);
      } else {
        this._scrollToNextArticle();
      }
    }
  }

  /**
   * Sets the next article by subtracting one from the number of articles loaded
   */
  _setNextArticle() {
    this.nextArticle = this.listOfArticles[this.howManyArticlesHaveLoaded - 1];
  }

  /**
   * Runs methods when scrolling
   */
  _scrollToNextArticle(offsetDifference = 0) {
    this.$window.on("scroll.article", debounce(() => {
      if (this._shouldGetNextArticle(offsetDifference)) {
        if (!this.isNextArticleLoading) {
          if (this.nextArticle) {
            this._getNextArticle(`/${this.nextArticle.slug}.json`);
          }
        }
      }

      this._setActiveArticle();
      this._checkIfHistoryShouldBeUpdated();
    }, 10));
  }

  _setActiveArticle() {
    this.articles.forEach((model, $article) => {
      this._toggleActiveClassForArticle($article);
    });
  }

  _shouldGetNextArticle(difference) {
    let amountNeededToScroll = this._getAmountNeededToScroll();

    return this.$window.scrollTop() >= (amountNeededToScroll - difference);
  }

  /**
   * Return how much room is available to scroll
   * @return {Number}
   */
  _getRoomToScroll() {
    return this.$document.height() - this.$window.height();
  }

  /**
   * Return the amount needed to scroll in order to load a new article
   * @return {Number}
   */
  _getAmountNeededToScroll() {
    let roomToScroll = this._getRoomToScroll(),
        amountToScrollPastEndOfArticle = 100;

    return roomToScroll - this.$footer.height() + amountToScrollPastEndOfArticle;
  }

  /**
   * Check scroll top against each value in the map and add or remove the active
   * class to the `$article` element
   * @param  {Object} $article The article object from the map
   */
  _toggleActiveClassForArticle($article) {
    if (this.$window.scrollTop()) {
      let top = $article.offsetTop,
          bottom = top + $article.offsetHeight,
          shouldActiveClassBeAdded = this.$window.scrollTop() < bottom && this.$window.scrollTop() > top;

      if (shouldActiveClassBeAdded) {
        this.$activeArticle = $($article).addClass("is-active");
      } else {
        $($article).removeClass("is-active");
      }
    }
  }

  /**
   * Find the active article and update the browser history
   */
  _checkIfHistoryShouldBeUpdated() {
    if (this.$activeArticle.hasClass("is-active")) {
      this._updateHistory(
        window.location.pathname,
        this.$activeArticle.data("title"),
        this.$activeArticle.data("slug")
      );
    }
  }

  /**
   * Loops through a given array and compares each slug in the given array with
   * a predefined slug that's passed in.
   * @param  {Array}  array An array of articles to loop through
   * @param  {String} slug  A slug to compare each item of the array against
   * @return {Boolean}
   */
  _doesItemExist(array, slug) {
    let exists = false;

    for (let i = 0; i < array.length; i++) {
      exists = slug === array[i].slug;

      if (exists) {
        break;
      }
    }

    return exists;
  }

  /**
   * Use an AJAX call to get data for a new article
   * @param {String} slug Pathname of article to get
   */
  _getNextArticle(slug) {
    this.isNextArticleLoading = true;

    this.$loader = $(this.loader({}))
      .insertAfter(this.$activeArticle);

    let nextArticle = new ArticleModel({ url: slug });

    nextArticle.fetch().then(() => {
        this.$newArticle = $(this.template({
          article: nextArticle.get()
        }))
        .appendTo(".page-container")
        .addClass("is-loading");

      // Set the new article element and data to the articles map
      this.articles.set(this.$newArticle[0], nextArticle);
      nextArticle.set("articleNumber", this.articles.size);

      this._addNewArticlesToArray(nextArticle.get("related_articles").articles);
      this._updateNewArticle(nextArticle);

      this.isNextArticleLoading = false;

      new Promise(resolve => this.adLoadedPromise = resolve)
        .then(() => {
          this._hideLoader({ showArticle: true });
        });
    }, () => {
      this.isNextArticleLoading = false;
      this._hideLoader({ showArticle: false });
    });
  }

  _hideLoader(options) {
    this.$loader.addClass("is-invisible");

    return waitForTransition(this.$loader, { fallbackTime: 1000 })
      .then(() => {
        this.$loader.remove();

        if (options.showArticle) {
          this.$newArticle.removeClass("is-loading");
        }
      });
  }

  /**
   * Updates a newly created article
   */
  _updateNewArticle(model) {
    this.articleBody = new ArticleBodyComponent({
      el: this.$newArticle.find(".article-body"),
      poiData: model.get("content").callouts
    });

    this.socialShare = new SocialShareComponent({
      el: this.$newArticle.find(".js-action-sheet")
    });

    this.howManyArticlesHaveLoaded += 1;

    this._setNextArticle();
    this._checkIfHistoryShouldBeUpdated();
    this._newArticleLoaded(model);
  }

  /**
   * Finds the previously viewed article and adds it to the array of viewed
   * articles
   */
  _updateListOfViewedArticles() {
    let previousArticle = this.listOfArticles[this.howManyArticlesHaveLoaded - 2];

    if (previousArticle) {
      this.viewedArticles.push(previousArticle);
    }
  }

  /**
   * Array of new items to add; loop through the new array of articles and check
   * that each item doesn't already exist in the `viewedArticles` array or the
   * `listOfArticles` array; push each unique item to the `listOfArticles` Array
   * @param {Array} array Array of new articles to add
   */
  _addNewArticlesToArray(array) {
    for (let i = 0; i < array.length; i++) {
      let slug = array[i].slug,
          hasItemBeenViewed = this._doesItemExist(this.viewedArticles, slug),
          isItemInList = this._doesItemExist(this.listOfArticles, slug);

      if (!hasItemBeenViewed && !isItemInList) {
        this.listOfArticles.push(array[i]);
      }
    }
  }

  /**
   * Use HTML5 pushState to update the browser's history
   * @param {String} pathname The window's current pathname
   * @param {String} title    Title of the new "page"
   * @param {String} slug     Pathname of the new "page"
   */
  _updateHistory(pathname, title, slug) {
    if (pathname !== `/${slug}`) {
      history.pushState(null, `${title} - Lonely Planet`, `/${slug}`);

      this._updateData();

      if(!this._doesItemExist(this.viewedArticles, slug)) {
        this._trackAjaxPageView(`/${slug}`, title);
        this._updateListOfViewedArticles();
      }
    }
  }

  /**
   * Track a virtual pageview for analytics
   * @param  {String} pathname Pathname to send to analytics
   * @param  {String} title    Title to send to analytics
   * @return {String}          Data to send to analytics
   */
  @track("article pageview scroll");
  _trackAjaxPageView(pathname, title) {
    utag.view({
      ga_location_override: pathname,
      title: `${title} - AJAX`
    });

    return pathname;
  }

  /**
   * Update data for ads and analytics
   */
  _updateData() {
    let article = this.articles.get(this.$activeArticle[0]).get(),
        interests = article.tealium.article.interests,
        categories = [],
        regex = /,\s*$/;

    window.lp.article = {
      name: article.title,
      slug: article.slug,
      image: article.image,
      postDate: article.post_date,
      author: article.author,
      atlasId: article.tealium.article.atlas_id,
      continentName: article.tealium.article.cd1_Continent,
      countryName: article.tealium.article.cd2_Country,
      cityName: article.tealium.article.cd3_City,
      type: article.tealium.article.page_type,
      siteSection: article.tealium.article.site_section,
      id: article.tealium.place.id,
      destination: article.tealium.place.destination
    };

    if (typeof interests === "object") {
      window.lp.article.interests = interests.join(", ").replace(regex, "");
    } else {
      window.lp.article.interests = interests.replace(regex, "");
    }

    if (typeof article.categories === "object") {
      $.each(article.categories, (index, value) => {
        categories.push(value.name);
      });

      window.lp.article.categories = categories.join(", ").replace(regex, "");
    } else {
      window.lp.article.categories = article.categories;
    }

    window.lp.ads.tnm = `tip-article, ${article.tealium.place.id}`;
    window.lp.ads.continent = article.tealium.article.cd1_Continent ? this._slugify(article.tealium.article.cd1_Continent) : "";
    window.lp.ads.country = article.tealium.article.cd2_Country ? this._slugify(article.tealium.article.cd2_Country) : "";
    window.lp.ads.destination = this._slugify(article.tealium.place.destination);
    window.lp.ads.state = this._slugify(article.tealium.place.state_name);
    window.lp.ads.region = this._slugify(article.tealium.place.region_name);
    window.lp.ads.city = this._slugify(article.tealium.article.cd3_City);
    window.lp.ads.interest = window.lp.article.interests;
    window.lp.ads.position = `article-${article.articleNumber}`;


    this._updateMetaData(window.lp.article);
  }

  /**
   * Update meta data
   * @param {Object} article Article data from window.lp.article
   */
  _updateMetaData(article) {
    let documentTitle = `${article.name} - Lonely Planet`,
        description = `Read ${article.name}`,
        url = `https://www.lonelyplanet.com/${article.slug}`;

    // Title
    document.title = documentTitle;
    $("meta[name=title]").attr("content", documentTitle);
    $(`meta[property="og:title"]`).attr("content", documentTitle);

    // Description
    $("meta[name=description]").attr("content", description);
    $("meta[itemprop=description]").attr("content", description);
    $(`meta[property="og:description"]`).attr("content", description);

    // URL
    $(`meta[property="og:url"]`).attr("content", url);
    $("link[rel=canonical]").attr("href", url);

    // Image
    $("meta[itemprop=image]").attr("content", article.image);
    $(`meta[property="og:image"]`).attr("content", article.image);

    // Article
    $(`meta[property="article:tag"]`).attr("content", article.categories);
    $(`meta[property="article:published_time"]`).attr("content", article.postDate);
    $(`meta[property="article:author"]`).attr("content", article.author);
  }

  @publish("reload", "ads")
  _newArticleLoaded() {
    this.$newArticle.prepend(this.adLeaderboardTemplate());

    let $slotLeader = this.$newArticle.find(".js-slot-leader");

    if ($slotLeader.length) {
      $slotLeader.data({
        adType: "ajax",
        targeting: null
      })
      .removeAttr("data-targeting");
    }
  }
}